Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 653282d

Browse files
committed
feat(tui): rolling thinking lines and merged tool rows
- while thinking streams, show a rolling tail of the latest lines and collapse to a single-line preview once it completes - collapse runs of consecutive completed read-only tool calls into one row (e.g. 'Read 3 files') with the paths listed beneath
1 parent 334caf0 commit 653282d

2 files changed

Lines changed: 288 additions & 43 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import assert from 'node:assert'
2+
import { describe, test } from 'vitest'
3+
import { TOOL_STATUS, type StepView } from '../../shared/types'
4+
import { collectTimelineItems } from './Cells'
5+
6+
function readStep(
7+
index: number,
8+
path: string,
9+
status: (typeof TOOL_STATUS)[keyof typeof TOOL_STATUS] = TOOL_STATUS.SUCCESS,
10+
): StepView {
11+
return {
12+
index,
13+
assistantText: '',
14+
action: { tool: 'read', input: { path } },
15+
toolResults: [{ tool: 'read', observation: 'ok', status }],
16+
}
17+
}
18+
19+
describe('collectTimelineItems', () => {
20+
test('merges consecutive successful reads into one item', () => {
21+
const items = collectTimelineItems(
22+
[readStep(0, '/repo/a.ts'), readStep(1, '/repo/b.ts'), readStep(2, '/repo/c.ts')],
23+
'/repo',
24+
120,
25+
)
26+
27+
assert.strictEqual(items.length, 1)
28+
assert.deepStrictEqual(items[0], {
29+
type: 'merged',
30+
tool: 'read',
31+
label: 'Read 3 files',
32+
params: ['a.ts', 'b.ts', 'c.ts'],
33+
})
34+
})
35+
36+
test('keeps a single read as a step', () => {
37+
const items = collectTimelineItems([readStep(0, '/repo/a.ts')], '/repo', 120)
38+
39+
assert.strictEqual(items.length, 1)
40+
assert.deepStrictEqual(items[0], { type: 'step', step: readStep(0, '/repo/a.ts') })
41+
})
42+
43+
test('does not merge a failed read', () => {
44+
const items = collectTimelineItems(
45+
[readStep(0, '/repo/a.ts'), readStep(1, '/repo/b.ts', TOOL_STATUS.ERROR)],
46+
'/repo',
47+
120,
48+
)
49+
50+
assert.strictEqual(items.length, 2)
51+
assert.ok(items.every((item) => item.type === 'step'))
52+
})
53+
54+
test('a step with assistant text interrupts the run', () => {
55+
const textStep: StepView = {
56+
index: 1,
57+
assistantText: 'processing',
58+
action: { tool: 'read', input: { path: '/repo/mid.ts' } },
59+
}
60+
const items = collectTimelineItems(
61+
[readStep(0, '/repo/a.ts'), textStep, readStep(2, '/repo/c.ts')],
62+
'/repo',
63+
120,
64+
)
65+
66+
assert.strictEqual(items.length, 3)
67+
assert.ok(items.every((item) => item.type === 'step'))
68+
})
69+
70+
test('does not merge non-read tools', () => {
71+
const execStep: StepView = {
72+
index: 1,
73+
assistantText: '',
74+
action: { tool: 'exec_command', input: { cmd: 'ls' } },
75+
toolResults: [{ tool: 'exec_command', observation: 'ok', status: TOOL_STATUS.SUCCESS }],
76+
}
77+
const items = collectTimelineItems(
78+
[readStep(0, '/repo/a.ts'), execStep, readStep(2, '/repo/c.ts')],
79+
'/repo',
80+
120,
81+
)
82+
83+
assert.strictEqual(items.length, 3)
84+
assert.ok(items.every((item) => item.type === 'step'))
85+
})
86+
87+
test('groups per tool: two reads then two searches stay separate', () => {
88+
const searchStep = (index: number, pattern: string): StepView => ({
89+
index,
90+
assistantText: '',
91+
action: { tool: 'search_files', input: { pattern } },
92+
toolResults: [{ tool: 'search_files', observation: 'ok', status: TOOL_STATUS.SUCCESS }],
93+
})
94+
const items = collectTimelineItems(
95+
[readStep(0, '/repo/a.ts'), readStep(1, '/repo/b.ts'), searchStep(2, '*.md'), searchStep(3, '*.ts')],
96+
'/repo',
97+
120,
98+
)
99+
100+
assert.strictEqual(items.length, 2)
101+
assert.deepStrictEqual(items[0], {
102+
type: 'merged',
103+
tool: 'read',
104+
label: 'Read 2 files',
105+
params: ['a.ts', 'b.ts'],
106+
})
107+
assert.deepStrictEqual(items[1], {
108+
type: 'merged',
109+
tool: 'search_files',
110+
label: 'Searched 2 patterns',
111+
params: ['*.md', '*.ts'],
112+
})
113+
})
114+
})

