Merge remote-tracking branch 'origin/master' into perf/tui-resume-scan

# Conflicts:
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/src/components/dialogs.ts
This commit is contained in:
Turtle
2026-08-03 17:06:28 +08:00
201 changed files with 5490 additions and 601 deletions

View File

@@ -28,6 +28,7 @@ import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
import { dialogSelectTheme, type Palette } from './theme.ts'
import type { ToolCardVisibility } from './transcript.ts'
import {
renderTuiPromptTemplate,
type TuiPromptTemplateToken,
@@ -426,6 +427,79 @@ export class ModelDialog implements Component {
}
}
/** Both transcript-detail dimensions, applied immediately on each Tab. */
export interface DetailsSelection {
readonly visibility: ToolCardVisibility
readonly showReasoning: boolean
}
const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden']
/**
* Keyboard toggle over the two transcript-detail entries — tool-card
* visibility and reasoning display. Tab cycles the highlighted entry's value
* and applies it immediately, so the transcript behind the dialog is the live
* preview; Enter, Esc, or Ctrl+C closes.
*/
export class DetailsDialog implements Component {
private readonly list: SelectList
private readonly toolsItem: SelectItem
private readonly reasoningItem: SelectItem
constructor(
private visibility: ToolCardVisibility,
private showReasoning: boolean,
private readonly palette: Palette,
private readonly apply: (selection: DetailsSelection) => void,
private readonly close: () => void,
) {
this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility }
this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() }
this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette))
this.list.onSelect = close
}
private reasoningLabel(): string {
return this.showReasoning ? 'shown' : 'hidden'
}
/** Cycle the highlighted entry one step and apply the new state. */
private cycle(): void {
const selected = this.list.getSelectedItem()
/* v8 ignore next -- the two-entry list always has a selection. */
if (selected === null) return
if (selected.value === 'tools') {
const index = TOOL_CARD_PHASES.indexOf(this.visibility)
this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility
this.toolsItem.description = this.visibility
} else {
this.showReasoning = !this.showReasoning
this.reasoningItem.description = this.reasoningLabel()
}
this.apply({ visibility: this.visibility, showReasoning: this.showReasoning })
}
invalidate(): void {
this.list.invalidate()
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close()
else if (matchesKey(data, Key.tab)) this.cycle()
else this.list.handleInput(data)
this.invalidate()
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - 4)
return renderDialog('Transcript details', [
...this.list.render(innerWidth),
'',
this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'),
], width, this.palette)
}
}
/** A resume selector row summarizing one session from metadata and its folded title. */
export interface ResumeCandidate {
record: SessionRecord

View File

@@ -149,20 +149,28 @@ export class UserMessageComponent extends Container {
}
}
/** Children of a settled assistant message: optional reasoning block then the response text. */
/**
* Children of a settled assistant message: optional reasoning block then the
* response text. A folded continuation (a later step of a turn while tool cards
* are hidden) drops the `Assistant` header and renders nothing when it has no
* visible body, so tool-only steps leave no blank segment behind.
*/
function assistantMessageChildren(
content: readonly ContentBlock[],
showReasoning: boolean,
foldedContinuation: boolean,
palette: Palette,
mdTheme: MarkdownTheme,
): Component[] {
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
const text = displayText(textBlocks(content, 'text').trim())
const children: Component[] = [
new Spacer(1),
new Text(messageHeader('Assistant', palette.accent, palette), 0, 0),
]
if (reasoning && showReasoning) {
const showsReasoning = reasoning !== '' && showReasoning
if (foldedContinuation && !showsReasoning && text === '') return []
const children: Component[] = [new Spacer(1)]
if (!foldedContinuation) {
children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0))
}
if (showsReasoning) {
children.push(
new Text(palette.italic(palette.dim('Reasoning')), 0, 0),
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }),
@@ -220,6 +228,7 @@ interface StreamingBlock {
export class StreamingAssistantComponent extends Container {
private readonly blocks = new Map<number, StreamingBlock>()
private settledContent: readonly ContentBlock[] | undefined
private foldedContinuation = false
/**
* The step's timing footer. The renderer keeps it at the tail of the chat so
* it trails any tool cards the step appends after this assistant message; it
@@ -228,7 +237,8 @@ export class StreamingAssistantComponent extends Container {
readonly timing: StepTimingComponent
constructor(
position: StepPosition,
/** The step's turn/step coordinates, used to group steps into their turn. */
readonly position: StepPosition,
events: () => readonly SessionEvent[],
now: () => number,
private showReasoning: boolean,
@@ -299,18 +309,49 @@ export class StreamingAssistantComponent extends Container {
this.rebuild()
}
private rebuild(): void {
this.clear()
const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()]
/**
* Mark this step as a folded continuation of its turn: no `Assistant` header,
* and no output at all while the step has no visible body. Used while tool
* cards are hidden so a turn reads as one assistant message.
* @param folded - Whether to render as a headerless continuation.
*/
setFoldedContinuation(folded: boolean): void {
if (this.foldedContinuation === folded) return
this.foldedContinuation = folded
this.rebuild()
}
/**
* Whether the step currently renders visible reasoning or text.
* @returns `true` when a header-owning render would show a body.
*/
hasVisibleBody(): boolean {
const content = this.presentedContent()
return textBlocks(content, 'text').trim() !== ''
|| (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '')
}
/** The settled content when available, otherwise the streamed blocks in model order. */
private presentedContent(): readonly ContentBlock[] {
return this.settledContent ?? [...this.blocks.entries()]
.sort(([left], [right]) => left - right)
.flatMap<ContentBlock>(([, block]) => {
if (block.type === 'text') return [{ type: 'text', text: block.text }]
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
return []
})
for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) {
this.addChild(child)
}
}
private rebuild(): void {
this.clear()
const children = assistantMessageChildren(
this.presentedContent(),
this.showReasoning,
this.foldedContinuation,
this.palette,
this.mdTheme,
)
for (const child of children) this.addChild(child)
}
}

