@@ -17,6 +17,7 @@ import { parsePlanUpdateObservation, planProgress } from '../plan/planState'
1717
1818const TOOL_PARAM_MAX_COLUMNS = 70
1919const THINKING_PREFIX_COLUMNS = 11
20+ const THINKING_LIVE_LINES = 3
2021
2122function 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+
146170const 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+
228344function 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