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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -335,6 +335,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
|
||||
// TUI steering submissions that the inbox has not yet claimed or discarded.
|
||||
@@ -614,6 +618,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]) {
|
||||
@@ -621,6 +654,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,6 +701,7 @@ export function createTuiChat(
|
||||
palette,
|
||||
mdTheme,
|
||||
)
|
||||
registerAssistantStep(streaming)
|
||||
chat.addChild(streaming)
|
||||
chat.addChild(streaming.timing)
|
||||
}
|
||||
@@ -722,12 +765,20 @@ 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)
|
||||
if (streaming !== undefined) {
|
||||
streaming.settle(event.data.message.content)
|
||||
applyTurnFolding(streaming.position.turn)
|
||||
}
|
||||
break
|
||||
case 'llm/retry': {
|
||||
retractFailedStreaming()
|
||||
@@ -838,6 +889,7 @@ export function createTuiChat(
|
||||
toolCards.clear()
|
||||
allToolCards.clear()
|
||||
contextCards.clear()
|
||||
assistantSteps.clear()
|
||||
streaming = undefined
|
||||
todo.update([])
|
||||
const transcriptCalls = transcriptToolCallIds(agent.session)
|
||||
@@ -956,6 +1008,9 @@ export function createTuiChat(
|
||||
// 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}.`)
|
||||
}
|
||||
|
||||
@@ -967,6 +1022,7 @@ export function createTuiChat(
|
||||
if (activeStreaming !== undefined) {
|
||||
streaming = activeStreaming
|
||||
streaming.setShowReasoning(showReasoning)
|
||||
registerAssistantStep(activeStreaming)
|
||||
chat.addChild(activeStreaming)
|
||||
chat.addChild(activeStreaming.timing)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user