Compare commits

...

15 Commits

Author SHA1 Message Date
Christian Gunderman f4a707be18 Implement compact UX for topic. 2026-03-26 22:27:03 -07:00
Christian Gunderman c69969a8d3 Fix build. 2026-03-26 21:38:50 -07:00
Christian Gunderman a46ba1496b Merge remote-tracking branch 'origin/feature/dense-output-build-0225' into gundermanc/narration-ux
# Conflicts:
#	packages/core/src/tools/definitions/base-declarations.ts
#	packages/core/src/tools/definitions/dynamic-declaration-helpers.ts
2026-03-26 21:31:30 -07:00
Christian Gunderman fd9eb5c0ef Slim down topic prompt. (#23615) 2026-03-26 21:19:46 -07:00
Jarrod Whelan 6206269149 refactor(cli): address code review feedback
- Abstracted hardcoded padding, margin, and width calculations in `DenseToolMessage` into explicit constants to improve readability.
- Made `terminalWidth` a required property on `DenseToolMessageProps` to ensure consistent layout calculations, simplifying internal ternary checks.
- Updated `DenseToolMessage` tests to provide the now-required `terminalWidth` prop.
2026-03-26 16:12:04 -07:00
Jarrod Whelan 09a6667c35 refactor(cli): address nuanced snapshot rendering scenarios through layout and padding refinements
- Refined height calculation logic in ToolGroupMessage to ensure consistent spacing between compact and standard tools.
- Adjusted padding and margins in StickyHeader, ToolConfirmationQueue, ShellToolMessage, and ToolMessage for visual alignment.
- Updated TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT to account for internal layout changes.
- Improved ToolResultDisplay height handling in alternate buffer mode.
- Updated test snapshots to reflect layout and spacing corrections.

refactor(cli): cleanup and simplify UI components

- Reduced UI refresh delay in AppContainer.tsx for a more responsive user experience.
- Reorder imports and hook definitions within AppContainer.tsx to reduce diff 'noise'.

refactor(cli): enhance compact output robustness and visual regression testing

Addressing automated review feedback to improve code maintainability and layout stability.

1. Robust File Extension Parsing:
- Introduced getFileExtension utility in packages/cli/src/ui/utils/fileUtils.ts using node:path for reliable extension extraction.
- Updated DenseToolMessage and DiffRenderer to use the new utility, replacing fragile string splitting.

2. Visual Regression Coverage:
- Added SVG snapshot tests to DenseToolMessage.test.tsx to verify semantic color rendering and layout integrity in compact mode.

fix(cli): resolve dense tool output code quality issues

- Replaced manual string truncation with Ink's `wrap="truncate-end"` to adhere to UI guidelines.
- Added `isReadManyFilesResult` type guard to `packages/core/src/tools/tools.ts` to improve typing for structured tool results.
- Fixed an incomplete test case in `DenseToolMessage.test.tsx` to properly simulate expansion via context instead of missing mouse events.
2026-03-26 10:37:26 -07:00
Jarrod Whelan 7d786f5f58 test(cli): refine settings and tool action mocks to resolve test rig regressions
This update addresses incomplete settings store contracts and default state mismatches that caused snapshot runtime errors and verification failures in compact tool output tests.

1. Settings Mock Completeness:
- Updated `createMockSettings` in `mockConfig.ts` to include required functional methods (`subscribe`, `getSnapshot`, `setValue`) to satisfy React's `useSyncExternalStore` contract. This resolves "store.getSnapshot is not a function" errors in snapshot tests.

2. Tool Actions Provider Configuration:
- Modified `renderWithProviders` to accept a `toolActions` options object, allowing individual tests to inject specific tool expansion states.
- Changed the default mock for `isExpanded` from `true` to `false` to align with the application's default behavior (collapsed outputs) in alternate buffer mode.

3. Test Refactoring:
- Refactored `ToolGroupMessage.compact.test.tsx` to use the standardized `createMockSettings` utility rather than a manual generic object, ensuring proper context evaluation during rendering.
2026-03-26 10:37:26 -07:00
Jarrod Whelan 7cd7d67e7d feat(ui): implement compact tool output and structured tool results
This commit introduces the compactToolOutput feature to optimize terminal vertical space usage for common, high-volume tools.

1. Compact Tool Output UI:
- Added DenseToolMessage component for rendering compact, single-line tool summaries.
- Updated ToolGroupMessage to detect contiguous compact tools and render them with stitched borders for a streamlined look.
- Added Ctrl+O shortcut in AppContainer to toggle full expansion of tool results from the last turn.
- Implemented useGeminiStream border logic to correctly render borders around dense sections.

2. Core Tool Structured Results:
- grep: Updated to return GrepResult with matches, file paths, and line numbers instead of a raw formatted string.
- ls (list_directory): Updated to return ListDirectoryResult with an array of files.
- read_many_files: Updated to return ReadManyFilesResult with read files, skipped files, and reasons.
- Modified ToolResultDisplay and isStructuredToolResult type guards to support these new structured interfaces.

3. User Settings & Documentation:
- Added compactToolOutput to the UI settings schema (default: true).
- Updated configuration docs and schema to reflect the new setting.
- Adjusted compact subview line limits (COMPACT_TOOL_SUBVIEW_MAX_LINES).
2026-03-26 10:37:21 -07:00
Jarrod Whelan a9e2f44207 refactor(cli,core): foundational layout, identity management, and type safety
This commit establishes the structural foundation and required infrastructure to support the upcoming compact tool output changes. It includes identity management improvements, layout fixes, and type-safety enhancements that stand independently.

1. Identity & History Management:
- useHistoryManager: Ensure strictly increasing IDs for history items, even with identical timestamps.
- acpClient: Introduced callIdCounter to prevent callId collisions during rapid execution.
- MainContent: Implemented negative ID logic for pending items to ensure stable React keys and prevent collisions.
- historyUtils: New file containing extracted history logic (isToolExecuting, getAllToolCalls) hoisted from AppContainer.

2. UI Infrastructure & Layout:
- test-utils/render: Modernized renderWithProviders and removed legacy props.
- AskUserDialog: Fixed layout, scroll visibility, and alignment issues.
- toolLayoutUtils: Increased TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT for better spacing.
- ShellToolMessage/ToolGroupMessage: Updated line-count expectations and snapshots to align with layout changes.

3. IDE & Diffing Improvements:
- ToolActionsContext: Refactored IdeClient initialization to fix a race condition and potential memory leak.
- edit/diffOptions: Improved accuracy of diff stat derivation, ensuring "full content" stats are available for the model.
- coreToolScheduler: Automatically derive diff stats from patches if missing.
- state-manager: Ensure diffStat preservation for rejected tool calls.

4. Type Safety & Constants:
- types/tools: Added foundational types like DiffStat, FileDiff, and StructuredToolResult.
- Type Guards: Added guards for isFileDiff, isTodoList, isAnsiOutput, and hasSummary.
- CodeColorizer: Added function overloads to gracefully handle null language detection.
- tool-names: Introduced DISPLAY_NAME constants for consistent tool labeling.

This commit passes all workspace tests and builds successfully. Feature-specific logic for compact output is excluded.
2026-03-26 10:17:43 -07:00
Abhijit Balaji 7bf08065f7 feat(core): implement Narrative Progress Protocol via update_topic tool
- Rename `create_new_topic` to `update_topic` to support continuous narrative heartbeats.
- Introduce `strategic_intent` parameter to capture tactical reasoning within tool-use turns.
- Consolidate `previous_summary` and `current_summary` into a single `summary` parameter to simplify model narrative bridging.
- Implement Smart Narrative Routing in `UpdateTopicTool` to dynamically toggle between full chapter headers and tactical intent-only heartbeats based on topic transitions.
- Update `TopicState` to track both active topic and active intent session-wide.
- Overhaul system prompt snippets to mandate the Heartbeat protocol, enforcing strictly granular, single-idea chapters and forbidding clubbed phases.
- Gate all logic and tool accessibility behind the `topicUpdateNarration` experimental flag.
- Update security policies (read-only and plan) to whitelist the heartbeat tool.
- Refactor scheduler logic to prioritize `update_topic` as the first execution in any batch.
- Synchronize all core and CLI configuration tests with the new tool schema and naming.
2026-03-23 12:12:49 -07:00
Abhijit Balaji 72e61d1c6f feat(core): enforce granular, single-focus Topic Model with detailed summaries
- Mandate granular, single-focus topics (e.g., 'Researching', 'Implementing [Idea]') to prevent broad, combined topics.
- Introduce required 'current_topic_summary' and 'previous_topic_summary' (on transition) parameters to the create_new_topic tool.
- Require 5-10 sentence detailed summaries for both parameters to improve context injection and strategic alignment.
- Mandate creating a new topic if work deviates from the current topic's stated goal.
- Relax silent-mode restrictions to allow Strategic Narration at the start of chapters for better transparency.
- Update tool declarations, system prompts, and unit tests to reflect these changes.
2026-03-20 16:24:24 -07:00
Abhijit Balaji b692310478 test(cli): update config tests for default topic tool exclusion 2026-03-20 16:24:24 -07:00
Abhijit Balaji 61a46fb933 refactor(core): transition topic management from singleton to session-scoped TopicState
- Move topic tracking from global TopicManager to a session-scoped TopicState within Config.
- Implement sanitization to strip newlines and carriage returns from topic titles.
- Reject empty or whitespace-only topic titles for improved robustness.
- Update CreateNewTopicTool and PromptProvider to use the session-scoped state.
- Disable topicUpdateNarration by default in .gemini/settings.json.
- Enhance test coverage for state independence and sanitization.
2026-03-20 16:24:23 -07:00
Abhijit Balaji 7bc55f4db4 feat(core): always allow topic tool and refine reasoning mandate
- Added explicit policy rules to always allow 'create_new_topic' in all modes.
- Updated topic tool output to use 'Current topic' phrasing for clarity.
- Replaced the 'Thinking Protocol' with a mandate for internal reasoning to prevent literal thought block output in the UI.
- Added and updated unit tests to verify policy and tool behavior.
2026-03-20 16:24:23 -07:00
Abhijit Balaji 731433499b feat(core): implement tool-based topic grouping (Chapters)
Organize agent work into logical phases (Chapters) using a dedicated tool
instead of prompt-only narration. This improves UI organization and
provides the model with explicit state tracking.

Key changes:
- Introduced 'create_new_topic' tool for semantic phase transitions.
- Added TopicManager singleton to maintain session chapter state.
- Gated the feature behind 'experimental.topicUpdateNarration' flag.
- Limited tool registration to Gemini-3 model set.
- Modified Scheduler to prioritize topic markers at start of batches.
- Injected active topic context into system prompt footers.
- Replaced SI narration in snippets with tool-based mandates.
2026-03-20 16:24:23 -07:00
70 changed files with 3575 additions and 456 deletions
+1
View File
@@ -59,6 +59,7 @@ they appear in the UI.
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `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` |
+5
View File
@@ -257,6 +257,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`
+27 -18
View File
@@ -15,6 +15,7 @@ import {
EDIT_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
type ExtensionLoader,
debugLogger,
ApprovalMode,
@@ -1117,16 +1118,18 @@ describe('mergeExcludeTools', () => {
]);
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
settings,
'test-session',
argv,
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
new Set([
'tool1',
'tool2',
'tool3',
'tool4',
'tool5',
UPDATE_TOPIC_TOOL_NAME,
]),
);
expect(config.getExcludeTools()).toHaveLength(5);
expect(config.getExcludeTools()).toHaveLength(6);
});
it('should handle overlapping excludeTools between settings and extensions', async () => {
@@ -1148,9 +1151,9 @@ describe('mergeExcludeTools', () => {
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3']),
new Set(['tool1', 'tool2', 'tool3', UPDATE_TOPIC_TOOL_NAME]),
);
expect(config.getExcludeTools()).toHaveLength(3);
expect(config.getExcludeTools()).toHaveLength(4);
});
it('should handle overlapping excludeTools between extensions', async () => {
@@ -1181,9 +1184,9 @@ describe('mergeExcludeTools', () => {
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3', 'tool4']),
new Set(['tool1', 'tool2', 'tool3', 'tool4', UPDATE_TOPIC_TOOL_NAME]),
);
expect(config.getExcludeTools()).toHaveLength(4);
expect(config.getExcludeTools()).toHaveLength(5);
});
it('should return an empty array when no excludeTools are specified and it is interactive', async () => {
@@ -1192,7 +1195,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(new Set([]));
expect(config.getExcludeTools()).toEqual(new Set([UPDATE_TOPIC_TOOL_NAME]));
});
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
@@ -1201,7 +1204,9 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(new Set([ASK_USER_TOOL_NAME]));
expect(config.getExcludeTools()).toEqual(
new Set([ASK_USER_TOOL_NAME, UPDATE_TOPIC_TOOL_NAME]),
);
});
it('should handle settings with excludeTools but no extensions', async () => {
@@ -1212,8 +1217,10 @@ describe('mergeExcludeTools', () => {
});
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2']));
expect(config.getExcludeTools()).toHaveLength(2);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', UPDATE_TOPIC_TOOL_NAME]),
);
expect(config.getExcludeTools()).toHaveLength(3);
});
it('should handle extensions with excludeTools but no settings', async () => {
@@ -1232,8 +1239,10 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2']));
expect(config.getExcludeTools()).toHaveLength(2);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', UPDATE_TOPIC_TOOL_NAME]),
);
expect(config.getExcludeTools()).toHaveLength(3);
});
it('should not modify the original settings object', async () => {
+10
View File
@@ -561,6 +561,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',
+11
View File
@@ -193,6 +193,17 @@ export function createMockSettings(
user: { settings: {} },
workspace: { settings: {} },
errors: [],
subscribe: vi.fn().mockReturnValue(() => {}),
getSnapshot: vi.fn().mockReturnValue({
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
isTrusted: true,
errors: [],
merged,
}),
setValue: vi.fn(),
...overrides,
merged,
} as unknown as LoadedSettings;
+16
View File
@@ -612,6 +612,7 @@ export const renderWithProviders = async (
mouseEventsEnabled = false,
config,
uiActions,
toolActions,
persistentState,
appState = mockAppState,
}: {
@@ -622,6 +623,11 @@ export const renderWithProviders = async (
mouseEventsEnabled?: boolean;
config?: Config;
uiActions?: Partial<UIActions>;
toolActions?: Partial<{
isExpanded: (callId: string) => boolean;
toggleExpansion: (callId: string) => void;
toggleAllExpansion: (callIds: string[]) => void;
}>;
persistentState?: {
get?: typeof persistentStateMock.get;
set?: typeof persistentStateMock.set;
@@ -709,6 +715,16 @@ export const renderWithProviders = async (
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<AskUserActionsProvider
request={null}
+71 -12
View File
@@ -168,6 +168,7 @@ import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
import {
getLastTurnToolCallIds,
isToolExecuting,
isToolAwaitingConfirmation,
getAllToolCalls,
@@ -238,6 +239,39 @@ export const AppContainer = (props: AppContainerProps) => {
const [adminSettingsChanged, setAdminSettingsChanged] = 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 toggleAllExpansion = useCallback((callIds: string[]) => {
setExpandedTools((prev) => {
const next = new Set(prev);
const anyCollapsed = callIds.some((id) => !next.has(id));
if (anyCollapsed) {
callIds.forEach((id) => next.add(id));
} else {
callIds.forEach((id) => next.delete(id));
}
return next;
});
}, []);
const isExpanded = useCallback(
(callId: string) => expandedTools.has(callId),
[expandedTools],
);
const [shellModeActive, setShellModeActive] = useState(false);
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
@@ -1137,11 +1171,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
@@ -1727,13 +1756,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
const toggleLastTurnTools = () => {
// If the user manually collapses/expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
const targetToolCallIds = getLastTurnToolCallIds(
historyManager.history,
pendingHistoryItems,
);
if (targetToolCallIds.length > 0) {
toggleAllExpansion(targetToolCallIds);
}
};
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
enteringConstrainHeightMode = true;
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
toggleLastTurnTools();
}
if (!isAlternateBuffer) {
refreshStatic();
@@ -1781,11 +1823,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
!enteringConstrainHeightMode
) {
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
if (!isAlternateBuffer) {
toggleLastTurnTools();
// Force layout refresh after a short delay to allow the terminal layout to settle.
// Minimize "blank screen" issue after any async subview updates are complete.
setTimeout(() => {
refreshStatic();
}
}, 250);
return true;
} else if (
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
@@ -1890,6 +1935,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
triggerExpandHint,
keyMatchers,
isHelpDismissKey,
historyManager.history,
pendingHistoryItems,
toggleAllExpansion,
],
);
@@ -2033,6 +2081,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
@@ -2637,7 +2690,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
startupWarnings: props.startupWarnings || [],
}}
>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<App key={`app-${forceRerenderKey}`} />
</ShellFocusContext.Provider>
@@ -67,7 +67,6 @@ export const StickyHeader: React.FC<StickyHeaderProps> = ({
borderLeft={true}
borderRight={true}
paddingX={1}
paddingBottom={1}
paddingTop={isFirst ? 0 : 1}
>
{children}
@@ -66,9 +66,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
// ToolConfirmationMessage needs to know the height available for its OWN content.
// We subtract the lines used by the Queue wrapper:
// - 2 lines for the rounded border
// - 2 lines for the rounded border (top/bottom)
// - 2 lines for the Header (text + margin)
// - 2 lines for Tool Identity (text + margin)
// - 2 lines for Tool Identity (text + margin) if shown
const availableContentHeight = constrainHeight
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
: undefined;
@@ -83,10 +83,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
>
<Box flexDirection="column" width={mainAreaWidth - 4}>
{/* Header */}
<Box
marginBottom={hideToolIdentity ? 0 : 1}
justifyContent="space-between"
>
<Box marginBottom={1} justifyContent="space-between">
<Text color={borderColor} bold>
{getConfirmationHeader(tool.confirmationDetails)}
</Text>
@@ -98,7 +95,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
</Box>
{!hideToolIdentity && (
<Box>
<Box marginBottom={1}>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
@@ -43,10 +43,12 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ o tool3 Description for tool 3 │
│ │
@@ -93,6 +95,7 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
@@ -6,12 +6,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 15
│ Line 16 █ │
│ Line 17 █ │
│ Line 18 █ │
@@ -27,12 +26,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 15
│ Line 16 █ │
│ Line 17 █ │
│ Line 18 █ │
@@ -47,8 +45,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ ... first 10 lines hidden (Ctrl+O to show) ... │
│ Line 11 │
│ ... first 11 lines hidden (Ctrl+O to show) ... │
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -0,0 +1,577 @@
/**
* @license
* Copyright 2026 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,
type DiffStat,
type FileDiff,
type GrepResult,
type ListDirectoryResult,
type ReadManyFilesResult,
makeFakeConfig,
} from '@google/gemini-cli-core';
import type {
SerializableConfirmationDetails,
ToolResultDisplay,
} from '../../types.js';
import { createMockSettings } from '../../../test-utils/settings.js';
describe('DenseToolMessage', () => {
const defaultProps = {
callId: 'call-1',
name: 'test-tool',
description: 'Test description',
status: CoreToolCallStatus.Success,
resultDisplay: 'Success result' as ToolResultDisplay,
confirmationDetails: undefined,
terminalWidth: 80,
};
it('renders correctly for a successful string result', async () => {
const { lastFrame, waitUntilReady } = await 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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={longResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('…');
expect(lastFrame()).toMatchSnapshot();
});
it('flattens newlines in string results', async () => {
const multilineResult = 'Line 1\nLine 2';
const { lastFrame, waitUntilReady } = await 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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
);
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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.AwaitingApproval}
resultDisplay={undefined}
confirmationDetails={
confirmationDetails as SerializableConfirmationDetails
}
/>,
{},
);
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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.Cancelled}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
);
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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.Cancelled}
resultDisplay={undefined}
confirmationDetails={
confirmationDetails as unknown as SerializableConfirmationDetails
}
/>,
{},
);
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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="WriteFile"
status={CoreToolCallStatus.Success}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
);
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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="WriteFile"
status={CoreToolCallStatus.Cancelled}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
);
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',
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 } = await 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 → Failed (+1, -1)');
expect(output).toMatchSnapshot();
});
it('renders correctly for grep results', async () => {
const grepResult: GrepResult = {
summary: 'Found 2 matches',
matches: [
{
filePath: 'file1.ts',
absolutePath: '/file1.ts',
lineNumber: 10,
line: 'match 1',
},
{
filePath: 'file2.ts',
absolutePath: '/file2.ts',
lineNumber: 20,
line: 'match 2',
},
],
};
const { lastFrame, waitUntilReady } = await 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 } = await 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 } = await 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 } = await 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 } = await renderWithProviders(
<DenseToolMessage {...defaultProps} resultDisplay={genericResult} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Returned (possible empty result)');
expect(output).toMatchSnapshot();
});
it('renders correctly for error status with string message', async () => {
const { lastFrame, waitUntilReady } = await 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 } = await 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 } = await 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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Accepted');
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 } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Accepted');
expect(output).toContain('new line');
expect(output).toMatchSnapshot();
});
it('shows diff content when expanded via ToolActionsContext', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
toolActions: {
isExpanded: () => true,
},
},
);
await waitUntilReady();
// Verify it shows the diff when expanded
expect(lastFrame()).toContain('new line');
});
});
describe('Visual Regression', () => {
it('matches SVG snapshot for an Accepted file edit with diff stats', async () => {
const diffResult: FileDiff = {
fileName: 'test.ts',
filePath: '/mock/test.ts',
fileDiff: '--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
originalContent: 'old',
newContent: 'new',
diffStat: {
model_added_lines: 1,
model_removed_lines: 1,
model_added_chars: 3,
model_removed_chars: 3,
user_added_lines: 0,
user_removed_lines: 0,
user_added_chars: 0,
user_removed_chars: 0,
},
};
const renderResult = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="edit"
description="Editing test.ts"
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
});
it('matches SVG snapshot for a Rejected tool call', async () => {
const renderResult = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="read_file"
description="Reading important.txt"
resultDisplay="Rejected by user"
status={CoreToolCallStatus.Cancelled}
/>,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
});
});
});
@@ -0,0 +1,624 @@
/**
* @license
* Copyright 2026 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 {
CoreToolCallStatus,
type FileDiff,
type ListDirectoryResult,
type ReadManyFilesResult,
isFileDiff,
hasSummary,
isGrepResult,
isListResult,
isReadManyFilesResult,
UPDATE_TOPIC_DISPLAY_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_STRATEGIC_INTENT,
} from '@google/gemini-cli-core';
import { type IndividualToolCallDisplay, isTodoList } 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';
import { getFileExtension } from '../../utils/fileUtils.js';
const PAYLOAD_MARGIN_LEFT = 6;
const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
const PAYLOAD_SCROLL_GUTTER = 4;
const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
interface DenseToolMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
availableTerminalHeight?: number;
}
interface ViewParts {
// brief description of action
description?: React.ReactNode;
// result summary or status text
summary?: React.ReactNode;
// detailed output, e.g. diff or command output
payload?: React.ReactNode;
}
interface PayloadResult {
summary: string;
payload: string;
}
const hasPayload = (res: unknown): res is PayloadResult => {
if (!hasSummary(res)) return false;
if (!('payload' in res)) return false;
const value = (res as { payload?: unknown }).payload;
return typeof value === 'string';
};
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>
);
};
function getFileOpData(
diff: FileDiff,
status: CoreToolCallStatus,
resultDisplay: unknown,
terminalWidth: number,
availableTerminalHeight?: number,
isClickable?: boolean,
): 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 === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Executing ||
status === CoreToolCallStatus.AwaitingApproval;
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 resultSummary = '';
let resultColor = theme.text.secondary;
if (status === CoreToolCallStatus.AwaitingApproval) {
resultSummary = 'Confirming';
} else if (
status === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Executing
) {
resultSummary = 'Accepted';
resultColor = theme.text.accent;
} else if (status === CoreToolCallStatus.Cancelled) {
resultSummary = 'Rejected';
resultColor = theme.status.error;
} else if (status === CoreToolCallStatus.Error) {
resultSummary =
typeof resultDisplay === 'string' ? resultDisplay : 'Failed';
resultColor = theme.status.error;
}
const summary = (
<Box flexDirection="row">
{resultSummary && (
<Text color={resultColor} wrap="truncate-end">
{' '}
<Text underline={isClickable}>
{resultSummary.replace(/\n/g, ' ')}
</Text>
</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 - PAYLOAD_MARGIN_LEFT}
availableTerminalHeight={availableTerminalHeight}
disableColor={status === CoreToolCallStatus.Cancelled}
/>
);
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 description = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
const summary = <Text color={theme.text.accent}> {result.summary}</Text>;
// For directory listings, we want NO payload in dense mode
return { description, summary, payload: undefined };
}
function getListResultData(
result: ListDirectoryResult | ReadManyFilesResult,
originalDescription?: string,
): ViewParts {
if (isReadManyFilesResult(result)) {
return getReadManyFilesData(result);
}
return getListDirectoryData(result, 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="truncate-end">
{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">
Returned (possible empty result)
</Text>
);
}
return { description, summary, payload };
}
function getUpdateTopicData(args?: Record<string, unknown>): ViewParts {
const rawTitle = args?.[TOPIC_PARAM_TITLE];
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
const rawIntent = args?.[TOPIC_PARAM_STRATEGIC_INTENT];
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
const description = (
<Text color={theme.text.primary} bold wrap="truncate-end">
{title || 'Topic'}
</Text>
);
const summary = intent ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{' '}
-- {intent}
</Text>
) : undefined;
return { description, summary };
}
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
const {
callId,
name,
args,
status,
resultDisplay,
confirmationDetails,
outputFile,
terminalWidth,
availableTerminalHeight,
description: originalDescription,
} = props;
const settings = useSettings();
const isAlternateBuffer = useAlternateBuffer();
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
// ... (rest of component)
// 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);
// 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,
});
// State-to-View Coordination
const viewParts = useMemo((): ViewParts => {
if (name === UPDATE_TOPIC_DISPLAY_NAME || name === UPDATE_TOPIC_TOOL_NAME) {
return getUpdateTopicData(args);
}
if (diff) {
return getFileOpData(
diff,
status,
resultDisplay,
terminalWidth,
availableTerminalHeight,
isAlternateBuffer,
);
}
if (isListResult(resultDisplay)) {
return getListResultData(resultDisplay, originalDescription);
}
if (isGrepResult(resultDisplay)) {
return getGenericSuccessData(resultDisplay, originalDescription);
}
if (status === CoreToolCallStatus.Success && resultDisplay) {
return getGenericSuccessData(resultDisplay, originalDescription);
}
if (status === CoreToolCallStatus.Error) {
const text =
typeof resultDisplay === 'string'
? resultDisplay.replace(/\n/g, ' ')
: 'Failed';
const errorSummary = (
<Text color={theme.status.error} wrap="truncate-end">
{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,
status,
resultDisplay,
terminalWidth,
availableTerminalHeight,
originalDescription,
isAlternateBuffer,
name,
args,
]);
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 = getFileExtension(diff.fileName);
return colorizeCode({
code: addedContent,
language: fileExtension,
maxWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
settings,
disableColor: status === CoreToolCallStatus.Cancelled,
returnLines: true,
});
} else {
return renderDiffLines({
parsedLines,
filename: diff.fileName,
terminalWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
disableColor: status === CoreToolCallStatus.Cancelled,
});
}
}, [diff, isExpanded, isAlternateBuffer, terminalWidth, settings, status]);
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>
);
return (
<Box flexDirection="column">
<Box marginLeft={2} flexDirection="row">
{name === UPDATE_TOPIC_DISPLAY_NAME ||
name === UPDATE_TOPIC_TOOL_NAME ? (
<>
<Box flexShrink={1} flexGrow={0}>
{description}
</Box>
{summary && (
<Box marginLeft={1} flexGrow={0} flexShrink={1}>
{summary}
</Box>
)}
</>
) : (
<>
<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
key="tool-summary"
ref={isAlternateBuffer && diff ? toggleRef : undefined}
marginLeft={1}
flexGrow={0}
flexShrink={1}
>
{summary}
</Box>
)}
</>
)}
</Box>
{showPayload && isAlternateBuffer && diffLines.length > 0 && (
<Box
marginLeft={PAYLOAD_MARGIN_LEFT}
marginTop={1}
marginBottom={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={Math.min(
PAYLOAD_MAX_WIDTH,
terminalWidth - PAYLOAD_MARGIN_LEFT,
)}
>
<ScrollableList
data={diffLines}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
hasFocus={isFocused}
width={Math.min(
PAYLOAD_MAX_WIDTH,
terminalWidth -
PAYLOAD_MARGIN_LEFT -
PAYLOAD_BORDER_CHROME_WIDTH -
PAYLOAD_SCROLL_GUTTER,
)}
/>
</Box>
)}
{showPayload && (!isAlternateBuffer || !diff) && viewParts.payload && (
<Box marginLeft={PAYLOAD_MARGIN_LEFT} marginTop={1} marginBottom={1}>
{viewParts.payload}
</Box>
)}
{showPayload && outputFile && (
<Box marginLeft={PAYLOAD_MARGIN_LEFT} marginTop={1} marginBottom={1}>
<Text color={theme.text.secondary}>
(Output saved to: {outputFile})
</Text>
</Box>
)}
</Box>
);
};
@@ -55,6 +55,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});
@@ -89,6 +90,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});
@@ -119,6 +121,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});
@@ -13,15 +13,16 @@ import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme as semanticTheme } from '../../semantic-colors.js';
import type { Theme } from '../../themes/theme.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { getFileExtension } from '../../utils/fileUtils.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 +89,7 @@ interface DiffRendererProps {
availableTerminalHeight?: number;
terminalWidth: number;
theme?: Theme;
disableColor?: boolean;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
@@ -99,6 +101,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
availableTerminalHeight,
terminalWidth,
theme,
disableColor = false,
}) => {
const settings = useSettings();
@@ -111,17 +114,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,14 +144,14 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
);
}
if (isNewFile) {
if (isNewFileResult) {
// Extract only the added lines' content
const addedContent = parsedLines
.filter((line) => line.type === 'add')
.map((line) => line.content)
.join('\n');
// Attempt to infer language from filename, default to plain text if no filename
const fileExtension = filename?.split('.').pop() || null;
const fileExtension = getFileExtension(filename);
const language = fileExtension
? getLanguageFromExtension(fileExtension)
: null;
@@ -169,39 +162,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 +241,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(
@@ -232,7 +260,7 @@ const renderDiffContent = (
);
const gutterWidth = Math.max(1, maxLineNumber.toString().length);
const fileExtension = filename?.split('.').pop() || null;
const fileExtension = getFileExtension(filename);
const language = fileExtension
? getLanguageFromExtension(fileExtension)
: null;
@@ -252,10 +280,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 +345,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 +374,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 +401,7 @@ const renderDiffContent = (
[],
);
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{content}
</MaxSizedBox>
);
return content;
};
const getLanguageFromExtension = (extension: string): string | null => {
@@ -19,8 +19,12 @@ import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import {
SHELL_CONTENT_OVERHEAD,
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
} from '../../utils/toolLayoutUtils.js';
describe('<ShellToolMessage />', () => {
const baseProps: ShellToolMessageProps = {
@@ -35,6 +39,7 @@ describe('<ShellToolMessage />', () => {
isFirst: true,
borderColor: 'green',
borderDimColor: false,
isExpandable: false,
config: {
getEnableInteractiveShell: () => true,
} as unknown as Config,
@@ -52,6 +57,11 @@ describe('<ShellToolMessage />', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('interactive shell focus', () => {
@@ -59,14 +69,14 @@ describe('<ShellToolMessage />', () => {
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
])('clicks inside the shell area sets focus for %s', async (_, name) => {
const { lastFrame, simulateClick, unmount } = await renderWithProviders(
<ShellToolMessage {...baseProps} name={name} />,
{ uiActions, mouseEventsEnabled: true },
);
const { lastFrame, simulateClick, unmount, waitUntilReady } =
await renderWithProviders(
<ShellToolMessage {...baseProps} name={name} />,
{ uiActions, mouseEventsEnabled: true },
);
await waitFor(() => {
expect(lastFrame()).toContain('A shell command');
});
await waitUntilReady();
expect(lastFrame()).toContain('A shell command');
await simulateClick(2, 2);
@@ -75,6 +85,7 @@ describe('<ShellToolMessage />', () => {
});
unmount();
});
it('resets focus when shell finishes', async () => {
let updateStatus: (s: CoreToolCallStatus) => void = () => {};
@@ -86,19 +97,21 @@ describe('<ShellToolMessage />', () => {
return <ShellToolMessage {...baseProps} status={status} ptyId={1} />;
};
const { lastFrame, unmount } = await renderWithProviders(<Wrapper />, {
uiActions,
uiState: {
streamingState: StreamingState.Idle,
embeddedShellFocused: true,
activePtyId: 1,
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<Wrapper />,
{
uiActions,
uiState: {
streamingState: StreamingState.Idle,
embeddedShellFocused: true,
activePtyId: 1,
},
},
});
);
// Verify it is initially focused
await waitFor(() => {
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
});
await waitUntilReady();
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
// Now update status to Success
await act(async () => {
@@ -184,29 +197,33 @@ describe('<ShellToolMessage />', () => {
[
'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
10,
7,
10 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 7 (Header height is 3, but calculation uses reserved=3)
false,
true,
false,
],
[
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
100,
ACTIVE_SHELL_MAX_LINES - 4,
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
false,
true,
false,
],
[
'uses full availableTerminalHeight when focused in alternate buffer mode',
100,
97,
100 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 97
true,
false,
false,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES - 4,
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
false,
true,
false,
],
])(
@@ -217,29 +234,34 @@ describe('<ShellToolMessage />', () => {
expectedMaxLines,
focused,
constrainHeight,
isExpandable,
) => {
const { lastFrame, unmount } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={availableTerminalHeight}
ptyId={1}
status={CoreToolCallStatus.Executing}
/>,
{
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({
ui: { useAlternateBuffer: true },
}),
uiState: {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
constrainHeight,
const { lastFrame, waitUntilReady, unmount } =
await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={availableTerminalHeight}
ptyId={1}
status={CoreToolCallStatus.Executing}
isExpandable={isExpandable}
/>,
{
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({
ui: { useAlternateBuffer: true },
}),
uiState: {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
constrainHeight,
},
},
},
);
);
await waitUntilReady();
const frame = lastFrame();
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
@@ -249,7 +271,7 @@ describe('<ShellToolMessage />', () => {
);
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
@@ -264,16 +286,15 @@ describe('<ShellToolMessage />', () => {
},
);
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
await waitUntilReady();
const frame = lastFrame();
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
unmount();
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
@@ -292,17 +313,16 @@ describe('<ShellToolMessage />', () => {
},
);
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines because constrainHeight is false and isExpandable is true
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
await waitUntilReady();
const frame = lastFrame();
// Should show all 100 lines because constrainHeight is false and isExpandable is true
expect(frame.match(/Line \d+/g)?.length).toBe(100);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
@@ -321,11 +341,12 @@ describe('<ShellToolMessage />', () => {
},
);
await waitFor(() => {
const frame = lastFrame();
// Should still be constrained to 11 (15 - 4) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(11);
});
await waitUntilReady();
const frame = lastFrame();
// Should still be constrained to 11 (15 - 4) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -190,6 +190,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderLeft={true}
borderRight={true}
paddingX={1}
paddingTop={1}
flexDirection="column"
>
<ToolResultDisplay
@@ -34,6 +34,9 @@ describe('ToolConfirmationMessage', () => {
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const mockConfig = {
@@ -458,7 +461,11 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -485,7 +492,11 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -512,6 +523,9 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: true,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
@@ -728,6 +742,9 @@ describe('ToolConfirmationMessage', () => {
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/mockConfig.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';
import type { IndividualToolCallDisplay } from '../../types.js';
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 = createMockSettings({
merged: {
ui: {
compactToolOutput: true,
},
},
});
it('renders consecutive compact tools without empty lines between them', async () => {
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt\nfile2.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file3.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('does not add an extra empty line between a compact tool and a standard tool', async () => {
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: 'non-compact-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'some large output',
description: 'Doing something',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('does not add an extra empty line if a compact tool has a dense payload', async () => {
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: READ_FILE_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: {
summary: 'read file',
payload: 'file content',
files: ['file.txt'],
}, // Dense payload
description: 'Reading file',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('does not add an extra empty line between a standard tool and a compact tool', async () => {
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: 'non-compact-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'some large output',
description: 'Doing something',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
});
@@ -22,6 +22,9 @@ import {
EDIT_DISPLAY_NAME,
READ_FILE_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_STRATEGIC_INTENT,
} from '@google/gemini-cli-core';
import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
@@ -414,7 +417,7 @@ describe('<ToolGroupMessage />', () => {
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<Scrollable height={12} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />
</Scrollable>,
{
@@ -464,6 +467,60 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
describe('UpdateTopicTool', () => {
it('renders update_topic with Title -- Description format even when compact mode is disabled', async () => {
const toolCalls = [
createToolCall({
name: UPDATE_TOPIC_DISPLAY_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Research',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Researching Agent Skills',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: createMockSettings({
ui: { compactToolOutput: false },
}),
},
);
const output = lastFrame();
expect(output).toContain('Research');
expect(output).toContain('-- Researching Agent Skills');
expect(output).not.toContain('update_topic');
unmount();
});
it('renders update_topic with Topic -- Description format when title is missing', async () => {
const toolCalls = [
createToolCall({
name: UPDATE_TOPIC_DISPLAY_NAME,
args: {
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Tactical update',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Topic');
expect(output).toContain('-- Tactical update');
unmount();
});
});
it('renders two tool groups where only the last line of the previous group is visible', async () => {
const toolCalls1 = [
createToolCall({
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, Fragment } from 'react';
import { Box, Text } from 'ink';
import type {
HistoryItem,
@@ -16,6 +16,7 @@ import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { DenseToolMessage } from './DenseToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
@@ -23,11 +24,93 @@ import {
shouldHideToolCall,
CoreToolCallStatus,
Kind,
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,
UPDATE_TOPIC_DISPLAY_NAME,
UPDATE_TOPIC_TOOL_NAME,
isFileDiff,
isGrepResult,
isListResult,
} 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,
UPDATE_TOPIC_DISPLAY_NAME,
UPDATE_TOPIC_TOOL_NAME,
]);
// Helper to identify if a tool should use the compact view
export const isCompactTool = (
tool: IndividualToolCallDisplay,
isCompactModeEnabled: boolean,
): boolean => {
const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has(tool.name);
const displayStatus = mapCoreStatusToDisplayStatus(tool.status);
// update_topic always uses the dense/compact representation
if (
tool.name === UPDATE_TOPIC_DISPLAY_NAME ||
tool.name === UPDATE_TOPIC_TOOL_NAME
) {
return displayStatus !== ToolCallStatus.Confirming;
}
return (
isCompactModeEnabled &&
hasCompactOutputSupport &&
displayStatus !== ToolCallStatus.Confirming
);
};
// Helper to identify if a compact tool has a payload (diff, list, etc.)
export 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) return true;
// ReadManyFilesResult check (has 'include' and 'files')
if (isListResult(res) && 'include' in res) {
const includeProp = (res as { include?: unknown }).include;
if (Array.isArray(includeProp) && res.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[];
@@ -53,11 +136,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
}) => {
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(
const visibleToolCalls = useMemo(
() =>
allToolCalls.filter((t) => {
// Hide internal errors unless full verbosity
if (
isLowErrorVerbosity &&
t.status === CoreToolCallStatus.Error &&
@@ -65,19 +150,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
) {
return false;
}
// Standard hiding logic (e.g. Plan Mode internal edits)
if (
shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
parentCallId: t.parentCallId,
})
) {
return false;
}
return !shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
parentCallId: t.parentCallId,
});
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
// Only show tools that are actually running or finished.
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[allToolCalls, isLowErrorVerbosity],
);
const config = useConfig();
const {
activePtyId,
embeddedShellFocused,
@@ -85,6 +185,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
pendingHistoryItems,
} = useUIState();
const config = useConfig();
const { borderColor, borderDimColor } = useMemo(
() =>
getToolGroupBorderAppearance(
@@ -103,41 +205,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
],
);
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
// Only show tools that are actually running or finished.
// We explicitly exclude Pending and Confirming to ensure they only
// appear in the Global Queue until they are approved and start executing.
const visibleToolCalls = useMemo(
() =>
toolCalls.filter((t) => {
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[toolCalls],
);
const staticHeight = /* border */ 2;
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (
tool.kind !== Kind.Agent &&
tool.resultDisplay !== undefined &&
tool.resultDisplay !== ''
) {
countToolCallsWithResults++;
}
}
const countOneLineToolCalls =
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
countToolCallsWithResults;
const groupedTools = useMemo(() => {
const groups: Array<
IndividualToolCallDisplay | IndividualToolCallDisplay[]
@@ -157,10 +224,78 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
return groups;
}, [visibleToolCalls]);
const staticHeight = useMemo(() => {
let height = 0;
for (let i = 0; i < groupedTools.length; i++) {
const group = groupedTools[i];
const isFirst = i === 0;
const prevGroup = i > 0 ? groupedTools[i - 1] : null;
const prevIsCompact =
prevGroup &&
!Array.isArray(prevGroup) &&
isCompactTool(prevGroup, isCompactModeEnabled);
const isAgentGroup = Array.isArray(group);
const isCompact =
!isAgentGroup && isCompactTool(group, isCompactModeEnabled);
if (isFirst) {
height += (borderTopOverride ?? false) ? 1 : 0;
} else if (isCompact && prevIsCompact) {
height += 0;
} else if (isCompact || prevIsCompact) {
height += 1;
} else {
// Gap is provided by StickyHeader's paddingTop=1
height += 0;
}
const isFirstProp = !!(isFirst
? (borderTopOverride ?? true)
: prevIsCompact);
if (isAgentGroup) {
// Agent group
height += 1; // Header
height += group.length; // 1 line per agent
if (isFirstProp) height += 1; // Top border
} else {
if (isCompact) {
height += 1; // Base height for compact tool
} else {
// Static overhead for standard tool header:
// 1 line for header text
// 1 line for dark separator
// 1 line for tool body internal paddingTop=1
// 1 line for top border (if isFirstProp) OR StickyHeader paddingTop=1 (if !isFirstProp)
height += 3;
height += isFirstProp ? 1 : 1; // Either top border or paddingTop
}
}
}
return height;
}, [groupedTools, isCompactModeEnabled, borderTopOverride]);
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (tool.kind !== Kind.Agent) {
if (isCompactTool(tool, isCompactModeEnabled)) {
if (hasDensePayload(tool)) {
countToolCallsWithResults++;
}
} else if (
tool.resultDisplay !== undefined &&
tool.resultDisplay !== ''
) {
countToolCallsWithResults++;
}
}
}
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
(availableTerminalHeight - staticHeight) /
Math.max(1, countToolCallsWithResults),
),
1,
@@ -175,7 +310,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// explicit "closing slice" (tools: []) used to bridge static/pending sections,
// and only if it's actually continuing an open box from above.
const isExplicitClosingSlice = allToolCalls.length === 0;
if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
const shouldShowGroup =
visibleToolCalls.length > 0 ||
(isExplicitClosingSlice && borderBottomOverride === true);
if (!shouldShowGroup) {
return null;
}
@@ -190,98 +329,160 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={(borderBottomOverride ?? true) ? 1 : 0}
>
{visibleToolCalls.length === 0 &&
isExplicitClosingSlice &&
borderBottomOverride === true && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
{groupedTools.map((group, index) => {
const isFirst = index === 0;
const resolvedIsFirst =
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst;
const isLast = index === groupedTools.length - 1;
if (Array.isArray(group)) {
const prevGroup = index > 0 ? groupedTools[index - 1] : null;
const prevIsCompact =
prevGroup &&
!Array.isArray(prevGroup) &&
isCompactTool(prevGroup, isCompactModeEnabled);
const nextGroup = !isLast ? groupedTools[index + 1] : null;
const nextIsCompact =
nextGroup &&
!Array.isArray(nextGroup) &&
isCompactTool(nextGroup, isCompactModeEnabled);
const isAgentGroup = Array.isArray(group);
const isCompact =
!isAgentGroup && isCompactTool(group, isCompactModeEnabled);
let marginTop = 0;
if (isFirst) {
marginTop = (borderTopOverride ?? false) ? 1 : 0;
} else if (isCompact && prevIsCompact) {
marginTop = 0;
} else if (isCompact || prevIsCompact) {
marginTop = 1;
} else {
// Subsequent standard tools: StickyHeader's paddingTop=1 provides the gap.
marginTop = 0;
}
const isFirstProp = !!(isFirst
? (borderTopOverride ?? true)
: prevIsCompact);
const showClosingBorder = !isCompact && (nextIsCompact || isLast);
if (isAgentGroup) {
return (
<SubagentGroupDisplay
<Box
key={group[0].callId}
toolCalls={group}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={contentWidth}
borderColor={borderColor}
borderDimColor={borderDimColor}
isFirst={resolvedIsFirst}
isExpandable={isExpandable}
/>
marginTop={marginTop}
flexDirection="column"
width={contentWidth}
>
<SubagentGroupDisplay
toolCalls={group}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={contentWidth}
borderColor={borderColor}
borderDimColor={borderDimColor}
isFirst={isFirstProp}
isExpandable={isExpandable}
/>
{showClosingBorder && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={isLast ? (borderBottomOverride ?? true) : true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
</Box>
);
}
const tool = group;
const isShellToolCall = isShellTool(tool.name);
const commonProps = {
...tool,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: 'medium' as const,
isFirst: resolvedIsFirst,
isFirst: isCompact ? false : isFirstProp,
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>
{showClosingBorder && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderBottom={isLast ? (borderBottomOverride ?? true) : 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) &&
borderBottomOverride !== false && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)
}
</Box>
);
@@ -119,6 +119,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
borderLeft={true}
borderRight={true}
paddingX={1}
paddingTop={1}
flexDirection="column"
>
{status === CoreToolCallStatus.Executing && progress !== undefined && (
@@ -15,6 +15,7 @@ import {
type AnsiOutput,
type AnsiLine,
isSubagentProgress,
isStructuredToolResult,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
@@ -123,7 +124,28 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
{contentData}
</Text>
);
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
} else if (isStructuredToolResult(contentData)) {
if (renderOutputAsMarkdown) {
content = (
<MarkdownDisplay
text={contentData.summary}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
isPending={false}
/>
);
} else {
content = (
<Text wrap="wrap" color={theme.text.primary}>
{contentData.summary}
</Text>
);
}
} else if (
typeof contentData === 'object' &&
contentData !== null &&
'fileDiff' in contentData
) {
content = (
<DiffRenderer
diffContent={
@@ -157,10 +179,16 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
// Final render based on session mode
if (isAlternateBuffer) {
// If availableTerminalHeight is undefined, we don't have a fixed budget,
// so if maxLines is also undefined, we shouldn't cap the height at all.
const effectiveMaxHeight =
maxLines ??
(availableTerminalHeight !== undefined ? availableHeight : undefined);
return (
<Scrollable
width={childWidth}
maxHeight={maxLines ?? availableHeight}
maxHeight={effectiveMaxHeight}
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
scrollToBottom={true}
reportOverflow={true}
@@ -120,10 +120,10 @@ describe('ToolMessage Sticky Header Regression', () => {
expect(lastFrame()).toContain('tool-1');
});
expect(lastFrame()).toContain('Description for tool-1');
// Content lines 1-4 should be scrolled off
// Content lines 1-5 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)
expect(lastFrame()).not.toContain('c1-05');
// Line 6 and 7 should be visible (terminalHeight=5 means 2 lines of content show below 3-line header)
expect(lastFrame()).toContain('c1-06');
expect(lastFrame()).toContain('c1-07');
expect(lastFrame()).toMatchSnapshot();
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="37" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="2" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs" font-weight="bold">-</text>
<text x="45" y="2" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">read_file </text>
<text x="144" y="2" fill="#afafaf" textLength="189" lengthAdjust="spacingAndGlyphs">Reading important.txt</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 693 B

@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="2" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs" font-weight="bold">edit </text>
<text x="99" y="2" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">test.ts</text>
<text x="171" y="2" fill="#d7afff" textLength="90" lengthAdjust="spacingAndGlyphs">→ Accepted</text>
<text x="270" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">(</text>
<text x="279" y="2" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">+1</text>
<text x="297" y="2" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">, </text>
<text x="315" y="2" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-1</text>
<text x="333" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">)</text>
<rect x="54" y="34" width="9" height="17" fill="#5f0000" />
<text x="54" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<rect x="63" y="34" width="9" height="17" fill="#5f0000" />
<rect x="72" y="34" width="9" height="17" fill="#5f0000" />
<text x="72" y="36" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="81" y="34" width="9" height="17" fill="#5f0000" />
<rect x="90" y="34" width="27" height="17" fill="#5f0000" />
<text x="90" y="36" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">old</text>
<rect x="54" y="51" width="9" height="17" fill="#005f00" />
<text x="54" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<rect x="63" y="51" width="9" height="17" fill="#005f00" />
<rect x="72" y="51" width="9" height="17" fill="#005f00" />
<text x="72" y="53" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="81" y="51" width="9" height="17" fill="#005f00" />
<rect x="90" y="51" width="27" height="17" fill="#005f00" />
<text x="90" y="53" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">new</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,143 @@
// 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
"
`;
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 > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `
" ✓ edit test.ts → Accepted (+1, -1)
1 - old
1 + new
"
`;
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 (+1, -1)
1 - old line
1 + new line
"
`;
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 → Returned (possible empty result)
"
`;
exports[`DenseToolMessage > truncates long string results 1`] = `
" ✓ test-tool Test description
→ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…
"
`;
@@ -4,7 +4,6 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
@@ -129,7 +128,6 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 94 │
│ Line 95 │
│ Line 96 │
│ Line 97 │
@@ -143,7 +141,6 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
@@ -161,7 +158,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
@@ -179,11 +175,10 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7
│ Line 8
│ Line 7
│ Line 8
│ Line 9 █ │
│ Line 10 █ │
│ Line 11 █ │
@@ -0,0 +1,35 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a compact tool and a standard tool 1`] = `
" ✓ ReadFolder Listing files → file1.txt
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool Doing something │
│ │
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a standard tool and a compact tool 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool Doing something │
│ │
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
✓ ReadFolder Listing files → file1.txt
"
`;
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line if a compact tool has a dense payload 1`] = `
" ✓ ReadFolder Listing files → file1.txt
✓ ReadFile Reading file → read file
"
`;
exports[`ToolGroupMessage Compact Rendering > renders consecutive compact tools without empty lines between them 1`] = `
" ✓ ReadFolder Listing files → file1.txt file2.txt
✓ ReadFolder Listing files → file3.txt
"
`;
@@ -63,7 +63,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls arra
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
│──────────────────────────────────────────────────────────────────────────│
│ line4 │ █
│ line5 │ █
│ │ █
│ ✓ tool-2 Description 2 │ █
@@ -71,6 +72,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
│ line1 │ █
│ line2 │ █
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -128,12 +130,12 @@ 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 │
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -37,8 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... 249 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"... 250 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -2,7 +2,7 @@
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 │
@@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│────────────────────────────────────────────────────────────────────────│ █
│ shell-06 │ ▀
│ shell-07 │
+3
View File
@@ -62,3 +62,6 @@ export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
/** Documentation URL for skills setup and configuration */
export const SKILLS_DOCS_URL =
'https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md';
/** Max lines to show for a compact tool subview (e.g. diff) */
export const COMPACT_TOOL_SUBVIEW_MAX_LINES = 15;
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { act, useState, useCallback } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
@@ -71,16 +71,61 @@ describe('ToolActionsContext', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default to a pending promise to avoid unwanted async state updates in tests
// that don't specifically test the IdeClient initialization.
vi.mocked(IdeClient.getInstance).mockReturnValue(new Promise(() => {}));
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ToolActionsProvider config={mockConfig} toolCalls={mockToolCalls}>
{children}
</ToolActionsProvider>
);
const WrapperReactComp = ({ children }: { children: React.ReactNode }) => {
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const isExpanded = useCallback(
(callId: string) => expandedTools.has(callId),
[expandedTools],
);
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 toggleAllExpansion = useCallback((callIds: string[]) => {
setExpandedTools((prev) => {
const next = new Set(prev);
const anyCollapsed = callIds.some((id) => !next.has(id));
if (anyCollapsed) {
callIds.forEach((id) => next.add(id));
} else {
callIds.forEach((id) => next.delete(id));
}
return next;
});
}, []);
return (
<ToolActionsProvider
config={mockConfig}
toolCalls={mockToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
>
{children}
</ToolActionsProvider>
);
};
it('publishes to MessageBus for tools with correlationId', async () => {
const { result } = await renderHook(() => useToolActions(), { wrapper });
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
await result.current.confirm(
'modern-call',
@@ -98,7 +143,9 @@ describe('ToolActionsContext', () => {
});
it('handles cancel by calling confirm with Cancel outcome', async () => {
const { result } = await renderHook(() => useToolActions(), { wrapper });
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
await result.current.cancel('modern-call');
@@ -127,7 +174,9 @@ describe('ToolActionsContext', () => {
);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
const { result } = await renderHook(() => useToolActions(), { wrapper });
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
await act(async () => {
deferredIdeClient.resolve(mockIdeClient);
@@ -169,7 +218,9 @@ describe('ToolActionsContext', () => {
);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
const { result } = await renderHook(() => useToolActions(), { wrapper });
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
await act(async () => {
deferredIdeClient.resolve(mockIdeClient);
@@ -214,7 +265,13 @@ describe('ToolActionsContext', () => {
const { result } = await renderHook(() => useToolActions(), {
wrapper: ({ children }) => (
<ToolActionsProvider config={mockConfig} toolCalls={[legacyTool]}>
<ToolActionsProvider
config={mockConfig}
toolCalls={[legacyTool]}
isExpanded={vi.fn().mockReturnValue(false)}
toggleExpansion={vi.fn()}
toggleAllExpansion={vi.fn()}
>
{children}
</ToolActionsProvider>
),
@@ -233,4 +290,58 @@ describe('ToolActionsContext', () => {
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
describe('toggleAllExpansion', () => {
it('expands all when none are expanded', async () => {
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
act(() => {
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
});
expect(result.current.isExpanded('modern-call')).toBe(true);
expect(result.current.isExpanded('edit-call')).toBe(true);
});
it('expands all when some are expanded', async () => {
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
act(() => {
result.current.toggleExpansion('modern-call');
});
expect(result.current.isExpanded('modern-call')).toBe(true);
expect(result.current.isExpanded('edit-call')).toBe(false);
act(() => {
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
});
expect(result.current.isExpanded('modern-call')).toBe(true);
expect(result.current.isExpanded('edit-call')).toBe(true);
});
it('collapses all when all are expanded', async () => {
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
act(() => {
result.current.toggleExpansion('modern-call');
result.current.toggleExpansion('edit-call');
});
expect(result.current.isExpanded('modern-call')).toBe(true);
expect(result.current.isExpanded('edit-call')).toBe(true);
act(() => {
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
});
expect(result.current.isExpanded('modern-call')).toBe(false);
expect(result.current.isExpanded('edit-call')).toBe(false);
});
});
});
@@ -48,11 +48,14 @@ interface ToolActionsContextValue {
) => Promise<void>;
cancel: (callId: string) => Promise<void>;
isDiffingEnabled: boolean;
isExpanded: (callId: string) => boolean;
toggleExpansion: (callId: string) => void;
toggleAllExpansion: (callIds: 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');
@@ -64,12 +67,22 @@ interface ToolActionsProviderProps {
children: React.ReactNode;
config: Config;
toolCalls: IndividualToolCallDisplay[];
isExpanded: (callId: string) => boolean;
toggleExpansion: (callId: string) => void;
toggleAllExpansion: (callIds: string[]) => void;
}
export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
props: ToolActionsProviderProps,
) => {
const { children, config, toolCalls } = props;
const {
children,
config,
toolCalls,
isExpanded,
toggleExpansion,
toggleAllExpansion,
} = props;
// Hoist IdeClient logic here to keep UI pure
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
@@ -77,24 +90,23 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
useEffect(() => {
let isMounted = true;
let activeClient: IdeClient | null = null;
const handleStatusChange = () => {
if (isMounted && activeClient) {
setIsDiffingEnabled(activeClient.isDiffingEnabled());
}
};
if (config.getIdeMode()) {
IdeClient.getInstance()
.then((client) => {
if (!isMounted) return;
activeClient = client;
setIdeClient(client);
setIsDiffingEnabled(client.isDiffingEnabled());
const handleStatusChange = () => {
if (isMounted) {
setIsDiffingEnabled(client.isDiffingEnabled());
}
};
client.addStatusChangeListener(handleStatusChange);
// Return a cleanup function for the listener
return () => {
client.removeStatusChangeListener(handleStatusChange);
};
})
.catch((error) => {
debugLogger.error('Failed to get IdeClient instance:', error);
@@ -102,6 +114,9 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
}
return () => {
isMounted = false;
if (activeClient) {
activeClient.removeStatusChangeListener(handleStatusChange);
}
};
}, [config]);
@@ -164,7 +179,16 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
);
return (
<ToolActionsContext.Provider value={{ confirm, cancel, isDiffingEnabled }}>
<ToolActionsContext.Provider
value={{
confirm,
cancel,
isDiffingEnabled,
isExpanded,
toggleExpansion,
toggleAllExpansion,
}}
>
{children}
</ToolActionsContext.Provider>
);
+1
View File
@@ -51,6 +51,7 @@ export function mapToDisplay(
parentCallId: call.request.parentCallId,
name: displayName,
description,
args: call.request.args,
renderOutputAsMarkdown,
};
+117 -22
View File
@@ -82,6 +82,7 @@ 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,
@@ -298,9 +299,32 @@ 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).tools[0],
isCompactModeEnabled,
);
const prevWasCompact = isCompactTool(
mapTrackedToolCallsToDisplay(prevTool).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,
@@ -335,9 +359,7 @@ export const useGeminiStream = (
}
// Handle tool response submission immediately when tools complete
await handleCompletedTools(
completedToolCallsFromScheduler as TrackedToolCall[],
);
await handleCompletedTools(completedToolCallsFromScheduler);
}
},
config,
@@ -467,26 +489,85 @@ export const useGeminiStream = (
if (toolsToPush.length > 0) {
const newPushed = new Set(pushedToolCallIdsRef.current);
const isFirstInThisPush = isFirstToolInGroupRef.current;
const isCompactModeEnabled =
settings.merged.ui?.compactToolOutput === true;
const groups: TrackedToolCall[][] = [];
let currentGroup: TrackedToolCall[] = [];
for (const tc of toolsToPush) {
newPushed.add(tc.request.callId);
if (tc.tool?.kind === Kind.Agent) {
currentGroup.push(tc);
} else {
if (currentGroup.length > 0) {
groups.push(currentGroup);
currentGroup = [];
}
groups.push([tc]);
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
const isLastInBatch =
toolsToPush[toolsToPush.length - 1] === toolCalls[toolCalls.length - 1];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
const isFirstInBatch = i === 0 && isFirstInThisPush;
const lastTcInGroup = group[group.length - 1];
const tcIndexInBatch = toolCalls.indexOf(lastTcInGroup);
const isLastInBatch = tcIndexInBatch === toolCalls.length - 1;
const historyItem = mapTrackedToolCallsToDisplay(toolsToPush, {
borderTop: isFirstToolInGroupRef.current,
borderBottom: isLastInBatch,
...getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
),
});
addItem(historyItem);
const nextTcInBatch =
tcIndexInBatch < toolCalls.length - 1
? toolCalls[tcIndexInBatch + 1]
: null;
const prevTcInBatch =
toolCalls.indexOf(group[0]) > 0
? toolCalls[toolCalls.indexOf(group[0]) - 1]
: null;
const historyItem = mapTrackedToolCallsToDisplay(group, {
...getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
),
});
// Determine if this group starts with a compact tool
const currentIsCompact =
historyItem.tools.length === 1 &&
isCompactTool(historyItem.tools[0], isCompactModeEnabled);
let nextIsCompact = false;
if (nextTcInBatch) {
const nextHistoryItem = mapTrackedToolCallsToDisplay(nextTcInBatch);
nextIsCompact =
nextHistoryItem.tools.length === 1 &&
isCompactTool(nextHistoryItem.tools[0], isCompactModeEnabled);
}
let prevWasCompact = false;
if (prevTcInBatch) {
const prevHistoryItem = mapTrackedToolCallsToDisplay(prevTcInBatch);
prevWasCompact =
prevHistoryItem.tools.length === 1 &&
isCompactTool(prevHistoryItem.tools[0], isCompactModeEnabled);
}
historyItem.borderTop =
isFirstInBatch || (!currentIsCompact && prevWasCompact);
historyItem.borderBottom = currentIsCompact
? isLastInBatch && !nextIsCompact
: isLastInBatch || nextIsCompact;
addItem(historyItem);
}
setPushedToolCallIds(newPushed);
setIsFirstToolInGroup(false);
@@ -501,6 +582,7 @@ export const useGeminiStream = (
activeShellPtyId,
isShellFocused,
backgroundShells,
settings.merged.ui?.compactToolOutput,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
@@ -544,8 +626,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) => {
const displayName = tc.tool?.displayName ?? tc.request.name;
let hasResultDisplay = false;
@@ -582,12 +663,25 @@ export const useGeminiStream = (
// ToolGroupMessage now shows all non-canceled tools, so they are visible
// in pending and we need to draw the closing border for them.
return true;
});
};
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)
toolCalls.some(isToolVisible) &&
!lastVisibleIsCompact
) {
items.push({
type: 'tool_group' as const,
@@ -605,6 +699,7 @@ export const useGeminiStream = (
activeShellPtyId,
isShellFocused,
backgroundShells,
settings.merged.ui?.compactToolOutput,
]);
const lastQueryRef = useRef<PartListUnion | null>(null);
+5
View File
@@ -119,6 +119,7 @@ export interface IndividualToolCallDisplay {
parentCallId?: string;
name: string;
description: string;
args?: Record<string, unknown>;
resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus;
// True when the tool was initiated directly by the user (slash/@/shell flows).
@@ -369,6 +370,10 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
showSchema: boolean;
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
// Individually exported types extending HistoryItemBase
export type HistoryItemWithoutId =
| HistoryItemUser
| HistoryItemUserShell
@@ -19,7 +19,8 @@ Tips for getting started:
│ ⊶ google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = `
@@ -41,7 +42,8 @@ Tips for getting started:
│ ⊶ 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`] = `
@@ -63,5 +65,6 @@ Tips for getting started:
│ ⊶ google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
+19
View File
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
/**
* Gets the file extension from a filename or path, excluding the leading dot.
* Returns null if no extension is found.
*/
export function getFileExtension(
filename: string | null | undefined,
): string | null {
if (!filename) return null;
const ext = path.extname(filename);
return ext ? ext.slice(1) : null;
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { CoreToolCallStatus } from '@google/gemini-cli-core';
*/
export const TOOL_RESULT_STATIC_HEIGHT = 1;
export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6;
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 3;
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 4;
export const TOOL_RESULT_MIN_LINES_SHOWN = 2;
/**
+43 -1
View File
@@ -57,7 +57,10 @@ import {
} from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { ACTIVATE_SKILL_TOOL_NAME } from '../tools/tool-names.js';
import {
ACTIVATE_SKILL_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
} from '../tools/tool-names.js';
import type { SkillDefinition } from '../skills/skillLoader.js';
import type { McpClientManager } from '../tools/mcp-client-manager.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
@@ -1563,6 +1566,45 @@ describe('Server Config (config.ts)', () => {
expect(config.getSandboxNetworkAccess()).toBe(false);
});
});
describe('Topic & Update Narration', () => {
it('should NOT exclude topic tool when narration is enabled', () => {
const config = new Config({
...baseParams,
topicUpdateNarration: true,
});
const excluded = config.getExcludeTools();
expect(excluded!.has(UPDATE_TOPIC_TOOL_NAME)).toBe(false);
});
it('should exclude topic tool when narration is disabled', () => {
const config = new Config({
...baseParams,
topicUpdateNarration: false,
});
const excluded = config.getExcludeTools();
expect(excluded).toBeDefined();
expect(excluded!.has(UPDATE_TOPIC_TOOL_NAME)).toBe(true);
});
it('should default to disabled and exclude topic tool', () => {
const config = new Config(baseParams);
expect(config.isTopicUpdateNarrationEnabled()).toBe(false);
const excluded = config.getExcludeTools();
expect(excluded!.has(UPDATE_TOPIC_TOOL_NAME)).toBe(true);
});
it('should have independent TopicState across instances', () => {
const config1 = new Config(baseParams);
const config2 = new Config(baseParams);
config1.topicState.setTopic('Topic 1');
config2.topicState.setTopic('Topic 2');
expect(config1.topicState.getTopic()).toBe('Topic 1');
expect(config2.topicState.getTopic()).toBe('Topic 2');
});
});
});
describe('GemmaModelRouterSettings', () => {
+11
View File
@@ -21,6 +21,7 @@ import type { OverageStrategy } from '../billing/billing.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
import { ResourceRegistry } from '../resources/resource-registry.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { LSTool } from '../tools/ls.js';
import { ReadFileTool } from '../tools/read-file.js';
import { GrepTool } from '../tools/grep.js';
@@ -34,6 +35,7 @@ import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { GeminiClient } from '../core/client.js';
@@ -700,6 +702,7 @@ export class Config implements McpContext, AgentLoopContext {
private clientVersion: string;
private fileSystemService: FileSystemService;
private trackerService?: TrackerService;
readonly topicState = new TopicState();
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
readonly modelConfigService: ModelConfigService;
@@ -2083,6 +2086,10 @@ export class Config implements McpContext, AgentLoopContext {
excludeToolsSet.add(tool);
}
if (!this.isTopicUpdateNarrationEnabled()) {
excludeToolsSet.add(UPDATE_TOPIC_TOOL_NAME);
}
return excludeToolsSet;
}
@@ -3285,6 +3292,10 @@ export class Config implements McpContext, AgentLoopContext {
}
};
maybeRegister(UpdateTopicTool, () =>
registry.registerTool(new UpdateTopicTool(this, this.messageBus)),
);
maybeRegister(LSTool, () =>
registry.registerTool(new LSTool(this, this.messageBus)),
);
@@ -59,6 +59,11 @@ describe('Core System Prompt Substitution', () => {
getSkills: vi.fn().mockReturnValue([]),
}),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
isTrackerEnabled: vi.fn().mockReturnValue(false),
isModelSteeringEnabled: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
} as unknown as Config;
});
@@ -103,6 +103,13 @@ decision = "allow"
priority = 70
modes = ["plan"]
# Topic grouping tool is innocuous and used for UI organization.
[[rule]]
toolName = "update_topic"
decision = "allow"
priority = 70
modes = ["plan"]
[[rule]]
toolName = ["ask_user", "save_memory"]
decision = "ask_user"
@@ -55,4 +55,10 @@ priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help", "get_internal_docs"]
decision = "allow"
priority = 50
# Topic grouping tool is innocuous and used for UI organization.
[[rule]]
toolName = "update_topic"
decision = "allow"
priority = 50
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import * as path from 'node:path';
import { loadPoliciesFromToml } from './toml-loader.js';
import { PolicyEngine } from './policy-engine.js';
import { ApprovalMode, PolicyDecision } from './types.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
describe('Topic Tool Policy', () => {
async function loadDefaultPolicies() {
// Path relative to packages/core root
const policiesDir = path.resolve(process.cwd(), 'src/policy/policies');
const getPolicyTier = () => 1; // Default tier
const result = await loadPoliciesFromToml([policiesDir], getPolicyTier);
return result.rules;
}
it('should allow update_topic in DEFAULT mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.DEFAULT,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should allow update_topic in PLAN mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.PLAN,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should allow update_topic in YOLO mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
@@ -15,6 +15,8 @@ import { PREVIEW_GEMINI_MODEL } from '../config/models.js';
import { ApprovalMode } from '../policy/types.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { TopicState } from '../tools/topicTool.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
@@ -53,6 +55,7 @@ describe('PromptProvider', () => {
).getToolRegistry?.() as unknown as ToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
storage: {
@@ -73,6 +76,8 @@ describe('PromptProvider', () => {
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
getApprovalMode: vi.fn(),
isTrackerEnabled: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
} as unknown as Config;
});
@@ -234,4 +239,61 @@ describe('PromptProvider', () => {
expect(prompt).not.toContain('### APPROVED PLAN PRESERVATION');
});
});
describe('Topic & Update Narration', () => {
beforeEach(() => {
mockConfig.topicState.reset();
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
(mockConfig.getToolRegistry as ReturnType<typeof vi.fn>).mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue([UPDATE_TOPIC_TOOL_NAME]),
getAllTools: vi.fn().mockReturnValue([
new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic',
}),
]),
});
vi.mocked(mockConfig.getHasAccessToPreviewModel).mockReturnValue(true);
vi.mocked(mockConfig.getGemini31LaunchedSync).mockReturnValue(true);
});
it('should include active topic context when narration is enabled', () => {
mockConfig.topicState.setTopic('Active Chapter');
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('[Active Topic: Active Chapter]');
});
it('should NOT include active topic context when narration is disabled', () => {
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
false,
);
mockConfig.topicState.setTopic('Active Chapter');
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('[Active Topic: Active Chapter]');
});
it('should filter out update_topic tool when narration is disabled', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
false,
);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain(UPDATE_TOPIC_TOOL_NAME);
});
it('should NOT filter out update_topic tool when narration is enabled', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain(`<tool>\`${UPDATE_TOPIC_TOOL_NAME}\`</tool>`);
});
});
});
+23 -2
View File
@@ -26,6 +26,7 @@ import {
ENTER_PLAN_MODE_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
} from '../tools/tool-names.js';
import { resolveModel, supportsModernFeatures } from '../config/models.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
@@ -57,6 +58,10 @@ export class PromptProvider {
const skills = context.config.getSkillManager().getSkills();
const toolNames = context.toolRegistry.getAllToolNames();
const enabledToolNames = new Set(toolNames);
if (!context.config.isTopicUpdateNarrationEnabled()) {
enabledToolNames.delete(UPDATE_TOPIC_TOOL_NAME);
}
const approvedPlanPath = context.config.getApprovedPlanPath();
const desiredModel = resolveModel(
@@ -74,7 +79,15 @@ export class PromptProvider {
// --- Context Gathering ---
let planModeToolsList = '';
if (isPlanMode) {
const allTools = context.toolRegistry.getAllTools();
const allTools = context.toolRegistry.getAllTools().filter((t) => {
if (
!context.config.isTopicUpdateNarrationEnabled() &&
t.name === UPDATE_TOPIC_TOOL_NAME
) {
return false;
}
return true;
});
planModeToolsList = allTools
.map((t) => {
if (t instanceof DiscoveredMCPTool) {
@@ -232,7 +245,15 @@ export class PromptProvider {
);
// Sanitize erratic newlines from composition
const sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
let sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
// Context Reinjection (Active Topic)
if (context.config.isTopicUpdateNarrationEnabled()) {
const activeTopic = context.config.topicState.getTopic();
if (activeTopic) {
sanitizedPrompt += `\n\n[Active Topic: ${activeTopic}]`;
}
}
// Write back to file if requested
this.maybeWriteSystemMd(
+18 -42
View File
@@ -10,6 +10,9 @@ import {
EDIT_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
MEMORY_TOOL_NAME,
@@ -235,11 +238,11 @@ Use the following guidelines to optimize your search and read patterns.
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.${mandateConflictResolution(options.hasHierarchicalMemory)}
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
- ${mandateConfirm(options.interactive)}${
options.topicUpdateNarration
? mandateTopicUpdateModel()
: mandateExplainBeforeActing()
options.topicUpdateNarration ? '' : mandateExplainBeforeActing()
}
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${mandateContinueWork(options.interactive)}
${options.topicUpdateNarration ? mandateTopicUpdateModel() : ''}
`.trim();
}
@@ -361,7 +364,7 @@ export function renderOperationalGuidelines(
- **Role:** A senior software engineer and collaborative peer programmer.
- **High-Signal Output:** Focus exclusively on **intent** and **technical rationale**. Avoid conversational filler, apologies, and ${
options.topicUpdateNarration
? 'per-tool explanations.'
? 'unnecessary per-tool explanations.'
: 'mechanical tool-use narration (e.g., "I will now call...").'
}
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
@@ -614,46 +617,19 @@ function mandateConfirm(interactive: boolean): string {
function mandateTopicUpdateModel(): string {
return `
- **Protocol: Topic Model**
You are an agentic system. You must maintain a visible state log that tracks broad logical phases using a specific header format.
## Topic Updates
As you work, the user follows along by reading topic updates that you publish with ${UPDATE_TOPIC_TOOL_NAME}. Keep them informed by doing the following:
- **1. Topic Initialization & Persistence:**
- **The Trigger:** You MUST issue a \`Topic: <Phase> : <Brief Summary>\` header ONLY when beginning a task or when the broad logical nature of the task changes (e.g., transitioning from research to implementation).
- **The Format:** Use exactly \`Topic: <Phase> : <Brief Summary>\` (e.g., \`Topic: <Research> : Researching Agent Skills in the repo\`).
- **Persistence:** Once a Topic is declared, do NOT repeat it for subsequent tool calls or in subsequent messages within that same phase.
- **Start of Task:** Your very first tool execution must be preceded by a Topic header.
- Always call ${UPDATE_TOPIC_TOOL_NAME} in your first and last turn. The final turn should always recap what was done.
- Each topic update should give a concise description of what you are doing for the next few turns in the \`${TOPIC_PARAM_SUMMARY}\` parameter.
- Provide topic updates whenever you change "topics". A topic is typically a discrete subgoal and will be every 3 to 10 turns. Do not use ${UPDATE_TOPIC_TOOL_NAME} on every turn.
- The typical user message should call ${UPDATE_TOPIC_TOOL_NAME} 3 or more times. Each corresponds to a distinct phase of the task, such as "Researching X", "Researching Y", "Implementing Z with X", and "Testing Z".
- Remember to call ${UPDATE_TOPIC_TOOL_NAME} when you experience an unexpected event (e.g., a test failure, compilation error, environment issue, or unexpected learning) that requires a strategic detour.
- **Examples:**
- \`update_topic(${TOPIC_PARAM_TITLE}="Researching Parser", ${TOPIC_PARAM_SUMMARY}="I am starting an investigation into the parser timeout bug. My goal is to first understand the current test coverage and then attempt to reproduce the failure. This phase will focus on identifying the bottleneck in the main loop before we move to implementation.")\`
- \`update_topic(${TOPIC_PARAM_TITLE}="Implementing Buffer Fix", ${TOPIC_PARAM_SUMMARY}="I have completed the research phase and identified a race condition in the tokenizer's buffer management. I am now transitioning to implementation. This new chapter will focus on refactoring the buffer logic to handle async chunks safely, followed by unit testing the fix.")\`
- **2. Tool Execution Protocol (Zero-Noise):**
- **No Per-Tool Headers:** It is a violation of protocol to print "Topic:" before every tool call.
- **Silent Mode:** No conversational filler, no "I will now...", and no summaries between tools.
- Only the Topic header at the start of a broad phase is permitted to break the silence. Everything in between must be silent.
- **3. Thinking Protocol:**
- Use internal thought blocks to keep track of what tools you have called, plan your next steps, and reason about the task.
- Without reasoning and tracking in thought blocks, you may lose context.
- Always use the required syntax for thought blocks to ensure they remain hidden from the user interface.
- **4. Completion:**
- Only when the entire task is finalized do you provide a **Final Summary**.
**IMPORTANT: Topic Headers vs. Thoughts**
The \`Topic: <Phase> : <Brief Summary>\` header must **NOT** be placed inside a thought block. It must be standard text output so that it is properly rendered and displayed in the UI.
**Correct State Log Example:**
\`\`\`
Topic: <Research> : Researching Agent Skills in the repo
<tool_call 1>
<tool_call 2>
<tool_call 3>
Topic: <Implementation> : Implementing the skill-creator logic
<tool_call 1>
<tool_call 2>
The task is complete. [Final Summary]
\`\`\`
- **Constraint Enforcement:** If you repeat a "Topic:" line without a fundamental shift in work, or if you provide a Topic for every tool call, you have failed the system integrity protocol.`;
`;
}
function mandateExplainBeforeActing(): string {
@@ -74,6 +74,7 @@ import {
type AnyDeclarativeTool,
type AnyToolInvocation,
} from '../tools/tools.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import {
CoreToolCallStatus,
ROOT_SCHEDULER_ID,
@@ -441,6 +442,44 @@ describe('Scheduler (Orchestrator)', () => {
]),
);
});
it('should sort UPDATE_TOPIC_TOOL_NAME to the front of the batch', async () => {
const topicReq: ToolCallRequestInfo = {
callId: 'call-topic',
name: UPDATE_TOPIC_TOOL_NAME,
args: { title: 'New Chapter' },
prompt_id: 'p1',
isClientInitiated: false,
};
const otherReq: ToolCallRequestInfo = {
callId: 'call-other',
name: 'test-tool',
args: {},
prompt_id: 'p1',
isClientInitiated: false,
};
// Mock tool registry to return a tool for update_topic
vi.mocked(mockToolRegistry.getTool).mockImplementation((name) => {
if (name === UPDATE_TOPIC_TOOL_NAME) {
return {
name: UPDATE_TOPIC_TOOL_NAME,
build: vi.fn().mockReturnValue({}),
} as unknown as AnyDeclarativeTool;
}
return mockTool;
});
// Schedule in reverse order (other first, topic second)
await scheduler.schedule([otherReq, topicReq], signal);
// Verify they were enqueued in the correct sorted order (topic first)
const enqueueCalls = vi.mocked(mockStateManager.enqueue).mock.calls;
const lastCall = enqueueCalls[enqueueCalls.length - 1][0];
expect(lastCall[0].request.callId).toBe('call-topic');
expect(lastCall[1].request.callId).toBe('call-other');
});
});
describe('Phase 2: Queue Management', () => {
+9 -1
View File
@@ -26,6 +26,7 @@ import {
type ScheduledToolCall,
} from './types.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { PolicyDecision, type ApprovalMode } from '../policy/types.js';
import {
ToolConfirmationOutcome,
@@ -302,9 +303,16 @@ export class Scheduler {
this.state.clearBatch();
const currentApprovalMode = this.config.getApprovalMode();
// Sort requests to ensure Topic changes happen before actions in the same batch.
const sortedRequests = [...requests].sort((a, b) => {
if (a.name === UPDATE_TOPIC_TOOL_NAME) return -1;
if (b.name === UPDATE_TOPIC_TOOL_NAME) return 1;
return 0;
});
try {
const toolRegistry = this.context.toolRegistry;
const newCalls: ToolCall[] = requests.map((request) => {
const newCalls: ToolCall[] = sortedRequests.map((request) => {
const enrichedRequest: ToolCallRequestInfo = {
...request,
schedulerId: this.schedulerId,
@@ -123,5 +123,11 @@ export const EXIT_PLAN_PARAM_PLAN_FILENAME = 'plan_filename';
export const ENTER_PLAN_MODE_TOOL_NAME = 'enter_plan_mode';
export const PLAN_MODE_PARAM_REASON = 'reason';
// -- update_topic --
export const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
export const TOPIC_PARAM_TITLE = 'title';
export const TOPIC_PARAM_SUMMARY = 'summary';
export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
// -- sandbox --
export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions';
@@ -17,10 +17,10 @@ import {
getShellDeclaration,
getExitPlanModeDeclaration,
getActivateSkillDeclaration,
getUpdateTopicDeclaration,
} from './dynamic-declaration-helpers.js';
// Re-export names for compatibility
export {
import {
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
@@ -91,8 +91,89 @@ export {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
UPDATE_TOPIC_TOOL_NAME as UPDATE_TOPIC_TOOL_NAME_BASE,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './base-declarations.js';
export {
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME,
MEMORY_TOOL_NAME,
GET_INTERNAL_DOCS_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME_BASE as UPDATE_TOPIC_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
PARAM_PATTERN,
PARAM_CASE_SENSITIVE,
PARAM_RESPECT_GIT_IGNORE,
PARAM_RESPECT_GEMINI_IGNORE,
PARAM_FILE_FILTERING_OPTIONS,
PARAM_DESCRIPTION,
// Tool-specific parameter names
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
WRITE_FILE_PARAM_CONTENT,
GREP_PARAM_INCLUDE_PATTERN,
GREP_PARAM_EXCLUDE_PATTERN,
GREP_PARAM_NAMES_ONLY,
GREP_PARAM_MAX_MATCHES_PER_FILE,
GREP_PARAM_TOTAL_MAX_MATCHES,
GREP_PARAM_FIXED_STRINGS,
GREP_PARAM_CONTEXT,
GREP_PARAM_AFTER,
GREP_PARAM_BEFORE,
GREP_PARAM_NO_IGNORE,
EDIT_PARAM_INSTRUCTION,
EDIT_PARAM_OLD_STRING,
EDIT_PARAM_NEW_STRING,
EDIT_PARAM_ALLOW_MULTIPLE,
LS_PARAM_IGNORE,
SHELL_PARAM_COMMAND,
SHELL_PARAM_IS_BACKGROUND,
WEB_SEARCH_PARAM_QUERY,
WEB_FETCH_PARAM_PROMPT,
READ_MANY_PARAM_INCLUDE,
READ_MANY_PARAM_EXCLUDE,
READ_MANY_PARAM_RECURSIVE,
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
MEMORY_PARAM_FACT,
TODOS_PARAM_TODOS,
TODOS_ITEM_PARAM_DESCRIPTION,
TODOS_ITEM_PARAM_STATUS,
DOCS_PARAM_PATH,
ASK_USER_PARAM_QUESTIONS,
ASK_USER_QUESTION_PARAM_QUESTION,
ASK_USER_QUESTION_PARAM_HEADER,
ASK_USER_QUESTION_PARAM_TYPE,
ASK_USER_QUESTION_PARAM_OPTIONS,
ASK_USER_QUESTION_PARAM_MULTI_SELECT,
ASK_USER_QUESTION_PARAM_PLACEHOLDER,
ASK_USER_OPTION_PARAM_LABEL,
ASK_USER_OPTION_PARAM_DESCRIPTION,
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
};
// Re-export sets for compatibility
export { DEFAULT_LEGACY_SET } from './model-family-sets/default-legacy.js';
export { GEMINI_3_SET } from './model-family-sets/gemini-3.js';
@@ -221,6 +302,13 @@ export const ENTER_PLAN_MODE_DEFINITION: ToolDefinition = {
overrides: (modelId) => getToolSet(modelId).enter_plan_mode,
};
export const UPDATE_TOPIC_DEFINITION: ToolDefinition = {
get base() {
return getUpdateTopicDeclaration();
},
overrides: (modelId) => getToolSet(modelId).update_topic,
};
// ============================================================================
// DYNAMIC TOOL DEFINITIONS (LEGACY EXPORTS)
// ============================================================================
@@ -23,6 +23,10 @@ import {
SHELL_PARAM_IS_BACKGROUND,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
PARAM_ADDITIONAL_PERMISSIONS,
} from './base-declarations.js';
@@ -204,3 +208,34 @@ export function getActivateSkillDeclaration(
parametersJsonSchema: zodToJsonSchema(schema),
};
}
/**
* Returns the FunctionDeclaration for updating the topic context.
*/
export function getUpdateTopicDeclaration(): FunctionDeclaration {
return {
name: UPDATE_TOPIC_TOOL_NAME,
description:
'Manages your narrative flow. Include `title` and `summary` only when starting a new Chapter (logical phase) or shifting strategic intent.',
parametersJsonSchema: {
type: 'object',
properties: {
[TOPIC_PARAM_TITLE]: {
type: 'string',
description: 'The title of the new topic or chapter.',
},
[TOPIC_PARAM_SUMMARY]: {
type: 'string',
description:
'(OPTIONAL) A detailed summary (5-10 sentences) covering both the work completed in the previous topic and the strategic intent of the new topic. This is required when transitioning between topics to maintain continuity.',
},
[TOPIC_PARAM_STRATEGIC_INTENT]: {
type: 'string',
description:
'A mandatory one-sentence statement of your immediate intent.',
},
},
required: [TOPIC_PARAM_STRATEGIC_INTENT],
},
};
}
@@ -78,6 +78,7 @@ import {
getShellDeclaration,
getExitPlanModeDeclaration,
getActivateSkillDeclaration,
getUpdateTopicDeclaration,
} from '../dynamic-declaration-helpers.js';
import {
DEFAULT_MAX_LINES_TEXT_FILE,
@@ -724,4 +725,5 @@ The agent did not use the todo list because this task could be completed by a ti
exit_plan_mode: () => getExitPlanModeDeclaration(),
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
update_topic: getUpdateTopicDeclaration(),
};
@@ -50,4 +50,5 @@ export interface CoreToolSet {
enter_plan_mode: FunctionDeclaration;
exit_plan_mode: () => FunctionDeclaration;
activate_skill: (skillNames: string[]) => FunctionDeclaration;
update_topic?: FunctionDeclaration;
}
+24 -6
View File
@@ -7,6 +7,7 @@
import fsPromises from 'node:fs/promises';
import { debugLogger } from '../utils/debugLogger.js';
import { MAX_LINE_LENGTH_TEXT_FILE } from '../utils/constants.js';
import type { GrepResult } from './tools.js';
/**
* Result object for a single grep match
@@ -148,12 +149,18 @@ export async function formatGrepResults(
},
searchLocationDescription: string,
totalMaxMatches: number,
): Promise<{ llmContent: string; returnDisplay: string }> {
): Promise<{ llmContent: string; returnDisplay: GrepResult }> {
const { pattern, names_only, include_pattern } = params;
if (allMatches.length === 0) {
const noMatchMsg = `No matches found for pattern "${pattern}" ${searchLocationDescription}${include_pattern ? ` (filter: "${include_pattern}")` : ''}.`;
return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
return {
llmContent: noMatchMsg,
returnDisplay: {
summary: 'No matches found',
matches: [],
},
};
}
const matchesByFile = groupMatchesByFile(allMatches);
@@ -181,7 +188,10 @@ export async function formatGrepResults(
llmContent += filePaths.join('\n');
return {
llmContent: llmContent.trim(),
returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
returnDisplay: {
summary: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
matches: [],
},
};
}
@@ -213,8 +223,16 @@ export async function formatGrepResults(
return {
llmContent: llmContent.trim(),
returnDisplay: `Found ${matchCount} ${matchTerm}${
wasTruncated ? ' (limited)' : ''
}`,
returnDisplay: {
summary: `Found ${matchCount} ${matchTerm}${wasTruncated ? ' (limited)' : ''}`,
matches: allMatches
.filter((m) => !m.isContext)
.map((m) => ({
filePath: m.filePath,
absolutePath: m.absolutePath,
lineNumber: m.lineNumber,
line: m.line,
})),
},
};
}
+19 -7
View File
@@ -6,7 +6,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { GrepTool, type GrepToolParams } from './grep.js';
import type { ToolResult } from './tools.js';
import type { ToolResult, GrepResult } from './tools.js';
import path from 'node:path';
import { isSubpath } from '../utils/paths.js';
import fs from 'node:fs/promises';
@@ -180,7 +180,9 @@ describe('GrepTool', () => {
`File: ${path.join('sub', 'fileC.txt')}`,
);
expect(result.llmContent).toContain('L1: another world in sub dir');
expect(result.returnDisplay).toBe('Found 3 matches');
expect((result.returnDisplay as GrepResult)?.summary).toBe(
'Found 3 matches',
);
}, 30000);
it('should include files that start with ".." in JS fallback', async () => {
@@ -221,7 +223,9 @@ describe('GrepTool', () => {
);
expect(result.llmContent).toContain('File: fileC.txt'); // Path relative to 'sub'
expect(result.llmContent).toContain('L1: another world in sub dir');
expect(result.returnDisplay).toBe('Found 1 match');
expect((result.returnDisplay as GrepResult)?.summary).toBe(
'Found 1 match',
);
}, 30000);
it('should find matches with an include glob', async () => {
@@ -238,7 +242,9 @@ describe('GrepTool', () => {
expect(result.llmContent).toContain(
'L2: function baz() { return "hello"; }',
);
expect(result.returnDisplay).toBe('Found 1 match');
expect((result.returnDisplay as GrepResult)?.summary).toBe(
'Found 1 match',
);
}, 30000);
it('should find matches with an include glob and path', async () => {
@@ -258,7 +264,9 @@ describe('GrepTool', () => {
);
expect(result.llmContent).toContain('File: another.js');
expect(result.llmContent).toContain('L1: const greeting = "hello";');
expect(result.returnDisplay).toBe('Found 1 match');
expect((result.returnDisplay as GrepResult)?.summary).toBe(
'Found 1 match',
);
}, 30000);
it('should return "No matches found" when pattern does not exist', async () => {
@@ -268,7 +276,9 @@ describe('GrepTool', () => {
expect(result.llmContent).toContain(
'No matches found for pattern "nonexistentpattern" in the workspace directory.',
);
expect(result.returnDisplay).toBe('No matches found');
expect((result.returnDisplay as GrepResult)?.summary).toBe(
'No matches found',
);
}, 30000);
it('should handle regex special characters correctly', async () => {
@@ -480,7 +490,9 @@ describe('GrepTool', () => {
expect(result.llmContent).toContain('L2: second line with world');
// And sub/fileC.txt should be excluded because limit reached
expect(result.llmContent).not.toContain('File: sub/fileC.txt');
expect(result.returnDisplay).toBe('Found 2 matches (limited)');
expect((result.returnDisplay as GrepResult)?.summary).toBe(
'Found 2 matches (limited)',
);
});
it('should respect max_matches_per_file in JS fallback', async () => {
+2 -2
View File
@@ -30,7 +30,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 { buildPatternArgsPattern } from '../policy/utils.js';
import { debugLogger } from '../utils/debugLogger.js';
import { GREP_DEFINITION } from './definitions/coreTools.js';
@@ -638,7 +638,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,
+26 -7
View File
@@ -131,7 +131,10 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('[DIR] subdir');
expect(result.llmContent).toContain('file1.txt');
expect(result.returnDisplay).toBe('Listed 2 item(s).');
expect(result.returnDisplay).toEqual({
summary: 'Listed 2 item(s).',
files: ['[DIR] subdir', 'file1.txt'],
});
});
it('should list files from secondary workspace directory', async () => {
@@ -146,7 +149,10 @@ describe('LSTool', () => {
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('secondary-file.txt');
expect(result.returnDisplay).toBe('Listed 1 item(s).');
expect(result.returnDisplay).toEqual({
summary: 'Listed 1 item(s).',
files: expect.any(Array),
});
});
it('should handle empty directories', async () => {
@@ -171,7 +177,10 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('file1.txt');
expect(result.llmContent).not.toContain('file2.log');
expect(result.returnDisplay).toBe('Listed 1 item(s).');
expect(result.returnDisplay).toEqual({
summary: 'Listed 1 item(s).',
files: expect.any(Array),
});
});
it('should respect gitignore patterns', async () => {
@@ -185,7 +194,9 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('file1.txt');
expect(result.llmContent).not.toContain('file2.log');
// .git is always ignored by default.
expect(result.returnDisplay).toBe('Listed 2 item(s). (2 ignored)');
expect(result.returnDisplay).toEqual(
expect.objectContaining({ summary: 'Listed 2 item(s). (2 ignored)' }),
);
});
it('should respect geminiignore patterns', async () => {
@@ -200,7 +211,9 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('file1.txt');
expect(result.llmContent).not.toContain('file2.log');
expect(result.returnDisplay).toBe('Listed 2 item(s). (1 ignored)');
expect(result.returnDisplay).toEqual(
expect.objectContaining({ summary: 'Listed 2 item(s). (1 ignored)' }),
);
});
it('should handle non-directory paths', async () => {
@@ -287,7 +300,10 @@ describe('LSTool', () => {
// Should still list the other files
expect(result.llmContent).toContain('file1.txt');
expect(result.llmContent).not.toContain('problematic.txt');
expect(result.returnDisplay).toBe('Listed 1 item(s).');
expect(result.returnDisplay).toEqual({
summary: 'Listed 1 item(s).',
files: expect.any(Array),
});
statSpy.mockRestore();
});
@@ -347,7 +363,10 @@ describe('LSTool', () => {
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('secondary-file.txt');
expect(result.returnDisplay).toBe('Listed 1 item(s).');
expect(result.returnDisplay).toEqual({
summary: 'Listed 1 item(s).',
files: expect.any(Array),
});
});
});
+8 -4
View File
@@ -20,7 +20,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 { buildDirPathArgsPattern } from '../policy/utils.js';
import { debugLogger } from '../utils/debugLogger.js';
import { LS_DEFINITION } from './definitions/coreTools.js';
@@ -143,7 +143,6 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
): ToolResult {
return {
llmContent,
// Keep returnDisplay simpler in core logic
returnDisplay: `Error: ${returnDisplay}`,
error: {
message: llmContent,
@@ -284,7 +283,12 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
return {
llmContent: resultMessage,
returnDisplay: displayMessage,
returnDisplay: {
summary: displayMessage,
files: entries.map(
(entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`,
),
},
};
} catch (error) {
const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
@@ -309,7 +313,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,
+28 -17
View File
@@ -31,6 +31,7 @@ import {
import * as glob from 'glob';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
import type { ReadManyFilesResult } from './tools.js';
vi.mock('glob', { spy: true });
@@ -277,7 +278,7 @@ describe('ReadManyFilesTool', () => {
`--- ${expectedPath} ---\n\nContent of file1\n\n`,
`\n--- End of content ---`,
]);
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **1 file(s)**',
);
});
@@ -301,7 +302,7 @@ describe('ReadManyFilesTool', () => {
c.includes(`--- ${expectedPath2} ---\n\nContent2\n\n`),
),
).toBe(true);
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **2 file(s)**',
);
});
@@ -327,7 +328,7 @@ describe('ReadManyFilesTool', () => {
),
).toBe(true);
expect(content.find((c) => c.includes('sub/data.json'))).toBeUndefined();
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **2 file(s)**',
);
});
@@ -347,7 +348,7 @@ describe('ReadManyFilesTool', () => {
expect(
content.find((c) => c.includes('src/main.test.ts')),
).toBeUndefined();
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **1 file(s)**',
);
});
@@ -359,7 +360,7 @@ describe('ReadManyFilesTool', () => {
expect(result.llmContent).toEqual([
'No files matching the criteria were found or all were skipped.',
]);
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'No files were read and concatenated based on the criteria.',
);
});
@@ -379,7 +380,7 @@ describe('ReadManyFilesTool', () => {
expect(
content.find((c) => c.includes('node_modules/some-lib/index.js')),
).toBeUndefined();
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **1 file(s)**',
);
});
@@ -406,7 +407,7 @@ describe('ReadManyFilesTool', () => {
c.includes(`--- ${expectedPath2} ---\n\napp code\n\n`),
),
).toBe(true);
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **2 file(s)**',
);
});
@@ -430,7 +431,7 @@ describe('ReadManyFilesTool', () => {
},
'\n--- End of content ---',
]);
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **1 file(s)**',
);
});
@@ -471,8 +472,10 @@ describe('ReadManyFilesTool', () => {
c.includes(`--- ${expectedPath} ---\n\ntext notes\n\n`),
),
).toBe(true);
expect(result.returnDisplay).toContain('**Skipped 1 item(s):**');
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'**Skipped 1 item(s):**',
);
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'- `document.pdf` (Reason: asset file (image/pdf/audio) was not explicitly requested by name or extension)',
);
});
@@ -516,9 +519,15 @@ describe('ReadManyFilesTool', () => {
const params = { include: ['foo.bar', 'bar.ts', 'foo.quux'] };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).not.toContain('foo.bar');
expect(result.returnDisplay).not.toContain('foo.quux');
expect(result.returnDisplay).toContain('bar.ts');
expect((result.returnDisplay as ReadManyFilesResult).files).not.toContain(
'foo.bar',
);
expect((result.returnDisplay as ReadManyFilesResult).files).not.toContain(
'foo.quux',
);
expect((result.returnDisplay as ReadManyFilesResult).files).toContain(
'bar.ts',
);
});
it('should read files from multiple workspace directories', async () => {
@@ -594,7 +603,7 @@ describe('ReadManyFilesTool', () => {
c.includes(`--- ${expectedPath2} ---\n\nContent2\n\n`),
),
).toBe(true);
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **2 file(s)**',
);
@@ -646,7 +655,7 @@ Content of receive-detail
`,
`\n--- End of content ---`,
]);
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **1 file(s)**',
);
});
@@ -665,7 +674,7 @@ Content of file[1]
`,
`\n--- End of content ---`,
]);
expect(result.returnDisplay).toContain(
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read and concatenated content from **1 file(s)**',
);
});
@@ -764,7 +773,9 @@ Content of file[1]
// Should successfully process valid files despite one failure
expect(content.length).toBeGreaterThanOrEqual(3);
expect(result.returnDisplay).toContain('Successfully read');
expect((result.returnDisplay as ReadManyFilesResult).summary).toContain(
'Successfully read',
);
// Verify valid files were processed
const expectedPath1 = path.join(tempRootDir, 'valid1.txt');
+18 -4
View File
@@ -13,6 +13,7 @@ import {
type ToolResult,
type PolicyUpdateOptions,
type ToolConfirmationOutcome,
type ReadManyFilesResult,
} from './tools.js';
import { getErrorMessage } from '../utils/errors.js';
import * as fsPromises from 'node:fs/promises';
@@ -36,7 +37,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';
@@ -269,7 +273,7 @@ ${finalExclusionPatternsForDescription
const errorMessage = `Error during file search: ${getErrorMessage(error)}`;
return {
llmContent: errorMessage,
returnDisplay: `## File Search Error\n\nAn error occurred while searching for files:\n\`\`\`\n${getErrorMessage(error)}\n\`\`\``,
returnDisplay: `Error: ${getErrorMessage(error)}`,
error: {
message: errorMessage,
type: ToolErrorType.READ_MANY_FILES_SEARCH_ERROR,
@@ -483,9 +487,19 @@ ${finalExclusionPatternsForDescription
'No files matching the criteria were found or all were skipped.',
);
}
const returnDisplay: ReadManyFilesResult = {
summary: displayMessage.trim(),
files: processedFilesRelativePaths,
skipped: skippedFiles,
include: this.params.include,
excludes: effectiveExcludes,
targetDir: this.config.getTargetDir(),
};
return {
llmContent: contentParts,
returnDisplay: displayMessage.trim(),
returnDisplay,
};
}
}
@@ -507,7 +521,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,
+28 -9
View File
@@ -19,6 +19,7 @@ import {
ensureRgPath,
type RipGrepToolParams,
} from './ripGrep.js';
import type { GrepResult } from './tools.js';
import path from 'node:path';
import { isSubpath } from '../utils/paths.js';
import fs from 'node:fs/promises';
@@ -447,7 +448,9 @@ describe('RipGrepTool', () => {
`File: ${path.join('sub', 'fileC.txt')}`,
);
expect(result.llmContent).toContain('L1: another world in sub dir');
expect(result.returnDisplay).toBe('Found 3 matches');
expect((result.returnDisplay as GrepResult).summary).toBe(
'Found 3 matches',
);
});
it('should ignore matches that escape the base path', async () => {
@@ -509,7 +512,9 @@ describe('RipGrepTool', () => {
);
expect(result.llmContent).toContain('File: fileC.txt'); // Path relative to 'sub'
expect(result.llmContent).toContain('L1: another world in sub dir');
expect(result.returnDisplay).toBe('Found 1 match');
expect((result.returnDisplay as GrepResult).summary).toBe(
'Found 1 match',
);
});
it('should find matches with an include glob', async () => {
@@ -542,7 +547,9 @@ describe('RipGrepTool', () => {
expect(result.llmContent).toContain(
'L2: function baz() { return "hello"; }',
);
expect(result.returnDisplay).toBe('Found 1 match');
expect((result.returnDisplay as GrepResult).summary).toBe(
'Found 1 match',
);
});
it('should find matches with an include glob and path', async () => {
@@ -579,7 +586,9 @@ describe('RipGrepTool', () => {
);
expect(result.llmContent).toContain('File: another.js');
expect(result.llmContent).toContain('L1: const greeting = "hello";');
expect(result.returnDisplay).toBe('Found 1 match');
expect((result.returnDisplay as GrepResult).summary).toBe(
'Found 1 match',
);
});
it('should return "No matches found" when pattern does not exist', async () => {
@@ -596,7 +605,9 @@ describe('RipGrepTool', () => {
expect(result.llmContent).toContain(
'No matches found for pattern "nonexistentpattern" in path ".".',
);
expect(result.returnDisplay).toBe('No matches found');
expect((result.returnDisplay as GrepResult).summary).toBe(
'No matches found',
);
});
it('should throw error for invalid regex pattern during build', async () => {
@@ -689,7 +700,9 @@ describe('RipGrepTool', () => {
});
const result = await invocation.execute(abortSignal);
expect(result.returnDisplay).toContain('(limited)');
expect((result.returnDisplay as GrepResult).summary).toContain(
'(limited)',
);
}, 10000);
it('should filter out files based on FileDiscoveryService even if ripgrep returns them', async () => {
@@ -740,7 +753,9 @@ describe('RipGrepTool', () => {
expect(result.llmContent).toContain('should be kept');
expect(result.llmContent).not.toContain('ignored.txt');
expect(result.llmContent).not.toContain('should be ignored');
expect(result.returnDisplay).toContain('Found 1 match');
expect((result.returnDisplay as GrepResult).summary).toContain(
'Found 1 match',
);
});
it('should handle regex special characters correctly', async () => {
@@ -1064,7 +1079,9 @@ describe('RipGrepTool', () => {
controller.abort();
const result = await invocation.execute(controller.signal);
expect(result.returnDisplay).toContain('No matches found');
expect((result.returnDisplay as GrepResult).summary).toContain(
'No matches found',
);
});
});
@@ -1946,7 +1963,9 @@ describe('RipGrepTool', () => {
expect(result.llmContent).toContain('L1: match 1');
expect(result.llmContent).toContain('L2: match 2');
expect(result.llmContent).not.toContain('L3: match 3');
expect(result.returnDisplay).toBe('Found 2 matches (limited)');
expect((result.returnDisplay as GrepResult).summary).toBe(
'Found 2 matches (limited)',
);
});
it('should return only file paths when names_only is true', async () => {
+11
View File
@@ -75,6 +75,10 @@ import {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/coreTools.js';
export {
@@ -95,6 +99,7 @@ export {
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -148,6 +153,9 @@ export {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
};
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
@@ -185,6 +193,7 @@ 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 UPDATE_TOPIC_DISPLAY_NAME = 'Update Topic Context';
/**
* Mapping of legacy tool names to their current names.
@@ -253,6 +262,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [
GET_INTERNAL_DOCS_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
] as const;
/**
@@ -269,6 +279,7 @@ export const PLAN_MODE_TOOLS = [
ASK_USER_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
GET_INTERNAL_DOCS_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
'codebase_investigator',
'cli_help',
] as const;
+7 -1
View File
@@ -921,12 +921,18 @@ export const isListResult = (
): res is ListDirectoryResult | ReadManyFilesResult =>
isStructuredToolResult(res) && 'files' in res && Array.isArray(res.files);
export const isReadManyFilesResult = (
res: unknown,
): res is ReadManyFilesResult => isListResult(res) && 'include' in res;
export type ToolResultDisplay =
| string
| FileDiff
| AnsiOutput
| TodoList
| SubagentProgress;
| SubagentProgress
| GrepResult
| ListDirectoryResult
| ReadManyFilesResult;
export type TodoStatus =
| 'pending'
+133
View File
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TopicState, UpdateTopicTool } from './topicTool.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/base-declarations.js';
import type { Config } from '../config/config.js';
describe('TopicState', () => {
let state: TopicState;
beforeEach(() => {
state = new TopicState();
});
it('should store and retrieve topic title and intent', () => {
expect(state.getTopic()).toBeUndefined();
expect(state.getIntent()).toBeUndefined();
const success = state.setTopic('Test Topic', 'Test Intent');
expect(success).toBe(true);
expect(state.getTopic()).toBe('Test Topic');
expect(state.getIntent()).toBe('Test Intent');
});
it('should sanitize newlines and carriage returns', () => {
state.setTopic('Topic\nWith\r\nLines', 'Intent\nWith\r\nLines');
expect(state.getTopic()).toBe('Topic With Lines');
expect(state.getIntent()).toBe('Intent With Lines');
});
it('should trim whitespace', () => {
state.setTopic(' Spaced Topic ', ' Spaced Intent ');
expect(state.getTopic()).toBe('Spaced Topic');
expect(state.getIntent()).toBe('Spaced Intent');
});
it('should reject empty or whitespace-only inputs', () => {
expect(state.setTopic('', '')).toBe(false);
});
it('should reset topic and intent', () => {
state.setTopic('Test Topic', 'Test Intent');
state.reset();
expect(state.getTopic()).toBeUndefined();
expect(state.getIntent()).toBeUndefined();
});
});
describe('UpdateTopicTool', () => {
let tool: UpdateTopicTool;
let mockMessageBus: MessageBus;
let mockConfig: Config;
beforeEach(() => {
mockMessageBus = new MessageBus(vi.mocked({} as PolicyEngine));
// Mock enough of Config to satisfy the tool
mockConfig = {
topicState: new TopicState(),
} as unknown as Config;
tool = new UpdateTopicTool(mockConfig, mockMessageBus);
});
it('should have correct name and display name', () => {
expect(tool.name).toBe(UPDATE_TOPIC_TOOL_NAME);
expect(tool.displayName).toBe('Update Topic Context');
});
it('should update TopicState and include strategic intent on execute', async () => {
const invocation = tool.build({
[TOPIC_PARAM_TITLE]: 'New Chapter',
[TOPIC_PARAM_SUMMARY]: 'The goal is to implement X. Previously we did Y.',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Initial Move',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Current topic: "New Chapter"');
expect(result.llmContent).toContain(
'Topic summary: The goal is to implement X. Previously we did Y.',
);
expect(result.llmContent).toContain('Strategic Intent: Initial Move');
expect(mockConfig.topicState.getTopic()).toBe('New Chapter');
expect(mockConfig.topicState.getIntent()).toBe('Initial Move');
expect(result.returnDisplay).toContain('## 📂 Topic: **New Chapter**');
expect(result.returnDisplay).toContain('**Summary:**');
expect(result.returnDisplay).toContain(
'> [!STRATEGY]\n> **Intent:** Initial Move',
);
});
it('should render only intent for tactical updates (same topic)', async () => {
mockConfig.topicState.setTopic('New Chapter');
const invocation = tool.build({
[TOPIC_PARAM_TITLE]: 'New Chapter',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Subsequent Move',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).not.toContain('## 📂 Topic:');
expect(result.returnDisplay).toBe(
'> [!STRATEGY]\n> **Intent:** Subsequent Move',
);
expect(result.llmContent).toBe('Strategic Intent: Subsequent Move');
});
it('should return error if strategic_intent is missing', async () => {
try {
tool.build({
[TOPIC_PARAM_TITLE]: 'Title',
});
expect.fail('Should have thrown validation error');
} catch (e: unknown) {
if (e instanceof Error) {
expect(e.message).toContain(
"must have required property 'strategic_intent'",
);
} else {
expect.fail('Expected Error instance');
}
}
expect(mockConfig.topicState.getTopic()).toBeUndefined();
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/coreTools.js';
import { UPDATE_TOPIC_DISPLAY_NAME } from './tool-names.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolResult,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js';
import type { Config } from '../config/config.js';
/**
* Manages the current active topic title and tactical intent for a session.
* Hosted within the Config instance for session-scoping.
*/
export class TopicState {
private activeTopicTitle?: string;
private activeIntent?: string;
/**
* Sanitizes and sets the topic title and/or intent.
* @returns true if the input was valid and set, false otherwise.
*/
setTopic(title?: string, intent?: string): boolean {
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
if (!sanitizedTitle && !sanitizedIntent) return false;
if (sanitizedTitle) {
this.activeTopicTitle = sanitizedTitle;
}
if (sanitizedIntent) {
this.activeIntent = sanitizedIntent;
}
return true;
}
getTopic(): string | undefined {
return this.activeTopicTitle;
}
getIntent(): string | undefined {
return this.activeIntent;
}
reset(): void {
this.activeTopicTitle = undefined;
this.activeIntent = undefined;
}
}
interface UpdateTopicParams {
[TOPIC_PARAM_TITLE]?: string;
[TOPIC_PARAM_SUMMARY]?: string;
[TOPIC_PARAM_STRATEGIC_INTENT]?: string;
}
class UpdateTopicInvocation extends BaseToolInvocation<
UpdateTopicParams,
ToolResult
> {
constructor(
params: UpdateTopicParams,
messageBus: MessageBus,
toolName: string,
private readonly config: Config,
) {
super(params, messageBus, toolName);
}
getDescription(): string {
const title = this.params[TOPIC_PARAM_TITLE];
const intent = this.params[TOPIC_PARAM_STRATEGIC_INTENT];
if (title) {
return `Update topic to: "${title}"`;
}
return `Update tactical intent: "${intent || '...'}"`;
}
async execute(): Promise<ToolResult> {
const title = this.params[TOPIC_PARAM_TITLE];
const summary = this.params[TOPIC_PARAM_SUMMARY];
const strategicIntent = this.params[TOPIC_PARAM_STRATEGIC_INTENT];
const activeTopic = this.config.topicState.getTopic();
const isNewTopic = !!(
title &&
title.trim() !== '' &&
title.trim() !== activeTopic
);
this.config.topicState.setTopic(title, strategicIntent);
const currentTopic = this.config.topicState.getTopic() || '...';
const currentIntent =
strategicIntent || this.config.topicState.getIntent() || '...';
debugLogger.log(
`[TopicTool] Update: Topic="${currentTopic}", Intent="${currentIntent}", isNew=${isNewTopic}`,
);
let llmContent = '';
let returnDisplay = '';
if (isNewTopic) {
// Handle New Topic Header & Summary
llmContent = `Current topic: "${currentTopic}"\nTopic summary: ${summary || '...'}`;
returnDisplay = `## 📂 Topic: **${currentTopic}**\n\n**Summary:**\n${summary || '...'}`;
if (strategicIntent && strategicIntent.trim()) {
llmContent += `\n\nStrategic Intent: ${strategicIntent.trim()}`;
returnDisplay += `\n\n> [!STRATEGY]\n> **Intent:** ${strategicIntent.trim()}`;
}
} else {
// Tactical update only
llmContent = `Strategic Intent: ${currentIntent}`;
returnDisplay = `> [!STRATEGY]\n> **Intent:** ${currentIntent}`;
}
return {
llmContent,
returnDisplay,
};
}
}
/**
* Tool to update semantic topic context and tactical intent for UI grouping and model focus.
*/
export class UpdateTopicTool extends BaseDeclarativeTool<
UpdateTopicParams,
ToolResult
> {
constructor(
private readonly config: Config,
messageBus: MessageBus,
) {
const declaration = getUpdateTopicDeclaration();
super(
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
declaration.description ?? '',
Kind.Think,
declaration.parametersJsonSchema,
messageBus,
);
}
protected createInvocation(
params: UpdateTopicParams,
messageBus: MessageBus,
): UpdateTopicInvocation {
return new UpdateTopicInvocation(
params,
messageBus,
this.name,
this.config,
);
}
}
+7
View File
@@ -317,6 +317,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",