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.
This commit is contained in:
Michael Bleigh
2026-04-12 16:17:43 -07:00
parent 45eababfd8
commit 9e03476e03
4 changed files with 134 additions and 46 deletions

View File

@@ -25,6 +25,9 @@ export const ToolGroupDisplay: React.FC<ToolGroupDisplayProps> = ({
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<ToolGroupDisplayProps> = ({
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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
borderDimColor={borderDimColor}
borderTop={borderTop}
borderBottom={borderBottom}
borderLeft={!isToolGroupBoundary}
borderRight={!isToolGroupBoundary}
marginTop={borderTop ? 1 : 0}
marginBottom={borderBottom ? 1 : 0}
paddingX={1}
>
{tools.map((tool, index) => (
<ToolDisplayMessage key={index} tool={tool} />
))}
<Box flexDirection="column">
{noticeTools.map((tool, index) => {
const isFirstInGroup = index === 0 && borderTop;
const isLastElementInGroup =
index === noticeTools.length - 1 && !shouldShowBox && borderBottom;
return (
<Box
key={`notice-${index}`}
marginTop={isFirstInGroup ? 1 : index > 0 ? 1 : 0}
marginBottom={isLastElementInGroup ? 1 : 0}
>
<ToolDisplayMessage tool={tool} />
</Box>
);
})}
{shouldShowBox ? (
<Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
borderDimColor={borderDimColor}
borderTop={boxBorderTop}
borderBottom={borderBottom}
borderLeft={!isToolGroupBoundary}
borderRight={!isToolGroupBoundary}
marginTop={boxBorderTop ? 1 : 0}
marginBottom={borderBottom ? 1 : 0}
paddingX={1}
>
{otherTools.map((tool, index) => (
<ToolDisplayMessage key={`tool-${index}`} tool={tool} />
))}
</Box>
) : otherTools.length > 0 ? (
// Compact mode or no tools to box
<Box
flexDirection="column"
marginTop={noticeTools.length > 0 ? 1 : 0}
marginBottom={borderBottom ? 1 : 0}
>
{otherTools.map((tool, index) => (
<ToolDisplayMessage key={`tool-${index}`} tool={tool} />
))}
</Box>
) : null}
</Box>
);
};
@@ -90,6 +138,21 @@ const ToolDisplayMessage: React.FC<ToolDisplayMessageProps> = ({ tool }) => {
return null;
}
if (format === 'notice') {
return (
<Box paddingLeft={2} flexDirection="column">
<Text color={theme.text.primary} bold wrap="truncate-end">
{name || 'Topic'}:
</Text>
{description && (
<Text color={theme.text.secondary} wrap="wrap">
{description}
</Text>
)}
</Box>
);
}
const isCompact =
format === 'compact' || (format === 'auto' && isCompactModeEnabled);
@@ -153,7 +216,16 @@ const ToolResultDisplayContent: React.FC<ToolResultDisplayContentProps> = ({
switch (content.type) {
case 'text':
return <Text color={theme.text.secondary}>{content.text}</Text>;
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>{content.text}</Text>
{summary && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>{summary}</Text>
</Box>
)}
</Box>
);
case 'diff':
// Simplified diff display for now
return (

View File

@@ -77,6 +77,8 @@ export const useAgentStream = ({
useStateAndRef<Set<string>>(new Set());
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const [_hasEmittedBoxInTurn, hasEmittedBoxInTurnRef, setHasEmittedBoxInTurn] =
useStateAndRef<boolean>(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,

View File

@@ -288,14 +288,6 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
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,

View File

@@ -93,6 +93,11 @@ class UpdateTopicInvocation extends BaseToolInvocation<
return {
llmContent,
display: {
format: 'notice',
name: title || UPDATE_TOPIC_DISPLAY_NAME,
description: this.getDescription(),
},
returnDisplay,
};
}