Output Layout, Borders and Spacing

- fix(ui): update ToolGroupMessage to support stitched borders and dynamic margins
- fix(ui): implement border stitching in history pushing to eliminate gaps
- test(ui): update snapshots and test assertions for new layout
- style(ui): wrap dense tool payloads with vertical margins
    - Adds margins above and below dense payload content to allow compact output to consume a single line until expanded.
- fix(ui): unify spacing logic and border handling for tool groups
    - Corrects transitions between compact and standard tools to not add redundant empty lines and ensures history stitching respects boundaries.
- fix(ui): ensure top border is rendered within completed history for standard tool output when following compact tool output
    - Addresses an issue where non-compact tools pushed to history at the end of a batch (via onComplete) were missing their top border and proper margin if they followed compact tools already pushed to history.
    - The fix updates the onComplete callback in useGeminiStream.ts to be transition-aware. It now explicitly detects when a final push starts with a non-compact tool following a compact tool from the same batch, forcing borderTop: true in that case.
    - Previously, the logic relied solely on isFirstToolInGroupRef, which would be false if any earlier tools in the batch had already been pushed, causing the final non-compact tools to incorrectly inherit a borderless state from the preceding compact tools.

----------------------
Note: ToolGroupMessage.tsx and ToolGroupMessage.compact.test.tsx contain 'any' usage/unsafe assertions to be addressed before PR.
This commit is contained in:
Jarrod Whelan
2026-03-09 13:23:07 -07:00
parent afa3c13745
commit 9588effa29
15 changed files with 343 additions and 117 deletions

View File

@@ -37,10 +37,13 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
@@ -81,10 +84,13 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │

View File