‎packages/tui/src/features/timeline/Cells.tsx‎

Lines changed: 174 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { parsePlanUpdateObservation, planProgress } from '../plan/planState'
1717

1818
const TOOL_PARAM_MAX_COLUMNS = 70
1919
const THINKING_PREFIX_COLUMNS = 11
20+
const THINKING_LIVE_LINES = 3
2021

2122
function toolStatusPresentation(status?: ToolStatus): { glyph: string; verb: string; color: string } {
2223
if (status === TOOL_STATUS.ERROR) return { glyph: '×', verb: 'Failed', color: 'red' }
@@ -143,6 +144,29 @@ const ToolRow = memo(function ToolRow({
143144
)
144145
})
145146

147+
function resolveStepActions(step: StepView): ToolAction[] {
148+
const isParallel = Boolean(step.parallelActions && step.parallelActions.length > 1)
149+
return isParallel ? (step.parallelActions ?? []) : step.action ? [step.action] : []
150+
}
151+
152+
function resolveActionResult(step: StepView, action: ToolAction, index: number): ToolResultView | undefined {
153+
const actions = resolveStepActions(step)
154+
const matched = action.toolCallId
155+
? step.toolResults?.find((result) => result.toolCallId === action.toolCallId)
156+
: undefined
157+
if (matched) return matched
158+
if (step.toolResults?.[index]) return step.toolResults[index]
159+
if (actions.length === 1 && step.observation) {
160+
return {
161+
toolCallId: action.toolCallId,
162+
tool: action.tool,
163+
observation: step.observation,
164+
status: step.toolStatus ?? TOOL_STATUS.PENDING,
165+
}
166+
}
167+
return undefined
168+
}
169+
146170
const StepCell = memo(function StepCell({
147171
step,
148172
cwd,
@@ -154,52 +178,51 @@ const StepCell = memo(function StepCell({
154178
terminalWidth: number
155179
showText?: boolean
156180
}) {
157-
const isParallel = Boolean(step.parallelActions && step.parallelActions.length > 1)
158-
const actions: ToolAction[] = isParallel ? (step.parallelActions ?? []) : step.action ? [step.action] : []
181+
const actions = resolveStepActions(step)
159182
const thinking = step.thinking ?? step.streamingThinking
160-
const thinkingPreview = thinking
161-
? previewText(thinking.replace(/\s+/g, ' ').trim(), {
162-
columns: Math.max(1, terminalWidth - THINKING_PREFIX_COLUMNS),
163-
maxLines: 1,
164-
from: 'end',
165-
}).text
166-
: null
167-
168-
const resultForAction = (action: ToolAction, index: number): ToolResultView | undefined => {
169-
const matched = action.toolCallId
170-
? step.toolResults?.find((result) => result.toolCallId === action.toolCallId)
171-
: undefined
172-
if (matched) return matched
173-
if (step.toolResults?.[index]) return step.toolResults[index]
174-
if (actions.length === 1 && step.observation) {
175-
return {
176-
toolCallId: action.toolCallId,
177-
tool: action.tool,
178-
observation: step.observation,
179-
status: step.toolStatus ?? TOOL_STATUS.PENDING,
180-
}
181-
}
182-
return undefined
183-
}
183+
const isLiveThinking = Boolean(step.streamingThinking && !step.action)
184+
const thinkingColumns = Math.max(1, terminalWidth - THINKING_PREFIX_COLUMNS)
185+
// While thinking is streaming, show a rolling tail of the latest lines;
186+
// once it completes, collapse to a single-line preview.
187+
const thinkingLines = thinking
188+
? isLiveThinking
189+
? previewText(thinking, {
190+
columns: thinkingColumns,
191+
maxLines: THINKING_LIVE_LINES,
192+
from: 'end',
193+
}).text.split('\n')
194+
: [
195+
previewText(thinking.replace(/\s+/g, ' ').trim(), {
196+
columns: thinkingColumns,
197+
maxLines: 1,
198+
from: 'end',
199+
}).text,
200+
]
201+
: []
202+
203+
const resultForAction = (action: ToolAction, index: number): ToolResultView | undefined =>
204+
resolveActionResult(step, action, index)
184205

185206
return (
186207
<Box flexDirection="column">
187-
{thinkingPreview ? (
188-
<Box>
189-
<Text wrap="truncate-end">
190-
<Text color={step.streamingThinking && !step.action ? 'yellow' : 'gray'} italic>
191-
Thinking
192-
</Text>
193-
<Text color="gray" dimColor>
194-
{' · '}
195-
{thinkingPreview}
196-
</Text>
197-
</Text>
208+
{thinkingLines.length > 0 ? (
209+
<Box flexDirection="column">
210+
{thinkingLines.map((line, index) => (
211+
<Box key={index}>
212+
<Text color={isLiveThinking ? 'yellow' : 'gray'} italic>
213+
{index === 0 ? 'Thinking' : ' '}
214+
</Text>
215+
<Text color="gray" dimColor>
216+
{' · '}
217+
{line}
218+
</Text>
219+
</Box>
220+
))}
198221
</Box>
199222
) : null}
200223

201224
{showText && step.assistantText ? (
202-
<Box marginTop={thinkingPreview ? 1 : 0}>
225+
<Box marginTop={thinkingLines.length > 0 ? 1 : 0}>
203226
<Text color="green">● </Text>
204227
<Box flexDirection="column" flexShrink={1}>
205228
<MarkdownRenderer content={step.assistantText} />
@@ -225,6 +248,99 @@ const StepCell = memo(function StepCell({
225248
)
226249
})
227250

251+
const MERGED_TOOL_LABELS: Record<string, (count: number) => string> = {
252+
read: (count) => `Read ${count} files`,
253+
list_directory: (count) => `Listed ${count} directories`,
254+
search_files: (count) => `Searched ${count} patterns`,
255+
}
256+
257+
export type MergedToolGroup = {
258+
type: 'merged'
259+
tool: string
260+
label: string
261+
params: string[]
262+
}
263+
264+
export type TimelineItem = { type: 'step'; step: StepView } | MergedToolGroup
265+
266+
/**
267+
* Collapses runs of consecutive, completed read-only tool calls into one
268+
* merged item (e.g. three consecutive `read` calls render as "Read 3 files"),
269+
* so long tool sequences don't flood the timeline.
270+
*/
271+
export function collectTimelineItems(steps: StepView[], cwd: string, terminalWidth: number): TimelineItem[] {
272+
const items: TimelineItem[] = []
273+
let group: { tool: string; steps: StepView[] } | null = null
274+
275+
const flushGroup = () => {
276+
if (!group) return
277+
if (group.steps.length >= 2) {
278+
const labelFor = MERGED_TOOL_LABELS[group.tool]
279+
const params = group.steps.map((step) => {
280+
const action = resolveStepActions(step)[0]
281+
return action ? (mainParam(action.input, cwd, terminalWidth) ?? group.tool) : group.tool
282+
})
283+
items.push({
284+
type: 'merged',
285+
tool: group.tool,
286+
label: labelFor ? labelFor(group.steps.length) : `${group.tool} ×${group.steps.length}`,
287+
params,
288+
})
289+
} else {
290+
for (const step of group.steps) {
291+
items.push({ type: 'step', step })
292+
}
293+
}
294+
group = null
295+
}
296+
297+
for (const step of steps) {
298+
const actions = resolveStepActions(step)
299+
const singleAction = actions.length === 1 ? actions[0] : undefined
300+
// Only bare, successful, single-action steps can fold into a group;
301+
// thinking, text, or failures interrupt the run.
302+
const absorbable = Boolean(singleAction) && !step.assistantText && !step.thinking && !step.streamingThinking
303+
const mergable =
304+
absorbable &&
305+
Boolean(singleAction) &&
306+
(singleAction as ToolAction).tool in MERGED_TOOL_LABELS &&
307+
resolveActionResult(step, singleAction as ToolAction, 0)?.status === TOOL_STATUS.SUCCESS
308+
309+
if (!mergable || !singleAction) {
310+
flushGroup()
311+
items.push({ type: 'step', step })
312+
continue
313+
}
314+
315+
if (!group || group.tool !== singleAction.tool) {
316+
flushGroup()
317+
group = { tool: singleAction.tool, steps: [] }
318+
}
319+
group.steps.push(step)
320+
}
321+
flushGroup()
322+
323+
return items
324+
}
325+
326+
function MergedToolRow({ label, params }: { label: string; params: string[] }) {
327+
return (
328+
<Box flexDirection="column">
329+
<Box>
330+
<Text color="green">✓ </Text>
331+
<Text color="gray">{label}</Text>
332+
</Box>
333+
{params.map((param, index) => (
334+
<Box key={index} paddingLeft={2}>
335+
<Text color="gray" dimColor>
336+
{index === params.length - 1 ? '└' : '├'} {param}
337+
</Text>
338+
</Box>
339+
))}
340+
</Box>
341+
)
342+
}
343+
228344
function formatTurnMeta(turn: TurnView): string | null {
229345
const parts: string[] = []
230346
if (turn.durationMs !== undefined) {
@@ -258,11 +374,26 @@ export const TurnCell = memo(function TurnCell({ turn, cwd }: { turn: TurnView;
258374

259375
{turn.steps.length > 0 ? (
260376
<Box flexDirection="column" marginTop={1}>
261-
{turn.steps.map((step, index) => (
262-
<Box key={`${turn.index}-${step.index}`} flexDirection="column" marginTop={index > 0 ? 1 : 0}>
263-
<StepCell step={step} cwd={cwd} terminalWidth={terminalWidth} showText={inProgress} />
264-
</Box>
265-
))}
377+
{collectTimelineItems(turn.steps, cwd, terminalWidth).map((item, index) =>
378+
item.type === 'merged' ? (
379+
<Box key={`${turn.index}-merged-${index}`} marginTop={index > 0 ? 1 : 0}>
380+
<MergedToolRow label={item.label} params={item.params} />
381+
</Box>
382+
) : (
383+
<Box
384+
key={`${turn.index}-${item.step.index}`}
385+
flexDirection="column"
386+
marginTop={index > 0 ? 1 : 0}
387+
>
388+
<StepCell
389+
step={item.step}
390+
cwd={cwd}
391+
terminalWidth={terminalWidth}
392+
showText={inProgress}
393+
/>
394+
</Box>
395+
),
396+
)}
266397
</Box>
267398
) : null}
268399

0 commit comments

Comments
 (0)