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:
@@ -0,0 +1,46 @@
|
||||
terminal 100x40 buffer=normal length=40 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=20 bufferRow=20
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Inspecting the renderer first. "
|
||||
6| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
7| <blank>
|
||||
8| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
9| "Refactor the renderer. "
|
||||
10| "Model wait 0.0s · Completed 2026-07-29 22:23:17 "
|
||||
style 0-46 dim
|
||||
11| <blank>
|
||||
12| "The renderer is sound; no refactor needed. "
|
||||
13| "Model wait 0.0s · Completed 2026-07-29 22:23:17 "
|
||||
style 0-46 dim
|
||||
14| <blank>
|
||||
15| "Tool and context cards expanded. "
|
||||
style 0-31 dim
|
||||
16| <blank>
|
||||
17| "Tool cards hidden. "
|
||||
style 0-17 dim
|
||||
18| <blank>
|
||||
19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
20| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
21-39| <blank>
|
||||
@@ -44,6 +44,7 @@ const CHECKPOINTS = [
|
||||
'cordis-tools-pending',
|
||||
'advanced-cards-collapsed',
|
||||
'advanced-cards-expanded',
|
||||
'tool-cards-hidden-folded',
|
||||
'untrusted-controls',
|
||||
'question-dialog',
|
||||
'question-dialog-single-option',
|
||||
@@ -609,6 +610,31 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins the hidden phase folding a multi-step turn into one assistant message', async () => {
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
config: { maxToolOutputLines: 3 },
|
||||
}, { columns: 100, rows: 40 })
|
||||
await renderAfter(harness, () => {
|
||||
appendUser(harness.session, 'Refactor the renderer.')
|
||||
appendAssistant(harness.session, [{ type: 'text', text: 'Inspecting the renderer first.' }])
|
||||
appendToolCalls(harness.session, [
|
||||
{ id: 'fold-1', name: 'bash', arguments: { command: 'pnpm run test' } },
|
||||
])
|
||||
appendToolResult(harness.session, 'fold-1', [{ type: 'text', text: 'all tests pass' }])
|
||||
harness.session.append('step/end', { turn: 1, step: 1 })
|
||||
harness.session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 })
|
||||
harness.session.append('step/end', { turn: 1, step: 2 })
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
// collapsed -> expanded -> hidden: one Assistant header, no tool card.
|
||||
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
|
||||
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
|
||||
await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime())
|
||||
const tools = {
|
||||
|
||||
@@ -4989,6 +4989,134 @@ describe('tool cards and surface replay', () => {
|
||||
expect(mounted).not.toContain('stored model-only payload')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
/** The last repainted frame, with CSI/OSC escapes and carriage returns stripped. */
|
||||
const lastFrame = (terminal: FakeTerminal): string => terminal.output
|
||||
.slice(terminal.output.lastIndexOf('\x1b[2J'))
|
||||
.replaceAll(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g, '')
|
||||
|
||||
const countAssistantHeaders = (frame: string): number => frame.split('\n')
|
||||
.filter(row => row.trim() === 'Assistant').length
|
||||
|
||||
/** One turn with text -> tool call/result -> text across two steps. */
|
||||
const appendTwoStepTurn = (session: Awaited<ReturnType<typeof setup>>['session']): void => {
|
||||
appendUser(session, 'fold me')
|
||||
appendAssistant(session, [{ type: 'text', text: 'first step text' }])
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: 'fold-1' as never, name: 'bash', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'fold-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => {
|
||||
const result = await setup({ tools })
|
||||
appendTwoStepTurn(result.session)
|
||||
await tick()
|
||||
|
||||
// Collapsed (default): each step keeps its own header.
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2)
|
||||
|
||||
// collapsed -> expanded -> hidden.
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const hidden = lastFrame(result.terminal)
|
||||
expect(countAssistantHeaders(hidden)).toBe(1)
|
||||
expect(hidden).toContain('first step text')
|
||||
expect(hidden).toContain('second step text')
|
||||
expect(hidden).not.toContain('Tool / bash')
|
||||
// The fold keeps model order: header text precedes the continuation.
|
||||
expect(hidden.indexOf('first step text')).toBeLessThan(hidden.indexOf('second step text'))
|
||||
|
||||
// hidden -> collapsed restores per-step headers.
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('gives the hidden-mode header to the first step with a visible body and keeps turns separate', async () => {
|
||||
const result = await setup({ tools })
|
||||
// Turn 1, step 1 is tool-only; step 2 carries the turn's text.
|
||||
appendUser(result.session, 'tool-only first step')
|
||||
appendAssistant(result.session, [{ type: 'tool-call', id: 'only-1' as never, name: 'bash', arguments: '{}' }])
|
||||
result.session.append('tool/call', { turn: 1, step: 1, callId: 'only-1' as never, name: 'bash', arguments: '{}' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'only-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('step/end', { turn: 1, step: 1 })
|
||||
result.session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 })
|
||||
result.session.append('step/end', { turn: 1, step: 2 })
|
||||
result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Turn 2 keeps its own header.
|
||||
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
appendUser(result.session, 'next turn')
|
||||
result.session.append('step/start', { turn: 2, step: 1 })
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 })
|
||||
result.session.append('step/end', { turn: 2, step: 1 })
|
||||
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
await tick()
|
||||
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const hidden = lastFrame(result.terminal)
|
||||
// One header per turn: the tool-only step neither renders a blank segment
|
||||
// nor consumes turn one's header, which the late text step owns.
|
||||
expect(countAssistantHeaders(hidden)).toBe(2)
|
||||
expect(hidden).toContain('late turn-one text')
|
||||
expect(hidden).toContain('turn-two text')
|
||||
const rows = hidden.split('\n').map(row => row.trim())
|
||||
const turnOneHeader = rows.indexOf('Assistant')
|
||||
expect(rows[turnOneHeader + 1]).toBe('late turn-one text')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('folds live hidden-mode streaming once a later step shows text', async () => {
|
||||
const result = await setup({ tools, status: 'running' })
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live first' } })
|
||||
result.session.append('step/end', { turn: 1, step: 1 })
|
||||
result.session.append('step/start', { turn: 1, step: 2 })
|
||||
result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'live second' } })
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const hidden = lastFrame(result.terminal)
|
||||
expect(countAssistantHeaders(hidden)).toBe(1)
|
||||
expect(hidden).toContain('live first')
|
||||
expect(hidden).toContain('live second')
|
||||
|
||||
// A transcript rebuild (resize) recomputes the same fold from the log.
|
||||
result.terminal.resize(89)
|
||||
await tick()
|
||||
const rebuilt = lastFrame(result.terminal)
|
||||
expect(countAssistantHeaders(rebuilt)).toBe(1)
|
||||
expect(rebuilt).toContain('live second')
|
||||
await dispose(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI user-interaction dialogs', () => {
|
||||
|
||||
Reference in New Issue
Block a user