@@ -3,16 +3,15 @@
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focused shell should expand' 1`] = `
"ScrollableList
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 16
│ Line 15
│ Line 16
│ Line 17 █ │
│ Line 18 █ │
│ Line 19 █ │
@@ -24,16 +23,15 @@ AppHeader(full)
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = `
"ScrollableList
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 16
│ Line 15
│ Line 16
│ Line 17 █ │
│ Line 18 █ │
│ Line 19 █ │
@@ -44,12 +42,11 @@ AppHeader(full)
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ ... first 11 lines hidden (Ctrl+O to show) ... │
│ Line 12 │
│ Line 13 │
│ ... first 13 lines hidden (Ctrl+O to show) ... │
│ Line 14 │
│ Line 15 │
│ Line 16 │
@@ -63,6 +60,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
@@ -92,6 +90,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │

View File

@@ -536,6 +536,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
<Box
marginLeft={6}
marginTop={1}
marginBottom={1}
paddingX={1}
flexDirection="column"
height={
@@ -562,13 +563,13 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
)}
{showPayload && (!isAlternateBuffer || !diff) && viewParts.payload && (
<Box marginLeft={6} marginTop={1}>
<Box marginLeft={6} marginTop={1} marginBottom={1}>
{viewParts.payload}
</Box>
)}
{showPayload && outputFile && (
<Box marginLeft={6} marginTop={1}>
<Box marginLeft={6} marginTop={1} marginBottom={1}>
<Text color={theme.text.secondary}>
(Output saved to: {outputFile})
</Text>

View File

@@ -55,7 +55,7 @@ describe('ToolGroupMessage Compact Rendering', () => {
expect(output).toMatchSnapshot();
});
it('adds an empty line between a compact tool and a standard tool', async () => {
it('does not add an extra empty line between a compact tool and a standard tool', async () => {
const toolCalls = [
{
callId: 'call1',
@@ -81,7 +81,7 @@ describe('ToolGroupMessage Compact Rendering', () => {
expect(output).toMatchSnapshot();
});
it('adds an empty line if a compact tool has a dense payload', async () => {
it('does not add an extra empty line if a compact tool has a dense payload', async () => {
const toolCalls = [
{
callId: 'call1',
@@ -107,7 +107,7 @@ describe('ToolGroupMessage Compact Rendering', () => {
expect(output).toMatchSnapshot();
});
it('adds an empty line between a standard tool and a compact tool', async () => {
it('does not add an extra empty line between a standard tool and a compact tool', async () => {
const toolCalls = [
{
callId: 'call1',

View File

@@ -54,7 +54,7 @@ const COMPACT_OUTPUT_ALLOWLIST = new Set([
]);
// Helper to identify if a tool should use the compact view
const isCompactTool = (
export const isCompactTool = (
tool: IndividualToolCallDisplay,
isCompactModeEnabled: boolean,
): boolean => {
@@ -68,7 +68,7 @@ const isCompactTool = (
};
// Helper to identify if a compact tool has a payload (diff, list, etc.)
const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
if (tool.outputFile) return true;
const res = tool.resultDisplay;
if (!res) return false;
@@ -121,7 +121,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls: allToolCalls,
availableTerminalHeight,
terminalWidth,
// borderTop: borderTopOverride,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
isExpandable,
}) => {
@@ -207,29 +207,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const prevIsCompact = prevTool
? isCompactTool(prevTool, isCompactModeEnabled)
: false;
const hasPayload = hasDensePayload(tool);
const prevHasPayload = prevTool ? hasDensePayload(prevTool) : false;
if (isCompact) {
height += 1; // Base height for compact tool
// Spacing logic (matching marginTop)
if (
isFirst ||
isCompact !== prevIsCompact ||
hasPayload ||
prevHasPayload
) {
if (isFirst) {
height += borderTopOverride ?? true ? 1 : 0;
} else if (!prevIsCompact) {
height += 1;
}
} else {
height += 3; // Static overhead for standard tool
if (isFirst || prevIsCompact) {
height += 1;
if (isFirst) {
height += borderTopOverride ?? true ? 1 : 0;
} else {
height += 1; // marginTop is always 1 for non-compact tools (not first)
}
}
}
return height;
}, [visibleToolCalls, isCompactModeEnabled]);
}, [visibleToolCalls, isCompactModeEnabled, borderTopOverride]);
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
@@ -273,20 +270,18 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={1}
marginBottom={borderBottomOverride ?? true ? 1 : 0}
>
{visibleToolCalls.map((tool, index) => {
const isFirst = index === 0;
const isLast = index === visibleToolCalls.length - 1;
const isShellToolCall = isShellTool(tool.name);
const isCompact = isCompactTool(tool, isCompactModeEnabled);
const hasPayload = hasDensePayload(tool);
const prevTool = index > 0 ? visibleToolCalls[index - 1] : null;
const prevIsCompact = prevTool
? isCompactTool(prevTool, isCompactModeEnabled)
: false;
const prevHasPayload = prevTool ? hasDensePayload(prevTool) : false;
const nextTool = !isLast ? visibleToolCalls[index + 1] : null;
const nextIsCompact = nextTool
@@ -295,12 +290,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
let marginTop = 0;
if (isFirst) {
marginTop = 1;
} else if (isCompact !== prevIsCompact) {
marginTop = 1;
} else if (isCompact && (hasPayload || prevHasPayload)) {
marginTop = 1;
} else if (!isCompact && prevIsCompact) {
marginTop = borderTopOverride ?? true ? 1 : 0;
} else if (!(isCompact && prevIsCompact)) {
marginTop = 1;
}
@@ -309,7 +300,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: 'medium' as const,
isFirst: isCompact ? false : prevIsCompact || isFirst,
isFirst: isCompact
? false
: isFirst
? (borderTopOverride ?? true)
: prevIsCompact,
borderColor,
borderDimColor,
isExpandable,
@@ -358,7 +353,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderBottom={isLast ? (borderBottomOverride ?? true) : true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"

View File

@@ -123,9 +123,9 @@ describe('ToolMessage Sticky Header Regression', () => {
// Content lines 1-4 should be scrolled off
expect(lastFrame()).not.toContain('c1-01');
expect(lastFrame()).not.toContain('c1-04');
// Line 6 and 7 should be visible (terminalHeight=5 means only 2 lines of content show below 3-line header)
// Line 5 and 6 should be visible (terminalHeight=5 means only 2 lines of content show below 3-line header + margin)
expect(lastFrame()).toContain('c1-05');
expect(lastFrame()).toContain('c1-06');
expect(lastFrame()).toContain('c1-07');
expect(lastFrame()).toMatchSnapshot();
// Scroll further so tool-1 is completely gone and tool-2's header should be stuck

View File

@@ -0,0 +1,132 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > hides diff content by default when in alternate buffer mode 1`] = `
" ✓ test-tool test.ts → Accepted [Show Diff]
"
`;
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff content by default when NOT in alternate buffer mode 1`] = `
" ✓ test-tool test.ts → Accepted
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > does not render result arrow if resultDisplay is missing 1`] = `
" o test-tool Test description
"
`;
exports[`DenseToolMessage > flattens newlines in string results 1`] = `
" ✓ test-tool Test description → Line 1 Line 2
"
`;
exports[`DenseToolMessage > renders correctly for Edit tool using confirmationDetails 1`] = `
" ? Edit styles.scss → Confirming
1 - body { color: blue; }
1 + body { color: red; }
"
`;
exports[`DenseToolMessage > renders correctly for Errored Edit tool 1`] = `
" x Edit styles.scss → Failed [Show Diff]
"
`;
exports[`DenseToolMessage > renders correctly for ReadManyFiles results 1`] = `
" ✓ test-tool Attempting to read files from **/*.ts → Read 3 file(s) (1 ignored)
file1.ts
file2.ts
file3.ts
"
`;
exports[`DenseToolMessage > renders correctly for Rejected Edit tool 1`] = `
" - Edit styles.scss → Rejected (+1, -1)
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > renders correctly for Rejected Edit tool with confirmationDetails and diffStat 1`] = `
" - Edit styles.scss → Rejected (+1, -1)
1 - body { color: blue; }
1 + body { color: red; }
"
`;
exports[`DenseToolMessage > renders correctly for Rejected WriteFile tool 1`] = `
" - WriteFile config.json → Rejected
1 - old content
1 + new content
"
`;
exports[`DenseToolMessage > renders correctly for WriteFile tool 1`] = `
" ✓ WriteFile config.json → Accepted (+1, -1)
1 - old content
1 + new content
"
`;
exports[`DenseToolMessage > renders correctly for a successful string result 1`] = `
" ✓ test-tool Test description → Success result
"
`;
exports[`DenseToolMessage > renders correctly for error status with string message 1`] = `
" x test-tool Test description → Error occurred
"
`;
exports[`DenseToolMessage > renders correctly for file diff results with stats 1`] = `
" ✓ test-tool test.ts → Accepted (+15, -6)
1 - old line
1 + diff content
"
`;
exports[`DenseToolMessage > renders correctly for grep results 1`] = `
" ✓ test-tool Test description → Found 2 matches
file1.ts:10: match 1
file2.ts:20: match 2
"
`;
exports[`DenseToolMessage > renders correctly for ls results 1`] = `
" ✓ test-tool Test description → Listed 2 files. (1 ignored)
"
`;
exports[`DenseToolMessage > renders correctly for todo updates 1`] = `
" ✓ test-tool Test description → Todos updated
"
`;
exports[`DenseToolMessage > renders generic failure message for error status without string message 1`] = `
" x test-tool Test description → Failed
"
`;
exports[`DenseToolMessage > renders generic output message for unknown object results 1`] = `
" ✓ test-tool Test description → Output received
"
`;
exports[`DenseToolMessage > truncates long string results 1`] = `
" ✓ test-tool Test description
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAA...
"
`;

View File

@@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolGroupMessage Compact Rendering > adds an empty line between a compact tool and a standard tool 1`] = `
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a compact tool and a standard tool 1`] = `
"
list_directory → file1.txt
ReadFolder → file1.txt
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool │
@@ -12,7 +12,7 @@ exports[`ToolGroupMessage Compact Rendering > adds an empty line between a compa
"
`;
exports[`ToolGroupMessage Compact Rendering > adds an empty line between a standard tool and a compact tool 1`] = `
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a standard tool and a compact tool 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool │
@@ -20,21 +20,20 @@ exports[`ToolGroupMessage Compact Rendering > adds an empty line between a stand
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
list_directory → file1.txt
ReadFolder → file1.txt
"
`;
exports[`ToolGroupMessage Compact Rendering > adds an empty line if a compact tool has a dense payload 1`] = `
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line if a compact tool has a dense payload 1`] = `
"
list_directory → file1.txt
ReadFolder → file1.txt
✓ ReadFile → read file
"
`;
exports[`ToolGroupMessage Compact Rendering > renders consecutive compact tools without empty lines between them 1`] = `
"
list_directory → file1.txt file2.txt
list_directory → file3.txt
ReadFolder → file1.txt file2.txt
ReadFolder → file3.txt
"
`;