View File

@@ -48,6 +48,8 @@ export interface TuiConfig {
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Transcript-details selector width in terminal columns. */
detailsDialogWidth?: number
/** Maximum fuzzy file candidates displayed for one `@` query. */
fileSearchMaxResults?: number
/** Maximum paths retained in one `@` workspace index. */
@@ -71,6 +73,7 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const detailsDialogWidthSchema = z.number().step(1).min(20).default(72)
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
@@ -102,6 +105,7 @@ const tuiConfigSchemaFields = {
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
detailsDialogWidth: detailsDialogWidthSchema,
fileSearchMaxResults: fileSearchMaxResultsSchema,
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
@@ -142,6 +146,7 @@ export const Config: z<Config> = z.object({
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth,
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
@@ -171,6 +176,7 @@ export interface ResolvedTuiConfig {
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
detailsDialogWidth: number
fileSearchMaxResults: number
fileSearchMaxEntries: number
fileSearchExcludedDirectories: string[]
@@ -196,6 +202,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 76,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
detailsDialogWidth: config?.detailsDialogWidth ?? 72,
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],

View File

@@ -105,6 +105,7 @@ import {
} from './components/transcript.ts'
import {
compactTargetLabel,
DetailsDialog,
diagnosticMeter,
formatDiagnosticCount,
formatDiagnosticNumber,
@@ -113,6 +114,7 @@ import {
StatusCardComponent,
PromptContextComponent,
targetLabel,
type DetailsSelection,
type StatusCardRow,
} from './components/dialogs.ts'
import {
@@ -337,6 +339,10 @@ export function createTuiChat(
let toolsVisibility: ToolCardVisibility = 'collapsed'
let streaming: StreamingAssistantComponent | undefined
let completedStreaming: StreamingAssistantComponent | undefined
// Assistant step components in model order per turn, for hidden-mode folding:
// with tool cards hidden, a turn keeps one Assistant header and later steps
// render as headerless continuations (see applyTurnFolding).
const assistantSteps = new Map<number, StreamingAssistantComponent[]>()
let runningStatus: RunningStatus | undefined
let fadingStatus: FadingStatus | undefined
/**
@@ -646,6 +652,35 @@ export function createTuiChat(
return card
}
/**
* Re-derive hidden-mode folding for one turn: the first step with a visible
* body owns the turn's single Assistant header, every other step renders as a
* headerless continuation (empty ones render nothing). Any other visibility
* restores the per-step headers.
*/
const applyTurnFolding = (turn: number): void => {
const steps = assistantSteps.get(turn)
if (steps === undefined) return
let headerSeen = false
for (const step of steps) {
if (toolsVisibility !== 'hidden') {
step.setFoldedContinuation(false)
} else if (!headerSeen && step.hasVisibleBody()) {
headerSeen = true
step.setFoldedContinuation(false)
} else {
step.setFoldedContinuation(true)
}
}
}
const registerAssistantStep = (component: StreamingAssistantComponent): void => {
const steps = assistantSteps.get(component.position.turn) ?? []
steps.push(component)
assistantSteps.set(component.position.turn, steps)
applyTurnFolding(component.position.turn)
}
const removeStreaming = (current: StreamingAssistantComponent | undefined): void => {
if (current === undefined) return
for (const child of [current, current.timing]) {
@@ -653,6 +688,15 @@ export function createTuiChat(
/* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */
if (index >= 0) chat.children.splice(index, 1)
}
const steps = assistantSteps.get(current.position.turn)
/* v8 ignore next -- every attached streaming component is registered in the fold map. */
if (steps === undefined) return
const index = steps.indexOf(current)
/* v8 ignore next -- registration precedes attachment, so the component is present until this removal. */
if (index < 0) return
steps.splice(index, 1)
// A retracted step may have owned the turn's hidden-mode header.
applyTurnFolding(current.position.turn)
}
/**
@@ -691,6 +735,7 @@ export function createTuiChat(
palette,
mdTheme,
)
registerAssistantStep(streaming)
chat.addChild(streaming)
chat.addChild(streaming.timing)
}
@@ -754,12 +799,22 @@ export function createTuiChat(
startAssistantStep(event.data)
break
case 'assistant/chunk':
if (options.renderChunks) streaming?.update(event.data.chunk)
if (options.renderChunks && streaming !== undefined) {
streaming.update(event.data.chunk)
// The first streamed text/reasoning may make this step the turn's
// hidden-mode header owner (or a continuation with a visible body).
applyTurnFolding(streaming.position.turn)
}
break
case 'assistant/message':
completedStreaming = undefined
if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data)
streaming?.settle(event.data.message.content)
// A settled component stays attached but never absorbs a later message
// of the same step; both the live and replay paths start a new one.
if (streaming === undefined || streaming.isSettled() || !chat.children.includes(streaming)) startAssistantStep(event.data)
if (streaming !== undefined) {
streaming.settle(event.data.message.content)
applyTurnFolding(streaming.position.turn)
}
break
case 'llm/retry': {
retractFailedStreaming()
@@ -870,6 +925,7 @@ export function createTuiChat(
toolCards.clear()
allToolCards.clear()
contextCards.clear()
assistantSteps.clear()
streaming = undefined
todo.update([])
const transcriptCalls = transcriptToolCallIds(agent.session)
@@ -979,32 +1035,99 @@ export function createTuiChat(
// same reason.
ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
const toggleTools = (): void => {
// The cycle order puts the two common reading modes adjacent: preview ->
// full detail -> conversation-only, then back to the preview default.
toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded'
: toolsVisibility === 'expanded' ? 'hidden' : 'collapsed'
const setToolsVisibility = (next: ToolCardVisibility): void => {
toolsVisibility = next
for (const card of allToolCards) card.setVisibility(toolsVisibility)
// Context cards carry injected instructions rather than tool traffic, so
// they never hide: the hidden phase reads as their collapsed preview.
for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded')
// Hidden mode folds each turn's steps into one assistant message; other
// modes restore the per-step Assistant headers.
for (const turn of assistantSteps.keys()) applyTurnFolding(turn)
appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`)
}
const toggleReasoning = (): void => {
showReasoning = !showReasoning
const toggleTools = (): void => {
// The cycle order puts the two common reading modes adjacent: preview ->
// full detail -> conversation-only, then back to the preview default.
setToolsVisibility(toolsVisibility === 'collapsed' ? 'expanded'
: toolsVisibility === 'expanded' ? 'hidden' : 'collapsed')
}
const setReasoning = (show: boolean): void => {
showReasoning = show
const activeStreaming = streaming
rebuildTranscript(false)
/* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */
if (activeStreaming !== undefined) {
streaming = activeStreaming
streaming.setShowReasoning(showReasoning)
registerAssistantStep(activeStreaming)
chat.addChild(activeStreaming)
chat.addChild(activeStreaming.timing)
}
appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`)
}
const toggleReasoning = (): void => { setReasoning(!showReasoning) }
// The selector and the argument grammar mutate the same closure state the
// Ctrl+O cycle and Ctrl+R toggle drive, so every entry converges.
let detailsOverlay: TuiOverlaySession | undefined
const showDetailsSelector = (): void => {
void detailsOverlay?.close()
const session = overlayManager.open({
create: () => new DetailsDialog(
toolsVisibility,
showReasoning,
palette,
// Each Tab applies immediately; one dimension changes per call.
(selection: DetailsSelection) => {
if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning)
if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility)
},
() => { void session.close() },
),
options: { width: resolved.detailsDialogWidth, anchor: 'center', margin: 1 },
})
detailsOverlay = session
void session.closed.then(() => {
if (detailsOverlay === session) detailsOverlay = undefined
})
requestRender()
}
// `/details` names the same transcript-detail state the Ctrl+O cycle and
// Ctrl+R toggle mutate, so a user can jump to a mode without cycling.
const runDetails = (rawInput: string): CommandResult => {
const tokens = rawInput.split(/\s+/u).filter(token => token !== '')
if (tokens.length === 0) {
showDetailsSelector()
return { kind: 'success' }
}
let visibility: ToolCardVisibility | undefined
let reasoning: boolean | undefined
for (let token = tokens.shift(); token !== undefined; token = tokens.shift()) {
if (token === 'collapsed' || token === 'expanded' || token === 'hidden') {
visibility = token
} else if (token === 'reasoning') {
const value = tokens[0]
if (value === 'on' || value === 'off') {
tokens.shift()
reasoning = value === 'on'
} else {
reasoning = !showReasoning
}
} else {
return { kind: 'error', text: `Unknown /details argument "${token}". Usage: /details [collapsed|expanded|hidden] [reasoning [on|off]]` }
}
}
// Reasoning first: its transcript rebuild would drop the visibility notice.
if (reasoning !== undefined) setReasoning(reasoning)
if (visibility !== undefined) setToolsVisibility(visibility)
return { kind: 'success' }
}
const showHelp = (): void => {
const commandLines = ctx.commands.list(agent).map((command) => {
const input = command.input === undefined ? '' : ` ${command.input.hint}`
@@ -1190,6 +1313,12 @@ export function createTuiChat(
description: 'Clear the transcript view (session history is unchanged)',
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'details',
description: 'Select tool-card visibility and reasoning display',
input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' },
handler: ({ rawInput }) => runDetails(rawInput),
})
commandCtx.commands.register({
name: 'palette',
description: 'Show every color and attribute role this terminal renders',
@@ -1529,7 +1658,6 @@ export function createTuiChat(
if (event.type === 'tool/result') fileSearch.invalidate()
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
// Track live standalone compaction state.
if (event.type === 'compact/start' && event.data.turn === null) {
if (compacting === undefined) {