From 9e03476e0387b0590b6a9b4887d245ed1b4cf687 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Sun, 12 Apr 2026 16:17:43 -0700 Subject: [PATCH] feat(cli): support 'notice' format and refine declarative tool rendering Key enhancements: - Updated `UpdateTopicTool` to provide declarative 'notice' display info, using dynamic descriptions for high-fidelity output. - Refined `ToolGroupDisplay` to 'hoist' notice-format tools to the top of the group. - Implemented conditional boxing in `ToolGroupDisplay`: borders are now suppressed in compact mode, matching the standard CLI view. - Added support for `resultSummary` rendering at the bottom of text results in boxed mode. - Improved `useAgentStream` to wait for turn completion before pushing tools to history, ensuring all notices for a turn are correctly grouped and hoisted together. - Fixed margin and border logic to handle seamless transitions between notices and tool boxes. --- .../components/messages/ToolGroupDisplay.tsx | 106 +++++++++++++++--- packages/cli/src/ui/hooks/useAgentStream.ts | 61 ++++++---- packages/core/src/tools/ls.ts | 8 -- packages/core/src/tools/topicTool.ts | 5 + 4 files changed, 134 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx index 0e393cb719..8e937964c5 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx @@ -25,6 +25,9 @@ export const ToolGroupDisplay: React.FC = ({ item, isToolGroupBoundary, }) => { + const settings = useSettings(); + const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true; + if (item.type !== 'tool_display_group') { return null; } @@ -32,23 +35,68 @@ export const ToolGroupDisplay: React.FC = ({ const { tools, borderColor, borderDimColor, borderTop, borderBottom } = item as HistoryItemToolDisplayGroup; + const noticeTools = tools.filter((t) => t.format === 'notice'); + const otherTools = tools.filter( + (t) => t.format !== 'notice' && t.format !== 'hidden', + ); + + const hasOtherTools = otherTools.length > 0; + const isClosingSlice = tools.length === 0 && borderBottom; + + // Standard view behavior: If compact mode is enabled, non-notice tools + // are typically rendered without an outer box. + const shouldShowBox = + (hasOtherTools || isClosingSlice) && !isCompactModeEnabled; + + const boxBorderTop = borderTop || noticeTools.length > 0; + return ( - - {tools.map((tool, index) => ( - - ))} + + {noticeTools.map((tool, index) => { + const isFirstInGroup = index === 0 && borderTop; + const isLastElementInGroup = + index === noticeTools.length - 1 && !shouldShowBox && borderBottom; + + return ( + 0 ? 1 : 0} + marginBottom={isLastElementInGroup ? 1 : 0} + > + + + ); + })} + {shouldShowBox ? ( + + {otherTools.map((tool, index) => ( + + ))} + + ) : otherTools.length > 0 ? ( + // Compact mode or no tools to box + 0 ? 1 : 0} + marginBottom={borderBottom ? 1 : 0} + > + {otherTools.map((tool, index) => ( + + ))} + + ) : null} ); }; @@ -90,6 +138,21 @@ const ToolDisplayMessage: React.FC = ({ tool }) => { return null; } + if (format === 'notice') { + return ( + + + {name || 'Topic'}: + + {description && ( + + {description} + + )} + + ); + } + const isCompact = format === 'compact' || (format === 'auto' && isCompactModeEnabled); @@ -153,7 +216,16 @@ const ToolResultDisplayContent: React.FC = ({ switch (content.type) { case 'text': - return {content.text}; + return ( + + {content.text} + {summary && ( + + {summary} + + )} + + ); case 'diff': // Simplified diff display for now return ( diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index 8f0fc61065..ac0c0db2ca 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -77,6 +77,8 @@ export const useAgentStream = ({ useStateAndRef>(new Set()); const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] = useStateAndRef(true); + const [_hasEmittedBoxInTurn, hasEmittedBoxInTurnRef, setHasEmittedBoxInTurn] = + useStateAndRef(false); const { startNewPrompt } = useSessionStats(); @@ -381,32 +383,27 @@ export const useAgentStream = ({ // Push completed tools to history useEffect(() => { - const toolsToPush: IndividualToolCallDisplay[] = []; - for (let i = 0; i < trackedTools.length; i++) { - const tc = trackedTools[i]; - if (pushedToolCallIdsRef.current.has(tc.callId)) continue; + if (trackedTools.length === 0) return; - if ( + // We only push to history once all currently known tools in the turn are terminal. + // This allows ToolGroupDisplay to correctly hoist ALL notices (topics) for the turn. + const allTerminal = trackedTools.every( + (tc) => tc.status === 'success' || tc.status === 'error' || - tc.status === 'cancelled' - ) { - toolsToPush.push(tc); - } else { - break; - } - } + tc.status === 'cancelled', + ); - if (toolsToPush.length > 0) { + const toolsToPush = trackedTools.filter( + (tc) => !pushedToolCallIdsRef.current.has(tc.callId), + ); + + if (allTerminal && toolsToPush.length > 0) { const newPushed = new Set(pushedToolCallIdsRef.current); for (const tc of toolsToPush) { newPushed.add(tc.callId); } - const isLastInBatch = - toolsToPush[toolsToPush.length - 1] === - trackedTools[trackedTools.length - 1]; - const appearance = getToolGroupBorderAppearance( { type: 'tool_group', tools: trackedTools }, activePtyId, @@ -415,6 +412,13 @@ export const useAgentStream = ({ backgroundTasks, ); + const hasBoxInBatch = toolsToPush.some( + (tc) => tc.display?.format !== 'notice', + ); + const shouldStartNewBlock = + isFirstToolInGroupRef.current || + (!hasEmittedBoxInTurnRef.current && hasBoxInBatch); + const historyItem: HistoryItemToolDisplayGroup = { type: 'tool_display_group', tools: toolsToPush.map((tc) => ({ @@ -424,21 +428,27 @@ export const useAgentStream = ({ status: tc.status, originalRequestName: tc.originalRequestName, })), - borderTop: isFirstToolInGroupRef.current, - borderBottom: isLastInBatch, + borderTop: shouldStartNewBlock, + borderBottom: true, ...appearance, }; addItem(historyItem); setPushedToolCallIds(newPushed); + + if (hasBoxInBatch) { + setHasEmittedBoxInTurn(true); + } setIsFirstToolInGroup(false); } }, [ trackedTools, pushedToolCallIdsRef, isFirstToolInGroupRef, + hasEmittedBoxInTurnRef, setPushedToolCallIds, setIsFirstToolInGroup, + setHasEmittedBoxInTurn, addItem, activePtyId, isShellFocused, @@ -447,7 +457,7 @@ export const useAgentStream = ({ const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { const remainingTools = trackedTools.filter( - (tc) => !pushedToolCallIds.has(tc.callId), + (tc) => !pushedToolCallIdsRef.current.has(tc.callId), ); const items: HistoryItemWithoutId[] = []; @@ -461,6 +471,13 @@ export const useAgentStream = ({ ); if (remainingTools.length > 0) { + const hasBoxInPending = remainingTools.some( + (tc) => tc.display?.format !== 'notice', + ); + const shouldStartNewBlock = + pushedToolCallIds.size === 0 || + (!hasEmittedBoxInTurnRef.current && hasBoxInPending); + items.push({ type: 'tool_display_group', tools: remainingTools.map((tc) => ({ @@ -470,7 +487,7 @@ export const useAgentStream = ({ status: tc.status, originalRequestName: tc.originalRequestName, })), - borderTop: pushedToolCallIds.size === 0, + borderTop: shouldStartNewBlock, borderBottom: false, ...appearance, }); @@ -510,6 +527,8 @@ export const useAgentStream = ({ }, [ trackedTools, pushedToolCallIds, + pushedToolCallIdsRef, + hasEmittedBoxInTurnRef, activePtyId, isShellFocused, backgroundTasks, diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 17e21359a8..c2e1a593bc 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -288,14 +288,6 @@ class LSToolInvocation extends BaseToolInvocation { name: LS_DISPLAY_NAME, description: this.getDescription(), resultSummary: displayMessage, - result: { - type: 'text', - text: entries - .map( - (entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`, - ) - .join('\n'), - }, }, returnDisplay: { summary: displayMessage, diff --git a/packages/core/src/tools/topicTool.ts b/packages/core/src/tools/topicTool.ts index 2b298159d1..f0cb328b0a 100644 --- a/packages/core/src/tools/topicTool.ts +++ b/packages/core/src/tools/topicTool.ts @@ -93,6 +93,11 @@ class UpdateTopicInvocation extends BaseToolInvocation< return { llmContent, + display: { + format: 'notice', + name: title || UPDATE_TOPIC_DISPLAY_NAME, + description: this.getDescription(), + }, returnDisplay, }; }