View File

@@ -1,7 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status='error' and hasResult='error message' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ x Ask User │
│ │
│ error message │
@@ -10,7 +11,8 @@ exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status=
`;
exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status='success' and hasResult='test result' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ Ask User │
│ │
│ test result │
@@ -19,7 +21,8 @@ exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status=
`;
exports[`<ToolGroupMessage /> > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ other-tool A tool for testing │
│ │
│ Test result │
@@ -28,7 +31,8 @@ exports[`<ToolGroupMessage /> > Ask User Filtering > shows other tools when ask_
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test result │
@@ -41,7 +45,8 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all t
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ run_shell_command A tool for testing │
│ │
│ Test result │
@@ -55,18 +60,19 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
line5
│ │ █
│ ✓ tool-2 Description 2 │ █
│ │ █
│ line1 │ █
│ line2 │ █
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │
│ │
│ Test result │
@@ -83,7 +89,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls incl
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses (only visible ones) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ successful-tool This tool succeeded │
│ │
│ Test result │
@@ -100,7 +107,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls w
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test result │
@@ -109,7 +117,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful too
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with outputFile 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-file Tool that saved output to file │
│ │
│ Test result │
@@ -119,17 +128,18 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
"──────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────╮
"──────────────────────────────────────────────────────────────────────────
│ ✓ tool-2 Description 2 │
│ │
│ line1 │
│ │
│ line1 │
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │
│ │
│ This is a long result that might need height constraints │
@@ -142,7 +152,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
"╭──────────────────────────────────╮
"
╭──────────────────────────────────╮
│ ✓ very-long-tool-name-that-mig… │
│ │
│ Test result │
@@ -151,7 +162,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal
`;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Result 1 │

View File

@@ -1,11 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │
"
╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │
│ │
│ shell-01 │
│ shell-02 │
"
`;
@@ -13,17 +13,17 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │ ▄
│────────────────────────────────────────────────────────────────────────│ █
│ shell-06
│ shell-07
│ shell-05
│ shell-06
"
`;
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 1`] = `
"╭────────────────────────────────────────────────────────────────────────╮
"
╭────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description for tool-1 │
│ │
│ c1-01 │
│ c1-02 │
"
`;
@@ -31,8 +31,8 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description for tool-1 │ █
│────────────────────────────────────────────────────────────────────────│
│ c1-05 │
│ c1-06 │
│ c1-07 │
"
`;
@@ -40,7 +40,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa
"│ │
│ ✓ tool-2 Description for tool-2 │
│────────────────────────────────────────────────────────────────────────│
│ c2-10 │
╰────────────────────────────────────────────────────────────────────────╯ █
│ c2-09
│ c2-10 │ ▀
"
`;

