feat(tui): hidden mode folds a turn's assistant steps into one message

The Ctrl+O hidden phase keeps one Assistant header per turn: the first
step with visible text/reasoning owns it, later steps render as
headerless continuations, and bodiless (tool-only) steps render
nothing. Leaving hidden restores per-step headers. Pure TUI
presentation; the session log is unchanged.
This commit is contained in:
Turtle
2026-07-29 22:38:21 +08:00
parent 9e11f438f3
commit 0c9e529f74
14 changed files with 376 additions and 23 deletions
+54 -13
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)
}
}