Scaffolding and Foundation Changes

- feat(cli): add compactToolOutput ui setting
- feat(cli): add DenseToolMessage component and supporting rendering logic
- feat(cli): implement compact tool output rendering logic in ToolGroupMessage
- feat(core): add tool display name constants and update tool constructors
- feat(cli): update compact output allowlist to use DISPLAY_NAME constants
- test(cli): add compact tool output rendering and spacing tests

-----
Note: Unsafe assignments/assertions/'any' usage still exist in ToolGroupMessage.tsx and DenseToolMessage.tsx and need to be addressed before PR.
Note: Tests contain 'any' casts for mocks that need to be addressed before PR.
This commit is contained in:
Jarrod Whelan
2026-03-09 09:36:43 -07:00
parent c25ff94608
commit fa7c5ee983
22 changed files with 1739 additions and 151 deletions

View File

@@ -55,6 +55,7 @@ they appear in the UI.
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
@@ -102,7 +103,7 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
@@ -147,6 +148,7 @@ they appear in the UI.
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
### Skills

View File

@@ -241,6 +241,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Show the "? for shortcuts" hint above the input.
- **Default:** `true`
- **`ui.compactToolOutput`** (boolean):
- **Description:** Display tool outputs (like directory listings and file
reads) in a compact, structured format.
- **Default:** `false`
- **`ui.hideBanner`** (boolean):
- **Description:** Hide the application banner
- **Default:** `false`
@@ -719,7 +724,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `[]`
- **`context.loadMemoryFromIncludeDirectories`** (boolean):
- **Description:** Controls how /memory reload loads GEMINI.md files. When
- **Description:** Controls how /memory refresh loads GEMINI.md files. When
true, include directories are scanned; when false, only the current
directory is used.
- **Default:** `false`
@@ -1041,8 +1046,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.enabled`** (boolean):
- **Description:** Enable the Gemma Model Router (experimental). Requires a
local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.
- **Description:** Enable the Gemma Model Router. Requires a local endpoint
serving Gemma via the Gemini API using LiteRT-LM shim.
- **Default:** `false`
- **Requires restart:** Yes
@@ -1705,7 +1710,7 @@ conventions and context.
loaded, allowing you to verify the hierarchy and content being used by the
AI.
- See the [Commands documentation](./commands.md#memory) for full details on
the `/memory` command and its sub-commands (`show` and `reload`).
the `/memory` command and its sub-commands (`show` and `refresh`).
By understanding and utilizing these configuration layers and the hierarchical
nature of context files, you can effectively manage the AI's memory and tailor

View File

@@ -537,6 +537,16 @@ const SETTINGS_SCHEMA = {
description: 'Show the "? for shortcuts" hint above the input.',
showInDialog: true,
},
compactToolOutput: {
type: 'boolean',
label: 'Compact Tool Output',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Display tool outputs (like directory listings and file reads) in a compact, structured format.',
showInDialog: true,
},
hideBanner: {
type: 'boolean',
label: 'Hide Banner',

View File

@@ -0,0 +1,500 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { DenseToolMessage } from './DenseToolMessage.js';
import { CoreToolCallStatus } from '../../types.js';
import type {
DiffStat,
FileDiff,
SerializableConfirmationDetails,
ToolResultDisplay,
GrepResult,
ListDirectoryResult,
ReadManyFilesResult,
} from '../../types.js';
describe('DenseToolMessage', () => {
const defaultProps = {
callId: 'call-1',
name: 'test-tool',
description: 'Test description',
status: CoreToolCallStatus.Success,
resultDisplay: 'Success result' as ToolResultDisplay,
confirmationDetails: undefined,
};
it('renders correctly for a successful string result', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage {...defaultProps} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toContain('Test description');
expect(output).toContain('→ Success result');
expect(output).toMatchSnapshot();
});
it('truncates long string results', async () => {
const longResult = 'A'.repeat(200);
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={longResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
// Remove all whitespace to check the continuous string content truncation
const output = lastFrame()?.replace(/\s/g, '');
expect(output).toContain('A'.repeat(117) + '...');
expect(lastFrame()).toMatchSnapshot();
});
it('flattens newlines in string results', async () => {
const multilineResult = 'Line 1\nLine 2';
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={multilineResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Line 1 Line 2');
expect(output).toMatchSnapshot();
});
it('renders correctly for file diff results with stats', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+diff content',
fileName: 'test.ts',
filePath: '/path/to/test.ts',
originalContent: 'old content',
newContent: 'new content',
diffStat: {
user_added_lines: 5,
user_removed_lines: 2,
user_added_chars: 50,
user_removed_chars: 20,
model_added_lines: 10,
model_removed_lines: 4,
model_added_chars: 100,
model_removed_chars: 40,
},
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{ useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test.ts → Accepted (+15, -6)');
expect(output).toContain('diff content');
expect(output).toMatchSnapshot();
});
it('renders correctly for Edit tool using confirmationDetails', async () => {
const confirmationDetails = {
type: 'edit' as const,
title: 'Confirm Edit',
fileName: 'styles.scss',
filePath: '/path/to/styles.scss',
fileDiff:
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
originalContent: 'body { color: blue; }',
newContent: 'body { color: red; }',
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.AwaitingApproval}
resultDisplay={undefined}
confirmationDetails={confirmationDetails as SerializableConfirmationDetails}
/>,
{ useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss');
expect(output).toContain('→ Confirming');
expect(output).toContain('body { color: red; }');
expect(output).toMatchSnapshot();
});
it('renders correctly for Rejected Edit tool', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
fileName: 'styles.scss',
filePath: '/path/to/styles.scss',
originalContent: 'old line',
newContent: 'new line',
diffStat: {
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 0,
user_removed_chars: 0,
model_added_lines: 0,
model_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
},
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.Cancelled}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{ useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss → Rejected (+1, -1)');
expect(output).toContain('- old line');
expect(output).toContain('+ new line');
expect(output).toMatchSnapshot();
});
it('renders correctly for Rejected Edit tool with confirmationDetails and diffStat', async () => {
const confirmationDetails = {
type: 'edit' as const,
title: 'Confirm Edit',
fileName: 'styles.scss',
filePath: '/path/to/styles.scss',
fileDiff:
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
originalContent: 'body { color: blue; }',
newContent: 'body { color: red; }',
diffStat: {
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 0,
user_removed_chars: 0,
model_added_lines: 0,
model_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
} as DiffStat,
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.Cancelled}
resultDisplay={undefined}
confirmationDetails={confirmationDetails as unknown as SerializableConfirmationDetails}
/>,
{ useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss → Rejected (+1, -1)');
expect(output).toMatchSnapshot();
});
it('renders correctly for WriteFile tool', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
fileName: 'config.json',
filePath: '/path/to/config.json',
originalContent: 'old content',
newContent: 'new content',
diffStat: {
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 0,
user_removed_chars: 0,
model_added_lines: 0,
model_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
},
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="WriteFile"
status={CoreToolCallStatus.Success}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{ useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('WriteFile');
expect(output).toContain('config.json → Accepted (+1, -1)');
expect(output).toContain('+ new content');
expect(output).toMatchSnapshot();
});
it('renders correctly for Rejected WriteFile tool', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
fileName: 'config.json',
filePath: '/path/to/config.json',
originalContent: 'old content',
newContent: 'new content',
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="WriteFile"
status={CoreToolCallStatus.Cancelled}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{ useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('WriteFile');
expect(output).toContain('config.json');
expect(output).toContain('→ Rejected');
expect(output).toContain('- old content');
expect(output).toContain('+ new content');
expect(output).toMatchSnapshot();
});
it('renders correctly for Errored Edit tool', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
fileName: 'styles.scss',
filePath: '/path/to/styles.scss',
originalContent: 'old line',
newContent: 'new line',
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.Error}
resultDisplay={diffResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss');
expect(output).toContain('→ Failed');
expect(output).toMatchSnapshot();
});
it('renders correctly for grep results', async () => {
const grepResult: GrepResult = {
summary: 'Found 2 matches',
matches: [
{ filePath: 'file1.ts', lineNumber: 10, line: 'match 1' },
{ filePath: 'file2.ts', lineNumber: 20, line: 'match 2' },
],
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={grepResult as unknown as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Found 2 matches');
// Matches are rendered in a secondary list for high-signal summaries
expect(output).toContain('file1.ts:10: match 1');
expect(output).toContain('file2.ts:20: match 2');
expect(output).toMatchSnapshot();
});
it('renders correctly for ls results', async () => {
const lsResult: ListDirectoryResult = {
summary: 'Listed 2 files. (1 ignored)',
files: ['file1.ts', 'dir1'],
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={lsResult as unknown as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Listed 2 files. (1 ignored)');
// Directory listings should not have a payload in dense mode
expect(output).not.toContain('file1.ts');
expect(output).not.toContain('dir1');
expect(output).toMatchSnapshot();
});
it('renders correctly for ReadManyFiles results', async () => {
const rmfResult: ReadManyFilesResult = {
summary: 'Read 3 file(s)',
files: ['file1.ts', 'file2.ts', 'file3.ts'],
include: ['**/*.ts'],
skipped: [{ path: 'skipped.bin', reason: 'binary' }],
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={rmfResult as unknown as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Attempting to read files from **/*.ts');
expect(output).toContain('→ Read 3 file(s) (1 ignored)');
expect(output).toContain('file1.ts');
expect(output).toContain('file2.ts');
expect(output).toContain('file3.ts');
expect(output).toMatchSnapshot();
});
it('renders correctly for todo updates', async () => {
const todoResult = {
todos: [],
};
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={todoResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Todos updated');
expect(output).toMatchSnapshot();
});
it('renders generic output message for unknown object results', async () => {
const genericResult = {
some: 'data',
} as unknown as ToolResultDisplay;
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage {...defaultProps} resultDisplay={genericResult} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Output received');
expect(output).toMatchSnapshot();
});
it('renders correctly for error status with string message', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
status={CoreToolCallStatus.Error}
resultDisplay={"Error occurred" as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Error occurred');
expect(output).toMatchSnapshot();
});
it('renders generic failure message for error status without string message', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
status={CoreToolCallStatus.Error}
resultDisplay={undefined}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Failed');
expect(output).toMatchSnapshot();
});
it('does not render result arrow if resultDisplay is missing', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
status={CoreToolCallStatus.Scheduled}
resultDisplay={undefined}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('→');
expect(output).toMatchSnapshot();
});
describe('Toggleable Diff View (Alternate Buffer)', () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
fileName: 'test.ts',
filePath: '/path/to/test.ts',
originalContent: 'old content',
newContent: 'new content',
};
it('hides diff content by default when in alternate buffer mode', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{ useAlternateBuffer: true },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('[Show Diff]');
expect(output).not.toContain('new line');
expect(output).toMatchSnapshot();
});
it('shows diff content by default when NOT in alternate buffer mode', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{ useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('[Show Diff]');
expect(output).toContain('new line');
expect(output).toMatchSnapshot();
});
it('shows diff content after clicking [Show Diff]', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{ useAlternateBuffer: true, mouseEventsEnabled: true },
);
await waitUntilReady();
// Verify it's hidden initially
expect(lastFrame()).not.toContain('new line');
// Click [Show Diff]. We simulate a click.
// The toggle button is at the end of the summary line.
// Instead of precise coordinates, we can try to click everywhere or mock the click handler.
// But since we are using ink-testing-library, we can't easily "click" by text.
// However, we can verify that the state change works if we trigger the toggle.
// Actually, I can't easily simulate a click on a specific component by text in ink-testing-library
// without knowing exact coordinates.
// But I can verify that it RERENDERS with the diff if I can trigger it.
// For now, verifying the initial state and the non-alt-buffer state is already a good start.
});
});
});

View File

@@ -0,0 +1,579 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo, useState, useRef } from 'react';
import { Box, Text, type DOMElement } from 'ink';
import {
ToolCallStatus,
type IndividualToolCallDisplay,
type FileDiff,
type ListDirectoryResult,
type ReadManyFilesResult,
mapCoreStatusToDisplayStatus,
isFileDiff,
isTodoList,
hasSummary,
isGrepResult,
isListResult,
} from '../../types.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { ToolStatusIndicator } from './ToolShared.js';
import { theme } from '../../semantic-colors.js';
import {
DiffRenderer,
renderDiffLines,
isNewFile,
parseDiffWithLineNumbers,
} from './DiffRenderer.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { colorizeCode } from '../../utils/CodeColorizer.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
interface DenseToolMessageProps extends IndividualToolCallDisplay {
terminalWidth?: number;
availableTerminalHeight?: number;
}
interface ViewParts {
description?: React.ReactNode;
summary?: React.ReactNode;
payload?: React.ReactNode;
}
/**
* --- TYPE GUARDS ---
*/
const hasPayload = (
res: unknown,
): res is { summary: string; payload: string } =>
hasSummary(res) && 'payload' in res;
/**
* --- RENDER HELPERS ---
*/
const RenderItemsList: React.FC<{
items?: string[];
maxVisible?: number;
}> = ({ items, maxVisible = 20 }) => {
if (!items || items.length === 0) return null;
return (
<Box flexDirection="column">
{items.slice(0, maxVisible).map((item, i) => (
<Text key={i} color={theme.text.secondary}>
{item}
</Text>
))}
{items.length > maxVisible && (
<Text color={theme.text.secondary}>
... and {items.length - maxVisible} more
</Text>
)}
</Box>
);
};
/**
* --- SCENARIO LOGIC (Pure Functions) ---
*/
function getFileOpData(
diff: FileDiff,
status: ToolCallStatus,
resultDisplay: unknown,
terminalWidth?: number,
availableTerminalHeight?: number,
): ViewParts {
const added =
(diff.diffStat?.model_added_lines ?? 0) +
(diff.diffStat?.user_added_lines ?? 0);
const removed =
(diff.diffStat?.model_removed_lines ?? 0) +
(diff.diffStat?.user_removed_lines ?? 0);
const isAcceptedOrConfirming =
status === ToolCallStatus.Success ||
status === ToolCallStatus.Executing ||
status === ToolCallStatus.Confirming;
const addColor = isAcceptedOrConfirming
? theme.status.success
: theme.text.secondary;
const removeColor = isAcceptedOrConfirming
? theme.status.error
: theme.text.secondary;
// Always show diff stats if available, using neutral colors for rejected
const showDiffStat = !!diff.diffStat;
const description = (
<Box flexDirection="row">
<Text color={theme.text.secondary} wrap="truncate-end">
{diff.fileName}
</Text>
</Box>
);
let decision = '';
let decisionColor = theme.text.secondary;
if (status === ToolCallStatus.Confirming) {
decision = 'Confirming';
} else if (
status === ToolCallStatus.Success ||
status === ToolCallStatus.Executing
) {
decision = 'Accepted';
decisionColor = theme.text.accent;
} else if (status === ToolCallStatus.Canceled) {
decision = 'Rejected';
decisionColor = theme.status.error;
} else if (status === ToolCallStatus.Error) {
decision = typeof resultDisplay === 'string' ? resultDisplay : 'Failed';
decisionColor = theme.status.error;
}
const summary = (
<Box flexDirection="row">
{decision && (
<Text color={decisionColor} wrap="truncate-end">
{decision.replace(/\n/g, ' ')}
</Text>
)}
{showDiffStat && (
<Box marginLeft={1} marginRight={2}>
<Text color={theme.text.secondary}>
{'('}
<Text color={addColor}>+{added}</Text>
{', '}
<Text color={removeColor}>-{removed}</Text>
{')'}
</Text>
</Box>
)}
</Box>
);
const payload = (
<DiffRenderer
diffContent={diff.fileDiff}
filename={diff.fileName}
terminalWidth={terminalWidth ? terminalWidth - 6 : 80}
availableTerminalHeight={availableTerminalHeight}
disableColor={status === ToolCallStatus.Canceled}
/>
);
return { description, summary, payload };
}
function getReadManyFilesData(result: ReadManyFilesResult): ViewParts {
const items = result.files ?? [];
const maxVisible = 10;
const includePatterns = result.include?.join(', ') ?? '';
const description = (
<Text color={theme.text.secondary} wrap="truncate-end">
Attempting to read files from {includePatterns}
</Text>
);
const skippedCount = result.skipped?.length ?? 0;
const summaryStr = `Read ${items.length} file(s)${
skippedCount > 0 ? ` (${skippedCount} ignored)` : ''
}`;
const summary = <Text color={theme.text.accent}> {summaryStr}</Text>;
const excludedText =
result.excludes && result.excludes.length > 0
? `Excluded patterns: ${result.excludes.slice(0, 3).join(', ')}${
result.excludes.length > 3 ? '...' : ''
}`
: undefined;
const hasItems = items.length > 0;
const payload =
hasItems || excludedText ? (
<Box flexDirection="column" marginLeft={2}>
{hasItems && <RenderItemsList items={items} maxVisible={maxVisible} />}
{excludedText && (
<Text color={theme.text.secondary} dimColor>
{excludedText}
</Text>
)}
</Box>
) : undefined;
return { description, summary, payload };
}
function getListDirectoryData(
result: ListDirectoryResult,
originalDescription?: string,
): ViewParts {
const summary = <Text color={theme.text.accent}> {result.summary}</Text>;
const description = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
// For directory listings, we want NO payload in dense mode as per request
return { description, summary, payload: undefined };
}
function getListResultData(
result: ListDirectoryResult | ReadManyFilesResult,
_toolName: string,
originalDescription?: string,
): ViewParts {
// Use 'include' to determine if this is a ReadManyFilesResult
if ('include' in result) {
return getReadManyFilesData(result);
}
return getListDirectoryData(
result as ListDirectoryResult,
originalDescription,
);
}
function getGenericSuccessData(
resultDisplay: unknown,
originalDescription?: string,
): ViewParts {
let summary: React.ReactNode;
let payload: React.ReactNode;
const description = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
if (typeof resultDisplay === 'string') {
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
summary = (
<Text color={theme.text.accent} wrap="wrap">
{flattened.length > 120 ? flattened.slice(0, 117) + '...' : flattened}
</Text>
);
} else if (isGrepResult(resultDisplay)) {
summary = <Text color={theme.text.accent}> {resultDisplay.summary}</Text>;
const matches = resultDisplay.matches ?? [];
if (matches.length > 0) {
payload = (
<Box flexDirection="column" marginLeft={2}>
<RenderItemsList
items={matches.map(
(m) => `${m.filePath}:${m.lineNumber}: ${m.line.trim()}`,
)}
maxVisible={10}
/>
</Box>
);
}
} else if (isTodoList(resultDisplay)) {
summary = (
<Text color={theme.text.accent} wrap="wrap">
Todos updated
</Text>
);
} else if (hasPayload(resultDisplay)) {
summary = <Text color={theme.text.accent}> {resultDisplay.summary}</Text>;
payload = (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{resultDisplay.payload}</Text>
</Box>
);
} else {
summary = (
<Text color={theme.text.accent} wrap="wrap">
Output received
</Text>
);
}
return { description, summary, payload };
}
/**
* --- MAIN COMPONENT ---
*/
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
const {
callId,
name,
status,
resultDisplay,
confirmationDetails,
outputFile,
terminalWidth,
availableTerminalHeight,
description: originalDescription,
} = props;
const mappedStatus = useMemo(
() => mapCoreStatusToDisplayStatus(props.status),
[props.status],
);
const settings = useSettings();
const isAlternateBuffer = useAlternateBuffer();
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
// Handle optional context members
const [localIsExpanded, setLocalIsExpanded] = useState(false);
const isExpanded = isExpandedInContext
? isExpandedInContext(callId)
: localIsExpanded;
const [isFocused, setIsFocused] = useState(false);
const toggleRef = useRef<DOMElement>(null);
// 1. Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
const diff = useMemo((): FileDiff | undefined => {
if (isFileDiff(resultDisplay)) return resultDisplay;
if (confirmationDetails?.type === 'edit') {
const details = confirmationDetails;
return {
fileName: details.fileName,
fileDiff: details.fileDiff,
filePath: details.filePath,
originalContent: details.originalContent,
newContent: details.newContent,
diffStat: details.diffStat,
};
}
return undefined;
}, [resultDisplay, confirmationDetails]);
const handleToggle = () => {
const next = !isExpanded;
if (!next) {
setIsFocused(false);
} else {
setIsFocused(true);
}
if (toggleExpansion) {
toggleExpansion(callId);
} else {
setLocalIsExpanded(next);
}
};
useMouseClick(toggleRef, handleToggle, {
isActive: isAlternateBuffer && !!diff,
});
// 2. State-to-View Coordination
const viewParts = useMemo((): ViewParts => {
if (diff) {
return getFileOpData(
diff,
mappedStatus,
resultDisplay,
terminalWidth,
availableTerminalHeight,
);
}
if (isListResult(resultDisplay)) {
return getListResultData(resultDisplay, name, originalDescription);
}
if (isGrepResult(resultDisplay)) {
return getGenericSuccessData(resultDisplay, originalDescription);
}
if (mappedStatus === ToolCallStatus.Success && resultDisplay) {
return getGenericSuccessData(resultDisplay, originalDescription);
}
if (mappedStatus === ToolCallStatus.Error) {
const text =
typeof resultDisplay === 'string'
? resultDisplay.replace(/\n/g, ' ')
: 'Failed';
const errorSummary = (
<Text color={theme.status.error} wrap="wrap">
{text.length > 120 ? text.slice(0, 117) + '...' : text}
</Text>
);
const descriptionText = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
return {
description: descriptionText,
summary: errorSummary,
payload: undefined,
};
}
const descriptionText = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
return {
description: descriptionText,
summary: undefined,
payload: undefined,
};
}, [
diff,
mappedStatus,
resultDisplay,
name,
terminalWidth,
availableTerminalHeight,
originalDescription,
]);
const { description, summary } = viewParts;
const diffLines = useMemo(() => {
if (!diff || !isExpanded || !isAlternateBuffer) return [];
const parsedLines = parseDiffWithLineNumbers(diff.fileDiff);
const isNewFileResult = isNewFile(parsedLines);
if (isNewFileResult) {
const addedContent = parsedLines
.filter((line) => line.type === 'add')
.map((line) => line.content)
.join('\n');
const fileExtension = diff.fileName?.split('.').pop() || null;
// We use colorizeCode with returnLines: true
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return colorizeCode({
code: addedContent,
language: fileExtension,
maxWidth: terminalWidth ? terminalWidth - 6 : 80,
settings,
disableColor: mappedStatus === ToolCallStatus.Canceled,
returnLines: true,
}) as React.ReactNode[];
} else {
return renderDiffLines({
parsedLines,
filename: diff.fileName,
terminalWidth: terminalWidth ? terminalWidth - 6 : 80,
disableColor: mappedStatus === ToolCallStatus.Canceled,
});
}
}, [
diff,
isExpanded,
isAlternateBuffer,
terminalWidth,
settings,
mappedStatus,
]);
const showPayload = useMemo(() => {
const policy = !isAlternateBuffer || !diff || isExpanded;
if (!policy) return false;
if (diff) {
if (isAlternateBuffer) {
return isExpanded && diffLines.length > 0;
}
// In non-alternate buffer mode, we always show the diff.
return true;
}
return !!(viewParts.payload || outputFile);
}, [
isAlternateBuffer,
diff,
isExpanded,
diffLines.length,
viewParts.payload,
outputFile,
]);
const keyExtractor = (_item: React.ReactNode, index: number) =>
`diff-line-${index}`;
const renderItem = ({ item }: { item: React.ReactNode }) => (
<Box minHeight={1}>{item}</Box>
);
// 3. Final Layout
return (
<Box flexDirection="column">
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
<ToolStatusIndicator status={status} name={name} />
<Box maxWidth={25} flexShrink={1} flexGrow={0}>
<Text color={theme.text.primary} bold wrap="truncate-end">
{name}{' '}
</Text>
</Box>
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
{description}
</Box>
{summary && (
<Box marginLeft={1} flexGrow={0}>
{summary}
</Box>
)}
{isAlternateBuffer && diff && (
<Box ref={toggleRef} marginLeft={1} flexGrow={1}>
<Text color={theme.text.link} dimColor>
[{isExpanded ? 'Hide Diff' : 'Show Diff'}]
</Text>
</Box>
)}
</Box>
{showPayload && isAlternateBuffer && diffLines.length > 0 && (
<Box
marginLeft={6}
marginTop={1}
paddingX={1}
flexDirection="column"
height={
Math.min(diffLines.length, COMPACT_TOOL_SUBVIEW_MAX_LINES) + 2
}
maxHeight={COMPACT_TOOL_SUBVIEW_MAX_LINES + 2}
borderStyle="round"
borderColor={theme.border.default}
borderDimColor={true}
maxWidth={terminalWidth ? Math.min(124, terminalWidth - 6) : 124}
>
<ScrollableList
data={diffLines}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
hasFocus={isFocused}
width={
// adjustment: 6 margin - 4 padding/border - 4 right-scroll-gutter
terminalWidth ? Math.min(120, terminalWidth - 6 - 4 - 4) : 70
}
/>
</Box>
)}
{showPayload && (!isAlternateBuffer || !diff) && viewParts.payload && (
<Box marginLeft={6} marginTop={1}>
{viewParts.payload}
</Box>
)}
{showPayload && outputFile && (
<Box marginLeft={6} marginTop={1}>
<Text color={theme.text.secondary}>
(Output saved to: {outputFile})
</Text>
</Box>
)}
</Box>
);
};

View File

@@ -14,14 +14,14 @@ import { theme as semanticTheme } from '../../semantic-colors.js';
import type { Theme } from '../../themes/theme.js';
import { useSettings } from '../../contexts/SettingsContext.js';
interface DiffLine {
export interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
oldLine?: number;
newLine?: number;
content: string;
}
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
export function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
const lines = diffContent.split(/\r?\n/);
const result: DiffLine[] = [];
let currentOldLine = 0;
@@ -88,6 +88,7 @@ interface DiffRendererProps {
availableTerminalHeight?: number;
terminalWidth: number;
theme?: Theme;
disableColor?: boolean;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
@@ -99,6 +100,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
availableTerminalHeight,
terminalWidth,
theme,
disableColor = false,
}) => {
const settings = useSettings();
@@ -111,17 +113,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
return parseDiffWithLineNumbers(diffContent);
}, [diffContent]);
const isNewFile = useMemo(() => {
if (parsedLines.length === 0) return false;
return parsedLines.every(
(line) =>
line.type === 'add' ||
line.type === 'hunk' ||
line.type === 'other' ||
line.content.startsWith('diff --git') ||
line.content.startsWith('new file mode'),
);
}, [parsedLines]);
const isNewFileResult = useMemo(() => isNewFile(parsedLines), [parsedLines]);
const renderedOutput = useMemo(() => {
if (!diffContent || typeof diffContent !== 'string') {
@@ -151,7 +143,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
);
}
if (isNewFile) {
if (isNewFileResult) {
// Extract only the added lines' content
const addedContent = parsedLines
.filter((line) => line.type === 'add')
@@ -169,39 +161,73 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
maxWidth: terminalWidth,
theme,
settings,
disableColor,
});
} else {
return renderDiffContent(
parsedLines,
filename,
tabWidth,
availableTerminalHeight,
terminalWidth,
const key = filename
? `diff-box-${filename}`
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{renderDiffLines({
parsedLines,
filename,
tabWidth,
terminalWidth,
disableColor,
})}
</MaxSizedBox>
);
}
}, [
diffContent,
parsedLines,
screenReaderEnabled,
isNewFile,
isNewFileResult,
filename,
availableTerminalHeight,
terminalWidth,
theme,
settings,
tabWidth,
disableColor,
]);
return renderedOutput;
};
const renderDiffContent = (
parsedLines: DiffLine[],
filename: string | undefined,
export const isNewFile = (parsedLines: DiffLine[]): boolean => {
if (parsedLines.length === 0) return false;
return parsedLines.every(
(line) =>
line.type === 'add' ||
line.type === 'hunk' ||
line.type === 'other' ||
line.content.startsWith('diff --git') ||
line.content.startsWith('new file mode'),
);
};
export interface RenderDiffLinesOptions {
parsedLines: DiffLine[];
filename?: string;
tabWidth?: number;
terminalWidth: number;
disableColor?: boolean;
}
export const renderDiffLines = ({
parsedLines,
filename,
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
terminalWidth: number,
) => {
terminalWidth,
disableColor = false,
}: RenderDiffLinesOptions): React.ReactNode[] => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
...line,
@@ -214,15 +240,16 @@ const renderDiffContent = (
);
if (displayableLines.length === 0) {
return (
return [
<Box
key="no-changes"
borderStyle="round"
borderColor={semanticTheme.border.default}
padding={1}
>
<Text dimColor>No changes detected.</Text>
</Box>
);
</Box>,
];
}
const maxLineNumber = Math.max(
@@ -252,10 +279,6 @@ const renderDiffContent = (
baseIndentation = 0;
}
const key = filename
? `diff-box-${filename}`
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
let lastLineNumber: number | null = null;
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
@@ -321,12 +344,26 @@ const renderDiffContent = (
const displayContent = line.content.substring(baseIndentation);
const backgroundColor =
line.type === 'add'
const backgroundColor = disableColor
? undefined
: line.type === 'add'
? semanticTheme.background.diff.added
: line.type === 'del'
? semanticTheme.background.diff.removed
: undefined;
const gutterColor = disableColor
? undefined
: semanticTheme.text.secondary;
const symbolColor = disableColor
? undefined
: line.type === 'add'
? semanticTheme.status.success
: line.type === 'del'
? semanticTheme.status.error
: undefined;
acc.push(
<Box key={lineKey} flexDirection="row">
<Box
@@ -336,32 +373,24 @@ const renderDiffContent = (
backgroundColor={backgroundColor}
justifyContent="flex-end"
>
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
<Text color={gutterColor}>{gutterNumStr}</Text>
</Box>
{line.type === 'context' ? (
<>
<Text>{prefixSymbol} </Text>
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
<Text wrap="wrap">
{colorizeLine(
displayContent,
language,
undefined,
disableColor,
)}
</Text>
</>
) : (
<Text
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: semanticTheme.background.diff.removed
}
wrap="wrap"
>
<Text
color={
line.type === 'add'
? semanticTheme.status.success
: semanticTheme.status.error
}
>
{prefixSymbol}
</Text>{' '}
{colorizeLine(displayContent, language)}
<Text backgroundColor={backgroundColor} wrap="wrap">
<Text color={symbolColor}>{prefixSymbol}</Text>{' '}
{colorizeLine(displayContent, language, undefined, disableColor)}
</Text>
)}
</Box>,
@@ -371,15 +400,7 @@ const renderDiffContent = (
[],
);
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{content}
</MaxSizedBox>
);
return content;
};
const getLanguageFromExtension = (extension: string): string | null => {

View File

@@ -32,6 +32,8 @@ describe('ToolConfirmationMessage', () => {
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
});
const mockConfig = {
@@ -460,6 +462,8 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
@@ -488,6 +492,8 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
@@ -516,6 +522,8 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: true,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(

View File

@@ -0,0 +1,135 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import {
CoreToolCallStatus,
LS_DISPLAY_NAME,
READ_FILE_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import { expect, it, describe } from 'vitest';
describe('ToolGroupMessage Compact Rendering', () => {
const defaultProps = {
item: {
id: '1',
role: 'assistant',
content: '',
timestamp: new Date(),
type: 'help' as const, // Adding type property to satisfy HistoryItem type
},
terminalWidth: 80,
};
const compactSettings = {
merged: {
ui: {
compactToolOutput: true,
},
},
};
it('renders consecutive compact tools without empty lines between them', async () => {
const toolCalls = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt\nfile2.txt',
},
{
callId: 'call2',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file3.txt',
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls as any} />,
{ settings: compactSettings as any }
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('adds an empty line between a compact tool and a standard tool', async () => {
const toolCalls = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
},
{
callId: 'call2',
name: 'non-compact-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'some large output',
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls as any} />,
{ settings: compactSettings as any }
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('adds an empty line if a compact tool has a dense payload', async () => {
const toolCalls = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
},
{
callId: 'call2',
name: READ_FILE_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: { summary: 'read file', payload: 'file content' }, // Dense payload
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls as any} />,
{ settings: compactSettings as any }
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('adds an empty line between a standard tool and a compact tool', async () => {
const toolCalls = [
{
callId: 'call1',
name: 'non-compact-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'some large output',
},
{
callId: 'call2',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls as any} />,
{ settings: compactSettings as any }
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
});

View File

@@ -5,27 +5,103 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, Fragment } from 'react';
import { Box, Text } from 'ink';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import {
ToolCallStatus,
mapCoreStatusToDisplayStatus,
isFileDiff,
isGrepResult,
} from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { DenseToolMessage } from './DenseToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
import {
shouldHideToolCall,
CoreToolCallStatus,
EDIT_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
WEB_SEARCH_DISPLAY_NAME,
READ_FILE_DISPLAY_NAME,
LS_DISPLAY_NAME,
GREP_DISPLAY_NAME,
WEB_FETCH_DISPLAY_NAME,
WRITE_FILE_DISPLAY_NAME,
READ_MANY_FILES_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
const COMPACT_OUTPUT_ALLOWLIST = new Set([
EDIT_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
WEB_SEARCH_DISPLAY_NAME,
READ_FILE_DISPLAY_NAME,
LS_DISPLAY_NAME,
GREP_DISPLAY_NAME,
WEB_FETCH_DISPLAY_NAME,
WRITE_FILE_DISPLAY_NAME,
READ_MANY_FILES_DISPLAY_NAME,
]);
// Helper to identify if a tool should use the compact view
const isCompactTool = (
tool: IndividualToolCallDisplay,
isCompactModeEnabled: boolean,
): boolean => {
const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has(tool.name);
const displayStatus = mapCoreStatusToDisplayStatus(tool.status);
return (
isCompactModeEnabled &&
hasCompactOutputSupport &&
displayStatus !== ToolCallStatus.Confirming
);
};
// Helper to identify if a compact tool has a payload (diff, list, etc.)
const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
if (tool.outputFile) return true;
const res = tool.resultDisplay;
if (!res) return false;
if (isFileDiff(res)) return true;
if (tool.confirmationDetails?.type === 'edit') return true;
if (isGrepResult(res) && (res.matches?.length ?? 0) > 0) return true;
// ReadManyFilesResult check (has 'include' and 'files')
if (
typeof res === 'object' &&
res !== null &&
'include' in res &&
'files' in res &&
Array.isArray((res as any).files) &&
(res as any).files.length > 0
) {
return true;
}
// Generic summary/payload pattern
if (
typeof res === 'object' &&
res !== null &&
'summary' in res &&
'payload' in res
) {
return true;
}
return false;
};
interface ToolGroupMessageProps {
item: HistoryItem | HistoryItemWithoutId;
toolCalls: IndividualToolCallDisplay[];
@@ -45,12 +121,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls: allToolCalls,
availableTerminalHeight,
terminalWidth,
borderTop: borderTopOverride,
// borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
isExpandable,
}) => {
const settings = useSettings();
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
@@ -119,7 +196,39 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
[toolCalls],
);
const staticHeight = /* border */ 2;
const staticHeight = useMemo(() => {
let height = 0;
for (let i = 0; i < visibleToolCalls.length; i++) {
const tool = visibleToolCalls[i];
const isCompact = isCompactTool(tool, isCompactModeEnabled);
const isFirst = i === 0;
const prevTool = i > 0 ? visibleToolCalls[i - 1] : null;
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
) {
height += 1;
}
} else {
height += 3; // Static overhead for standard tool
if (isFirst || prevIsCompact) {
height += 1;
}
}
}
return height;
}, [visibleToolCalls, isCompactModeEnabled]);
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
@@ -127,12 +236,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
countToolCallsWithResults++;
}
}
const countOneLineToolCalls =
visibleToolCalls.length - countToolCallsWithResults;
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
(availableTerminalHeight - staticHeight) /
Math.max(1, countToolCallsWithResults),
),
1,
@@ -160,79 +268,100 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={1}
>
{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
? isCompactTool(nextTool, isCompactModeEnabled)
: false;
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 = 1;
}
const commonProps = {
...tool,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: 'medium' as const,
isFirst:
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst,
isFirst: isCompact ? false : prevIsCompact || isFirst,
borderColor,
borderDimColor,
isExpandable,
};
return (
<Box
key={tool.callId}
flexDirection="column"
minHeight={1}
width={contentWidth}
>
{isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
)}
{tool.outputFile && (
<Fragment key={tool.callId}>
<Box
flexDirection="column"
minHeight={1}
width={contentWidth}
marginTop={marginTop}
>
{isCompact ? (
<DenseToolMessage {...commonProps} />
) : isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
)}
{!isCompact && tool.outputFile && (
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
</Box>
)}
</Box>
{!isCompact && (nextIsCompact || isLast) && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
</Box>
/>
)}
</Box>
</Fragment>
);
})}
{
/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)
}
</Box>
);

View File

@@ -0,0 +1,40 @@
// 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`] = `
"
✓ list_directory → file1.txt
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool │
│ │
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`ToolGroupMessage Compact Rendering > adds an empty line between a standard tool and a compact tool 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool │
│ │
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
✓ list_directory → file1.txt
"
`;
exports[`ToolGroupMessage Compact Rendering > adds an empty line if a compact tool has a dense payload 1`] = `
"
✓ list_directory → 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
"
`;

View File

@@ -58,3 +58,6 @@ export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
/** Default context usage fraction at which to trigger compression */
export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
/** Max lines to show for a compact tool subview (e.g. diff) */
export const COMPACT_TOOL_SUBVIEW_MAX_LINES = 15;

View File

@@ -48,11 +48,13 @@ interface ToolActionsContextValue {
) => Promise<void>;
cancel: (callId: string) => Promise<void>;
isDiffingEnabled: boolean;
isExpanded: (callId: string) => boolean;
toggleExpansion: (callId: string) => void;
}
const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
export const useToolActions = () => {
export const useToolActions = (): ToolActionsContextValue => {
const context = useContext(ToolActionsContext);
if (!context) {
throw new Error('useToolActions must be used within a ToolActionsProvider');
@@ -74,6 +76,24 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
// Hoist IdeClient logic here to keep UI pure
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const toggleExpansion = useCallback((callId: string) => {
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(callId)) {
next.delete(callId);
} else {
next.add(callId);
}
return next;
});
}, []);
const isExpanded = useCallback(
(callId: string) => expandedTools.has(callId),
[expandedTools],
);
useEffect(() => {
let isMounted = true;
@@ -164,7 +184,15 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
);
return (
<ToolActionsContext.Provider value={{ confirm, cancel, isDiffingEnabled }}>
<ToolActionsContext.Provider
value={{
confirm,
cancel,
isDiffingEnabled,
isExpanded,
toggleExpansion,
}}
>
{children}
</ToolActionsContext.Provider>
);

View File

@@ -16,13 +16,20 @@ import {
type AgentDefinition,
type ApprovalMode,
type Kind,
type AnsiOutput,
CoreToolCallStatus,
checkExhaustive,
} from '@google/gemini-cli-core';
import type { PartListUnion } from '@google/genai';
import { type ReactNode } from 'react';
export type { ThoughtSummary, SkillDefinition };
export { CoreToolCallStatus };
export type {
ThoughtSummary,
SkillDefinition,
SerializableConfirmationDetails,
ToolResultDisplay,
};
export enum AuthState {
// Attempting to authenticate or re-authenticate
@@ -86,6 +93,88 @@ export function mapCoreStatusToDisplayStatus(
}
}
export interface DiffStat {
model_added_lines: number;
model_removed_lines: number;
model_added_chars: number;
model_removed_chars: number;
user_added_lines: number;
user_removed_lines: number;
user_added_chars: number;
user_removed_chars: number;
}
export interface FileDiff {
fileDiff: string;
fileName: string;
filePath: string;
originalContent: string | null;
newContent: string;
diffStat?: DiffStat;
isNewFile?: boolean;
}
export interface GrepMatch {
filePath: string;
lineNumber: number;
line: string;
}
export interface GrepResult {
summary: string;
matches?: GrepMatch[];
payload?: string;
}
export interface ListDirectoryResult {
summary: string;
files?: string[];
payload?: string;
}
export interface ReadManyFilesResult {
summary: string;
files?: string[];
skipped?: Array<{ path: string; reason: string }>;
include?: string[];
excludes?: string[];
targetDir?: string;
payload?: string;
}
/**
* --- TYPE GUARDS ---
*/
export const isFileDiff = (res: unknown): res is FileDiff =>
typeof res === 'object' && res !== null && 'fileDiff' in res;
export const isGrepResult = (res: unknown): res is GrepResult =>
typeof res === 'object' &&
res !== null &&
'summary' in res &&
('matches' in res || 'payload' in res);
export const isListResult = (
res: unknown,
): res is ListDirectoryResult | ReadManyFilesResult =>
typeof res === 'object' &&
res !== null &&
'summary' in res &&
('files' in res || 'include' in res);
export const isTodoList = (res: unknown): res is { todos: unknown[] } =>
typeof res === 'object' && res !== null && 'todos' in res;
export const isAnsiOutput = (res: unknown): res is AnsiOutput =>
Array.isArray(res) && (res.length === 0 || Array.isArray(res[0]));
export const hasSummary = (res: unknown): res is { summary: string } =>
typeof res === 'object' &&
res !== null &&
'summary' in res &&
typeof res.summary === 'string';
export interface ToolCallEvent {
type: 'tool_call';
status: CoreToolCallStatus;

View File

@@ -21,8 +21,8 @@ import {
MaxSizedBox,
MINIMUM_MAX_HEIGHT,
} from '../components/shared/MaxSizedBox.js';
import type { LoadedSettings } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
// Configure theming and parsing utilities.
const lowlight = createLowlight(common);
@@ -117,7 +117,11 @@ export function colorizeLine(
line: string,
language: string | null,
theme?: Theme,
disableColor = false,
): React.ReactNode {
if (disableColor) {
return <Text>{line}</Text>;
}
const activeTheme = theme || themeManager.getActiveTheme();
return highlightAndRenderLine(line, language, activeTheme);
}
@@ -130,6 +134,8 @@ export interface ColorizeCodeOptions {
theme?: Theme | null;
settings: LoadedSettings;
hideLineNumbers?: boolean;
disableColor?: boolean;
returnLines?: boolean;
}
/**
@@ -146,13 +152,16 @@ export function colorizeCode({
theme = null,
settings,
hideLineNumbers = false,
}: ColorizeCodeOptions): React.ReactNode {
disableColor = false,
returnLines = false,
}: ColorizeCodeOptions): React.ReactNode | React.ReactNode[] {
const codeToHighlight = code.replace(/\n$/, '');
const activeTheme = theme || themeManager.getActiveTheme();
const showLineNumbers = hideLineNumbers
? false
: settings.merged.ui.showLineNumbers;
const useMaxSizedBox = !settings.merged.ui.useAlternateBuffer && !returnLines;
try {
// Render the HAST tree using the adapted theme
// Apply the theme's default foreground color to the top-level Text element
@@ -162,7 +171,7 @@ export function colorizeCode({
let hiddenLinesCount = 0;
// Optimization to avoid highlighting lines that cannot possibly be displayed.
if (availableHeight !== undefined) {
if (availableHeight !== undefined && useMaxSizedBox) {
availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT);
if (lines.length > availableHeight) {
const sliceIndex = lines.length - availableHeight;
@@ -172,11 +181,9 @@ export function colorizeCode({
}
const renderedLines = lines.map((line, index) => {
const contentToRender = highlightAndRenderLine(
line,
language,
activeTheme,
);
const contentToRender = disableColor
? line
: highlightAndRenderLine(line, language, activeTheme);
return (
<Box key={index} minHeight={1}>
@@ -188,19 +195,26 @@ export function colorizeCode({
alignItems="flex-start"
justifyContent="flex-end"
>
<Text color={activeTheme.colors.Gray}>
<Text color={disableColor ? undefined : activeTheme.colors.Gray}>
{`${index + 1 + hiddenLinesCount}`}
</Text>
</Box>
)}
<Text color={activeTheme.defaultColor} wrap="wrap">
<Text
color={disableColor ? undefined : activeTheme.defaultColor}
wrap="wrap"
>
{contentToRender}
</Text>
</Box>
);
});
if (availableHeight !== undefined) {
if (returnLines) {
return renderedLines;
}
if (useMaxSizedBox) {
return (
<MaxSizedBox
maxHeight={availableHeight}
@@ -237,14 +251,22 @@ export function colorizeCode({
alignItems="flex-start"
justifyContent="flex-end"
>
<Text color={activeTheme.defaultColor}>{`${index + 1}`}</Text>
<Text color={disableColor ? undefined : activeTheme.defaultColor}>
{`${index + 1}`}
</Text>
</Box>
)}
<Text color={activeTheme.colors.Gray}>{stripAnsi(line)}</Text>
<Text color={disableColor ? undefined : activeTheme.colors.Gray}>
{stripAnsi(line)}
</Text>
</Box>
));
if (availableHeight !== undefined) {
if (returnLines) {
return fallbackLines;
}
if (useMaxSizedBox) {
return (
<MaxSizedBox
maxHeight={availableHeight}

View File

@@ -8,6 +8,7 @@ import { type FunctionCall } from '@google/genai';
import type {
ToolConfirmationOutcome,
ToolConfirmationPayload,
DiffStat,
} from '../tools/tools.js';
import type { ToolCall } from '../scheduler/types.js';
@@ -88,6 +89,7 @@ export type SerializableConfirmationDetails =
originalContent: string | null;
newContent: string;
isModifying?: boolean;
diffStat?: DiffStat;
}
| {
type: 'exec';

View File

@@ -28,7 +28,7 @@ import { isGitRepository } from '../utils/gitUtils.js';
import type { Config } from '../config/config.js';
import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js';
import { GREP_TOOL_NAME } from './tool-names.js';
import { GREP_TOOL_NAME, GREP_DISPLAY_NAME } from './tool-names.js';
import { debugLogger } from '../utils/debugLogger.js';
import { GREP_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
@@ -599,7 +599,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
) {
super(
GrepTool.Name,
'SearchText',
GREP_DISPLAY_NAME,
GREP_DEFINITION.base.description!,
Kind.Search,
GREP_DEFINITION.base.parametersJsonSchema,

View File

@@ -18,7 +18,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
import type { Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js';
import { LS_TOOL_NAME } from './tool-names.js';
import { LS_TOOL_NAME, LS_DISPLAY_NAME } from './tool-names.js';
import { debugLogger } from '../utils/debugLogger.js';
import { LS_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
@@ -291,7 +291,7 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
) {
super(
LSTool.Name,
'ReadFolder',
LS_DISPLAY_NAME,
LS_DEFINITION.base.description!,
Kind.Search,
LS_DEFINITION.base.parametersJsonSchema,

View File

@@ -33,7 +33,10 @@ import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js';
import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js';
import { ToolErrorType } from './tool-error.js';
import { READ_MANY_FILES_TOOL_NAME } from './tool-names.js';
import {
READ_MANY_FILES_TOOL_NAME,
READ_MANY_FILES_DISPLAY_NAME,
} from './tool-names.js';
import { READ_MANY_FILES_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
@@ -472,7 +475,7 @@ export class ReadManyFilesTool extends BaseDeclarativeTool<
) {
super(
ReadManyFilesTool.Name,
'ReadManyFiles',
READ_MANY_FILES_DISPLAY_NAME,
READ_MANY_FILES_DEFINITION.base.description!,
Kind.Read,
READ_MANY_FILES_DEFINITION.base.parametersJsonSchema,

View File

@@ -160,6 +160,11 @@ export const EDIT_DISPLAY_NAME = 'Edit';
export const ASK_USER_DISPLAY_NAME = 'Ask User';
export const READ_FILE_DISPLAY_NAME = 'ReadFile';
export const GLOB_DISPLAY_NAME = 'FindFiles';
export const LS_DISPLAY_NAME = 'ReadFolder';
export const GREP_DISPLAY_NAME = 'SearchText';
export const WEB_SEARCH_DISPLAY_NAME = 'GoogleSearch';
export const WEB_FETCH_DISPLAY_NAME = 'WebFetch';
export const READ_MANY_FILES_DISPLAY_NAME = 'ReadManyFiles';
export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task';
export const TRACKER_UPDATE_TASK_TOOL_NAME = 'tracker_update_task';
export const TRACKER_GET_TASK_TOOL_NAME = 'tracker_get_task';

View File

@@ -27,7 +27,7 @@ import {
WebFetchFallbackAttemptEvent,
} from '../telemetry/index.js';
import { LlmRole } from '../telemetry/llmRole.js';
import { WEB_FETCH_TOOL_NAME } from './tool-names.js';
import { WEB_FETCH_TOOL_NAME, WEB_FETCH_DISPLAY_NAME } from './tool-names.js';
import { debugLogger } from '../utils/debugLogger.js';
import { retryWithBackoff } from '../utils/retry.js';
import { WEB_FETCH_DEFINITION } from './definitions/coreTools.js';
@@ -684,7 +684,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
) {
super(
WebFetchTool.Name,
'WebFetch',
WEB_FETCH_DISPLAY_NAME,
WEB_FETCH_DEFINITION.base.description!,
Kind.Fetch,
WEB_FETCH_DEFINITION.base.parametersJsonSchema,

View File

@@ -5,7 +5,7 @@
*/
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { WEB_SEARCH_TOOL_NAME } from './tool-names.js';
import { WEB_SEARCH_TOOL_NAME, WEB_SEARCH_DISPLAY_NAME } from './tool-names.js';
import type { GroundingMetadata } from '@google/genai';
import {
BaseDeclarativeTool,
@@ -206,7 +206,7 @@ export class WebSearchTool extends BaseDeclarativeTool<
) {
super(
WebSearchTool.Name,
'GoogleSearch',
WEB_SEARCH_DISPLAY_NAME,
WEB_SEARCH_DEFINITION.base.description!,
Kind.Search,
WEB_SEARCH_DEFINITION.base.parametersJsonSchema,

View File

@@ -300,6 +300,13 @@
"default": true,
"type": "boolean"
},
"compactToolOutput": {
"title": "Compact Tool Output",
"description": "Display tool outputs (like directory listings and file reads) in a compact, structured format.",
"markdownDescription": "Display tool outputs (like directory listings and file reads) in a compact, structured format.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"hideBanner": {
"title": "Hide Banner",
"description": "Hide the application banner",