View File

@@ -75,6 +75,9 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useLogger } from './useLogger.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
import {
isCompactTool,
} from '../components/messages/ToolGroupMessage.js';
import {
useToolScheduler,
type TrackedToolCall,
@@ -291,9 +294,34 @@ export const useGeminiStream = (
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
);
if (toolsToPush.length > 0) {
const isCompactModeEnabled =
settings.merged.ui?.compactToolOutput === true;
const firstToolToPush = toolsToPush[0];
const tcIndex = toolCalls.indexOf(firstToolToPush);
const prevTool = tcIndex > 0 ? toolCalls[tcIndex - 1] : null;
let borderTop = isFirstToolInGroupRef.current;
if (!borderTop && prevTool) {
// If the first tool in this push is non-compact but follows a compact tool,
// we must start a new border group.
const currentIsCompact = isCompactTool(
mapTrackedToolCallsToDisplay(firstToolToPush as TrackedToolCall)
.tools[0],
isCompactModeEnabled,
);
const prevWasCompact = isCompactTool(
mapTrackedToolCallsToDisplay(prevTool as TrackedToolCall)
.tools[0],
isCompactModeEnabled,
);
if (!currentIsCompact && prevWasCompact) {
borderTop = true;
}
}
addItem(
mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], {
borderTop: isFirstToolInGroupRef.current,
borderTop,
borderBottom: true,
borderColor: theme.border.default,
borderDimColor: false,
@@ -426,14 +454,18 @@ export const useGeminiStream = (
if (toolsToPush.length > 0) {
const newPushed = new Set(pushedToolCallIdsRef.current);
let isFirst = isFirstToolInGroupRef.current;
const isCompactModeEnabled =
settings.merged.ui?.compactToolOutput === true;
for (const tc of toolsToPush) {
newPushed.add(tc.request.callId);
const tcIndex = toolCalls.indexOf(tc);
const prevTool = tcIndex > 0 ? toolCalls[tcIndex - 1] : null;
const nextTool =
tcIndex < toolCalls.length - 1 ? toolCalls[tcIndex + 1] : null;
const isLastInBatch = tc === toolCalls[toolCalls.length - 1];
const historyItem = mapTrackedToolCallsToDisplay(tc, {
borderTop: isFirst,
borderBottom: isLastInBatch,
...getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
@@ -442,6 +474,33 @@ export const useGeminiStream = (
backgroundShells,
),
});
const currentIsCompact = historyItem.tools[0]
? isCompactTool(historyItem.tools[0], isCompactModeEnabled)
: false;
let nextIsCompact = false;
if (nextTool) {
const nextHistoryItem = mapTrackedToolCallsToDisplay(nextTool);
nextIsCompact = nextHistoryItem.tools[0]
? isCompactTool(nextHistoryItem.tools[0], isCompactModeEnabled)
: false;
}
let prevWasCompact = false;
if (prevTool) {
const prevHistoryItem = mapTrackedToolCallsToDisplay(prevTool);
prevWasCompact = prevHistoryItem.tools[0]
? isCompactTool(prevHistoryItem.tools[0], isCompactModeEnabled)
: false;
}
historyItem.borderTop =
isFirst || (!currentIsCompact && prevWasCompact);
historyItem.borderBottom = currentIsCompact
? isLastInBatch && !nextIsCompact
: isLastInBatch || nextIsCompact;
addItem(historyItem);
isFirst = false;
}
@@ -459,6 +518,7 @@ export const useGeminiStream = (
activeShellPtyId,
isShellFocused,
backgroundShells,
settings.merged.ui?.compactToolOutput,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
@@ -502,8 +562,7 @@ export const useGeminiStream = (
toolCalls.length > 0 &&
toolCalls.every((tc) => pushedToolCallIds.has(tc.request.callId));
const anyVisibleInHistory = pushedToolCallIds.size > 0;
const anyVisibleInPending = remainingTools.some((tc) => {
const isToolVisible = (tc: TrackedToolCall) => {
// AskUser tools are rendered by AskUserDialog, not ToolGroupMessage
const isInProgress =
tc.status !== 'success' &&
@@ -517,12 +576,28 @@ export const useGeminiStream = (
tc.status !== 'validating' &&
tc.status !== 'awaiting_approval'
);
});
};
const anyVisibleInHistory = pushedToolCallIds.size > 0;
const anyVisibleInPending = remainingTools.some(isToolVisible);
let lastVisibleIsCompact = false;
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
for (let i = toolCalls.length - 1; i >= 0; i--) {
if (isToolVisible(toolCalls[i])) {
const mapped = mapTrackedToolCallsToDisplay(toolCalls[i]);
lastVisibleIsCompact = mapped.tools[0]
? isCompactTool(mapped.tools[0], isCompactModeEnabled)
: false;
break;
}
}
if (
toolCalls.length > 0 &&
!(allTerminal && allPushed) &&
(anyVisibleInHistory || anyVisibleInPending)
(anyVisibleInHistory || anyVisibleInPending) &&
!lastVisibleIsCompact
) {
items.push({
type: 'tool_group' as const,
@@ -540,6 +615,7 @@ export const useGeminiStream = (
activeShellPtyId,
isShellFocused,
backgroundShells,
settings.merged.ui?.compactToolOutput,
]);
const lastQueryRef = useRef<PartListUnion | null>(null);

View File

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="207" fill="#000000" />
<rect width="920" height="224" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -17,16 +17,16 @@
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="138" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
<text x="855" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="172" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
<text x="855" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="207" fill="#000000" />
<rect width="920" height="224" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -17,16 +17,16 @@
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
<text x="855" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="138" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
<text x="855" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
<text x="855" y="155" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="172" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="172" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
<text x="855" y="172" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="207" fill="#000000" />
<rect width="920" height="224" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -17,16 +17,16 @@
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="138" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
<text x="855" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="172" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
<text x="855" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -7,11 +7,13 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▗▟▀
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = `
@@ -21,11 +23,13 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▗▟▀
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ run_shell_command │
│ │
│ Running command... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = `
@@ -35,9 +39,11 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▗▟▀
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;