diff --git a/.gemini/skills/docs-changelog/SKILL.md b/.gemini/skills/docs-changelog/SKILL.md index eb56bad98e..edd402b6bc 100644 --- a/.gemini/skills/docs-changelog/SKILL.md +++ b/.gemini/skills/docs-changelog/SKILL.md @@ -23,6 +23,8 @@ To standardize the process of updating changelog files (`latest.md`, ## Guidelines for `latest.md` and `preview.md` Highlights - Aim for **3-5 key highlight points**. +- Each highlight point must start with a bold-typed title that summarizes the + change (e.g., `**New Feature:** A brief description...`). - **Prioritize** summarizing new features over other changes like bug fixes or chores. - **Avoid** mentioning features that are "experimental" or "in preview" in @@ -65,6 +67,8 @@ detailed **highlights** section for the release-specific page. 1. **Create the Announcement for `index.md`**: - Generate a concise announcement summarizing the most important changes. + Each announcement entry must start with a bold-typed title that + summarizes the change. - **Important**: The format for this announcement is unique. You **must** use the existing announcements in `docs/changelogs/index.md` and the example within diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index c6f54d4f2b..08a3625822 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -32,6 +32,7 @@ jobs: with: # The user-level skills need to be available to the workflow fetch-depth: 0 + ref: 'main' - name: 'Set up Node.js' uses: 'actions/setup-node@v4' @@ -42,7 +43,6 @@ jobs: id: 'release_info' run: | VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}" - BODY="${{ github.event.inputs.body || github.event.release.body }}" TIME="${{ github.event.inputs.time || github.event.release.created_at }}" echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" @@ -50,10 +50,11 @@ jobs: # Use a heredoc to preserve multiline release body echo 'RAW_CHANGELOG<> "$GITHUB_OUTPUT" - printf "%s\n" "${BODY}" >> "$GITHUB_OUTPUT" + printf "%s\n" "$BODY" >> "$GITHUB_OUTPUT" echo 'EOF' >> "$GITHUB_OUTPUT" env: GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + BODY: '${{ github.event.inputs.body || github.event.release.body }}' - name: 'Generate Changelog with Gemini' uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 diff --git a/GEMINI.md b/GEMINI.md index daeaa747f7..f7017eab40 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -47,7 +47,13 @@ powerful tool for developers. be relative to the workspace root, e.g., `-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`) - **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install, - build, lint, type check, and tests. Recommended before submitting PRs.) + build, lint, type check, and tests. Recommended before submitting PRs. Due to + its long runtime, only run this at the very end of a code implementation task. + If it fails, use faster, targeted commands (e.g., `npm run test`, + `npm run lint`, or workspace-specific tests) to iterate on fixes before + re-running `preflight`. For simple, non-code changes like documentation or + prompting updates, skip `preflight` at the end of the task and wait for PR + validation.) - **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck` ## Development Conventions diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index adc5b12c0a..0117a652a2 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -36,7 +36,7 @@ available combinations. | Delete from the cursor to the start of the line. | `Ctrl + U` | | Clear all text in the input field. | `Ctrl + C` | | Delete the previous word. | `Ctrl + Backspace`
`Alt + Backspace`
`Ctrl + W` | -| Delete the next word. | `Ctrl + Delete`
`Alt + Delete` | +| Delete the next word. | `Ctrl + Delete`
`Alt + Delete`
`Alt + D` | | Delete the character to the left. | `Backspace`
`Ctrl + H` | | Delete the character to the right. | `Delete`
`Ctrl + D` | | Undo the most recent text edit. | `Cmd + Z (no Shift)`
`Alt + Z (no Shift)` | diff --git a/evals/tool_output_masking.eval.ts b/evals/tool_output_masking.eval.ts new file mode 100644 index 0000000000..dff639e421 --- /dev/null +++ b/evals/tool_output_masking.eval.ts @@ -0,0 +1,297 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; +import path from 'node:path'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; + +// Recursive function to find a directory by name +function findDir(base: string, name: string): string | null { + if (!fs.existsSync(base)) return null; + const files = fs.readdirSync(base); + for (const file of files) { + const fullPath = path.join(base, file); + if (fs.statSync(fullPath).isDirectory()) { + if (file === name) return fullPath; + const found = findDir(fullPath, name); + if (found) return found; + } + } + return null; +} + +describe('Tool Output Masking Behavioral Evals', () => { + /** + * Scenario: The agent needs information that was masked in a previous turn. + * It should recognize the tag and use a tool to read the file. + */ + evalTest('USUALLY_PASSES', { + name: 'should attempt to read the redirected full output file when information is masked', + params: { + security: { + folderTrust: { + enabled: true, + }, + }, + }, + prompt: '/help', + assert: async (rig) => { + // 1. Initialize project directories + await rig.run({ args: '/help' }); + + // 2. Discover the project temp dir + const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats'); + if (!chatsDir) throw new Error('Could not find chats directory'); + const projectTempDir = path.dirname(chatsDir); + + const sessionId = crypto.randomUUID(); + const toolOutputsDir = path.join( + projectTempDir, + 'tool-outputs', + `session-${sessionId}`, + ); + fs.mkdirSync(toolOutputsDir, { recursive: true }); + + const secretValue = 'THE_RECOVERED_SECRET_99'; + const outputFileName = `masked_output_${crypto.randomUUID()}.txt`; + const outputFilePath = path.join(toolOutputsDir, outputFileName); + fs.writeFileSync( + outputFilePath, + `Some padding...\nThe secret key is: ${secretValue}\nMore padding...`, + ); + + const maskedSnippet = ` +Output: [PREVIEW] +Output too large. Full output available at: ${outputFilePath} +`; + + // 3. Inject manual session file + const conversation = { + sessionId: sessionId, + projectHash: path.basename(projectTempDir), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages: [ + { + id: 'msg_1', + timestamp: new Date().toISOString(), + type: 'user', + content: [{ text: 'Get secret.' }], + }, + { + id: 'msg_2', + timestamp: new Date().toISOString(), + type: 'gemini', + model: 'gemini-3-flash-preview', + toolCalls: [ + { + id: 'call_1', + name: 'run_shell_command', + args: { command: 'get_secret' }, + status: 'success', + timestamp: new Date().toISOString(), + result: [ + { + functionResponse: { + id: 'call_1', + name: 'run_shell_command', + response: { output: maskedSnippet }, + }, + }, + ], + }, + ], + content: [{ text: 'I found a masked output.' }], + }, + ], + }; + + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + conversation.startTime = futureDate.toISOString(); + conversation.lastUpdated = futureDate.toISOString(); + const timestamp = futureDate + .toISOString() + .slice(0, 16) + .replace(/:/g, '-'); + const sessionFile = path.join( + chatsDir, + `session-${timestamp}-${sessionId.slice(0, 8)}.json`, + ); + fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2)); + + // 4. Trust folder + const settingsDir = path.join(rig.homeDir!, '.gemini'); + fs.writeFileSync( + path.join(settingsDir, 'trustedFolders.json'), + JSON.stringify( + { + [path.resolve(rig.homeDir!)]: 'TRUST_FOLDER', + }, + null, + 2, + ), + ); + + // 5. Run agent with --resume + const result = await rig.run({ + args: [ + '--resume', + 'latest', + 'What was the secret key in that last masked shell output?', + ], + approvalMode: 'yolo', + timeout: 120000, + }); + + // ASSERTION: Verify agent accessed the redirected file + const logs = rig.readToolLogs(); + const accessedFile = logs.some((log) => + log.toolRequest.args.includes(outputFileName), + ); + + expect( + accessedFile, + `Agent should have attempted to access the masked output file: ${outputFileName}`, + ).toBe(true); + expect(result.toLowerCase()).toContain(secretValue.toLowerCase()); + }, + }); + + /** + * Scenario: Information is in the preview. + */ + evalTest('USUALLY_PASSES', { + name: 'should NOT read the full output file when the information is already in the preview', + params: { + security: { + folderTrust: { + enabled: true, + }, + }, + }, + prompt: '/help', + assert: async (rig) => { + await rig.run({ args: '/help' }); + + const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats'); + if (!chatsDir) throw new Error('Could not find chats directory'); + const projectTempDir = path.dirname(chatsDir); + + const sessionId = crypto.randomUUID(); + const toolOutputsDir = path.join( + projectTempDir, + 'tool-outputs', + `session-${sessionId}`, + ); + fs.mkdirSync(toolOutputsDir, { recursive: true }); + + const secretValue = 'PREVIEW_SECRET_123'; + const outputFileName = `masked_output_${crypto.randomUUID()}.txt`; + const outputFilePath = path.join(toolOutputsDir, outputFileName); + fs.writeFileSync( + outputFilePath, + `Full content containing ${secretValue}`, + ); + + const maskedSnippet = ` +Output: The secret key is: ${secretValue} +... lines omitted ... + +Output too large. Full output available at: ${outputFilePath} +`; + + const conversation = { + sessionId: sessionId, + projectHash: path.basename(projectTempDir), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages: [ + { + id: 'msg_1', + timestamp: new Date().toISOString(), + type: 'user', + content: [{ text: 'Find secret.' }], + }, + { + id: 'msg_2', + timestamp: new Date().toISOString(), + type: 'gemini', + model: 'gemini-3-flash-preview', + toolCalls: [ + { + id: 'call_1', + name: 'run_shell_command', + args: { command: 'get_secret' }, + status: 'success', + timestamp: new Date().toISOString(), + result: [ + { + functionResponse: { + id: 'call_1', + name: 'run_shell_command', + response: { output: maskedSnippet }, + }, + }, + ], + }, + ], + content: [{ text: 'Masked output found.' }], + }, + ], + }; + + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + conversation.startTime = futureDate.toISOString(); + conversation.lastUpdated = futureDate.toISOString(); + const timestamp = futureDate + .toISOString() + .slice(0, 16) + .replace(/:/g, '-'); + const sessionFile = path.join( + chatsDir, + `session-${timestamp}-${sessionId.slice(0, 8)}.json`, + ); + fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2)); + + const settingsDir = path.join(rig.homeDir!, '.gemini'); + fs.writeFileSync( + path.join(settingsDir, 'trustedFolders.json'), + JSON.stringify( + { + [path.resolve(rig.homeDir!)]: 'TRUST_FOLDER', + }, + null, + 2, + ), + ); + + const result = await rig.run({ + args: [ + '--resume', + 'latest', + 'What was the secret key mentioned in the previous output?', + ], + approvalMode: 'yolo', + timeout: 120000, + }); + + const logs = rig.readToolLogs(); + const accessedFile = logs.some((log) => + log.toolRequest.args.includes(outputFileName), + ); + + expect( + accessedFile, + 'Agent should NOT have accessed the masked output file', + ).toBe(false); + expect(result.toLowerCase()).toContain(secretValue.toLowerCase()); + }, + }); +}); diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 9833af93de..b6b8cb9534 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -178,6 +178,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.DELETE_WORD_FORWARD]: [ { key: 'delete', ctrl: true }, { key: 'delete', alt: true }, + { key: 'd', alt: true }, ], [Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }], [Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }], diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index a668734328..2e40d35260 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -216,8 +216,18 @@ describe('App', () => { const stateWithConfirmingTool = { ...mockUIState, - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], - pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], + pendingGeminiHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], } as UIState; const configWithExperiment = makeFakeConfig(); diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx index 8e0ede2e09..7b81875c7b 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx @@ -53,8 +53,6 @@ export const AlternateBufferQuittingDisplay = () => { terminalWidth={uiState.mainAreaWidth} item={{ ...item, id: 0 }} isPending={true} - activeShellPtyId={uiState.activePtyId} - embeddedShellFocused={uiState.embeddedShellFocused} /> ))} {showPromptedTool && ( diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f41ee20895..8735566641 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -44,8 +44,6 @@ interface HistoryItemDisplayProps { terminalWidth: number; isPending: boolean; commands?: readonly SlashCommand[]; - activeShellPtyId?: number | null; - embeddedShellFocused?: boolean; availableTerminalHeightGemini?: number; } @@ -55,8 +53,6 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth, isPending, commands, - activeShellPtyId, - embeddedShellFocused, availableTerminalHeightGemini, }) => { const settings = useSettings(); @@ -173,12 +169,10 @@ export const HistoryItemDisplay: React.FC = ({ )} {itemForDisplay.type === 'tool_group' && ( diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 8b69a0f187..427a1c821e 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -7,6 +7,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { MainContent } from './MainContent.js'; +import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Box, Text } from 'ink'; import { act, useState, type JSX } from 'react'; @@ -18,6 +19,7 @@ import { type UIState, } from '../contexts/UIStateContext.js'; import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { type IndividualToolCallDisplay } from '../types.js'; // Mock dependencies vi.mock('../contexts/SettingsContext.js', async () => { @@ -76,6 +78,209 @@ vi.mock('./shared/ScrollableList.js', () => ({ SCROLL_TO_ITEM_END: 0, })); +import { theme } from '../semantic-colors.js'; +import { type BackgroundShell } from '../hooks/shellReducer.js'; + +describe('getToolGroupBorderAppearance', () => { + const mockBackgroundShells = new Map(); + const activeShellPtyId = 123; + + it('returns default empty values for non-tool_group items', () => { + const item = { type: 'user' as const, text: 'Hello', id: 1 }; + const result = getToolGroupBorderAppearance( + item, + null, + false, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ borderColor: '', borderDimColor: false }); + }); + + it('inspects only the last pending tool_group item if current has no tools', () => { + const item = { type: 'tool_group' as const, tools: [], id: 1 }; + const pendingItems = [ + { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: 'some_tool', + description: '', + status: CoreToolCallStatus.Executing, + ptyId: undefined, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + }, + { + type: 'tool_group' as const, + tools: [ + { + callId: '2', + name: 'other_tool', + description: '', + status: CoreToolCallStatus.Success, + ptyId: undefined, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + }, + ]; + + // Only the last item (Success) should be inspected, so hasPending = false. + // The previous item was Executing (pending) but it shouldn't be counted. + const result = getToolGroupBorderAppearance( + item, + null, + false, + pendingItems, + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.border.default, + borderDimColor: false, + }); + }); + + it('returns default border for completed normal tools', () => { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: 'some_tool', + description: '', + status: CoreToolCallStatus.Success, + ptyId: undefined, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + id: 1, + }; + const result = getToolGroupBorderAppearance( + item, + null, + false, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.border.default, + borderDimColor: false, + }); + }); + + it('returns warning border for pending normal tools', () => { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: 'some_tool', + description: '', + status: CoreToolCallStatus.Executing, + ptyId: undefined, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + id: 1, + }; + const result = getToolGroupBorderAppearance( + item, + null, + false, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.status.warning, + borderDimColor: true, + }); + }); + + it('returns symbol border for executing shell commands', () => { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: SHELL_COMMAND_NAME, + description: '', + status: CoreToolCallStatus.Executing, + ptyId: activeShellPtyId, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + id: 1, + }; + // While executing shell commands, it's dim false, border symbol + const result = getToolGroupBorderAppearance( + item, + activeShellPtyId, + true, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.ui.symbol, + borderDimColor: false, + }); + }); + + it('returns symbol border and dims color for background executing shell command when another shell is active', () => { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: SHELL_COMMAND_NAME, + description: '', + status: CoreToolCallStatus.Executing, + ptyId: 456, // Different ptyId, not active + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + id: 1, + }; + const result = getToolGroupBorderAppearance( + item, + activeShellPtyId, + false, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.ui.symbol, + borderDimColor: true, + }); + }); + + it('handles empty tools with active shell turn (isCurrentlyInShellTurn)', () => { + const item = { type: 'tool_group' as const, tools: [], id: 1 }; + + // active shell turn + const result = getToolGroupBorderAppearance( + item, + activeShellPtyId, + true, + [], + mockBackgroundShells, + ); + // Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true + // so it counts as pending shell. + expect(result.borderColor).toEqual(theme.ui.symbol); + // It shouldn't be dim because there are no tools to say it isEmbeddedShellFocused = false + expect(result.borderDimColor).toBe(false); + }); +}); + describe('MainContent', () => { const defaultMockUiState = { history: [ @@ -258,7 +463,7 @@ describe('MainContent', () => { history: [], pendingHistoryItems: [ { - type: 'tool_group' as const, + type: 'tool_group', id: 1, tools: [ { diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 1dcc32ffd4..cba57756e3 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -88,8 +88,6 @@ export const MainContent = () => { terminalWidth={mainAreaWidth} item={{ ...item, id: 0 }} isPending={true} - activeShellPtyId={uiState.activePtyId} - embeddedShellFocused={uiState.embeddedShellFocused} /> ))} {showConfirmationQueue && confirmingTool && ( @@ -103,8 +101,6 @@ export const MainContent = () => { isAlternateBuffer, availableTerminalHeight, mainAreaWidth, - uiState.activePtyId, - uiState.embeddedShellFocused, showConfirmationQueue, confirmingTool, ], diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index d4a0a11d6e..6975f757aa 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -14,5 +14,9 @@ interface SessionSummaryDisplayProps { export const SessionSummaryDisplay: React.FC = ({ duration, }) => ( - + ); diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index f901b6f9d2..3b42512424 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -178,7 +178,7 @@ const ModelUsageTable: React.FC<{ : `Model Usage`; return ( - + {/* Header */} @@ -379,6 +379,7 @@ interface StatsDisplayProps { duration: string; title?: string; quotas?: RetrieveUserQuotaResponse; + footer?: string; selectedAuthType?: string; userEmail?: string; tier?: string; @@ -390,6 +391,7 @@ export const StatsDisplay: React.FC = ({ duration, title, quotas, + footer, selectedAuthType, userEmail, tier, @@ -433,6 +435,13 @@ export const StatsDisplay: React.FC = ({ ); }; + const renderFooter = () => { + if (!footer) { + return null; + } + return {footer}; + }; + return ( = ({ pooledLimit={pooledLimit} pooledResetTime={pooledResetTime} /> + {renderFooter()} ); }; diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 6e0bb87136..d1e855b5c3 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Box } from 'ink'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; -import { StreamingState } from '../types.js'; +import { StreamingState, ToolCallStatus } from '../types.js'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; @@ -223,6 +223,58 @@ describe('ToolConfirmationQueue', () => { expect(lastFrame()).toMatchSnapshot(); }); + it('provides more height for ask_user by subtracting less overhead', async () => { + const confirmingTool = { + tool: { + callId: 'call-1', + name: 'ask_user', + description: 'ask user', + status: ToolCallStatus.Confirming, + confirmationDetails: { + type: 'ask_user' as const, + questions: [ + { + type: 'choice', + header: 'Height Test', + question: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6', + options: [{ label: 'Option 1', description: 'Desc' }], + }, + ], + }, + }, + index: 1, + total: 1, + }; + + const { lastFrame } = renderWithProviders( + , + { + config: mockConfig, + uiState: { + terminalWidth: 80, + terminalHeight: 40, + availableTerminalHeight: 20, + constrainHeight: true, + streamingState: StreamingState.WaitingForConfirmation, + }, + }, + ); + + // Calculation: + // availableTerminalHeight: 20 -> maxHeight: 19 (20-1) + // hideToolIdentity is true for ask_user -> subtracts 4 instead of 6 + // availableContentHeight = 19 - 4 = 15 + // ToolConfirmationMessage handlesOwnUI=true -> returns full 15 + // AskUserDialog uses 15 lines to render its multi-line question and options. + await waitFor(() => { + expect(lastFrame()).toContain('Line 6'); + expect(lastFrame()).not.toContain('lines hidden'); + }); + expect(lastFrame()).toMatchSnapshot(); + }); + it('does not render expansion hint when constrainHeight is false', () => { const longDiff = 'line\n'.repeat(50); const confirmingTool = { diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index 52cba7e0d7..e3c18e0231 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -60,6 +60,12 @@ export const ToolConfirmationQueue: React.FC = ({ ? Math.max(uiAvailableHeight - 1, 4) : Math.floor(terminalHeight * 0.5); + const isRoutine = + tool.confirmationDetails?.type === 'ask_user' || + tool.confirmationDetails?.type === 'exit_plan_mode'; + const borderColor = isRoutine ? theme.status.success : theme.status.warning; + const hideToolIdentity = isRoutine; + // 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 @@ -67,15 +73,9 @@ export const ToolConfirmationQueue: React.FC = ({ // - 2 lines for Tool Identity (text + margin) const availableContentHeight = constrainHeight && !isAlternateBuffer - ? Math.max(maxHeight - 6, 4) + ? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4) : undefined; - const isRoutine = - tool.confirmationDetails?.type === 'ask_user' || - tool.confirmationDetails?.type === 'exit_plan_mode'; - const borderColor = isRoutine ? theme.status.success : theme.status.warning; - const hideToolIdentity = isRoutine; - return ( diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index acaa85ea14..63c7507e5a 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -17,12 +17,13 @@ exports[` > renders the summary display with a title 1` │ » API Time: 50.2s (100.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ │ │ Model Usage │ │ Model Reqs Input Tokens Cache Reads Output Tokens │ │ ──────────────────────────────────────────────────────────────────────────── │ │ gemini-2.5-pro 10 500 500 2,000 │ │ │ │ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │ +│ │ +│ Tip: Resume a previous session using gemini --resume or /resume │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap index aa6c52b2a4..3cd067db6f 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -112,11 +112,11 @@ exports[` > Conditional Rendering Tests > hides Efficiency secti │ » API Time: 100ms (100.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ │ │ Model Usage │ │ Model Reqs Input Tokens Cache Reads Output Tokens │ │ ──────────────────────────────────────────────────────────────────────────── │ │ gemini-2.5-pro 1 100 0 100 │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -155,7 +155,6 @@ exports[` > Quota Display > renders pooled quota information for │ » API Time: 0s (0.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ │ │ auto Usage │ │ 65% usage remaining │ │ Usage limit: 1,100 │ @@ -166,6 +165,7 @@ exports[` > Quota Display > renders pooled quota information for │ ──────────────────────────────────────────────────────────── │ │ gemini-2.5-pro - │ │ gemini-2.5-flash - │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -185,11 +185,11 @@ exports[` > Quota Display > renders quota information for unused │ » API Time: 0s (0.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ │ │ Model Usage │ │ Model Reqs Usage remaining │ │ ──────────────────────────────────────────────────────────── │ │ gemini-2.5-flash - 50.0% resets in 2h │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -209,11 +209,11 @@ exports[` > Quota Display > renders quota information when quota │ » API Time: 100ms (100.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ │ │ Model Usage │ │ Model Reqs Usage remaining │ │ ──────────────────────────────────────────────────────────── │ │ gemini-2.5-pro 1 75.0% resets in 1h 30m │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -271,7 +271,6 @@ exports[` > renders a table with two models correctly 1`] = ` │ » API Time: 19.5s (100.0%) │ │ » Tool Time: 0s (0.0%) │ │ │ -│ │ │ Model Usage │ │ Model Reqs Input Tokens Cache Reads Output Tokens │ │ ──────────────────────────────────────────────────────────────────────────── │ @@ -279,6 +278,7 @@ exports[` > renders a table with two models correctly 1`] = ` │ gemini-2.5-flash 5 15,000 10,000 15,000 │ │ │ │ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -299,13 +299,13 @@ exports[` > renders all sections when all data is present 1`] = │ » API Time: 100ms (44.8%) │ │ » Tool Time: 123ms (55.2%) │ │ │ -│ │ │ Model Usage │ │ Model Reqs Input Tokens Cache Reads Output Tokens │ │ ──────────────────────────────────────────────────────────────────────────── │ │ gemini-2.5-pro 1 50 50 100 │ │ │ │ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap index aad58b92a7..f8ba499abd 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -40,6 +40,25 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe ╰──────────────────────────────────────────────────────────────────────────────╯" `; +exports[`ToolConfirmationQueue > provides more height for ask_user by subtracting less overhead 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Answer Questions │ +│ │ +│ Line 1 │ +│ Line 2 │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 │ +│ Line 6 │ +│ │ +│ ● 1. Option 1 │ +│ Desc │ +│ 2. Enter a custom value │ +│ │ +│ Enter to select · ↑/↓ to navigate · Esc to cancel │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + exports[`ToolConfirmationQueue > renders AskUser tool confirmation with Success color 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Answer Questions │ diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index c698445f8f..c1b2ce53b3 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -88,20 +88,16 @@ describe('', () => { CoreToolCallStatus.Executing, ); updateStatus = setStatus; - return ( - - ); + return ; }; const { lastFrame } = renderWithProviders(, { uiActions, - uiState: { streamingState: StreamingState.Idle }, + uiState: { + streamingState: StreamingState.Idle, + embeddedShellFocused: true, + activePtyId: 1, + }, }); // Verify it is initially focused @@ -143,21 +139,29 @@ describe('', () => { 'renders in Alternate Buffer mode while focused', { status: CoreToolCallStatus.Executing, - embeddedShellFocused: true, - activeShellPtyId: 1, ptyId: 1, }, - { useAlternateBuffer: true }, + { + useAlternateBuffer: true, + uiState: { + embeddedShellFocused: true, + activePtyId: 1, + }, + }, ], [ 'renders in Alternate Buffer mode while unfocused', { status: CoreToolCallStatus.Executing, - embeddedShellFocused: false, - activeShellPtyId: 1, ptyId: 1, }, - { useAlternateBuffer: true }, + { + useAlternateBuffer: true, + uiState: { + embeddedShellFocused: false, + activePtyId: 1, + }, + }, ], ])('%s', async (_, props, options) => { const { lastFrame } = renderShell(props, options); @@ -199,12 +203,16 @@ describe('', () => { resultDisplay: LONG_OUTPUT, renderOutputAsMarkdown: false, availableTerminalHeight, - activeShellPtyId: 1, - ptyId: focused ? 1 : 2, + ptyId: 1, status: CoreToolCallStatus.Executing, - embeddedShellFocused: focused, }, - { useAlternateBuffer: true }, + { + useAlternateBuffer: true, + uiState: { + activePtyId: focused ? 1 : 2, + embeddedShellFocused: focused, + }, + }, ); await waitFor(() => { diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index cb6f27b317..50af3bc1e6 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -29,9 +29,9 @@ import { import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; +import { useUIState } from '../../contexts/UIStateContext.js'; + export interface ShellToolMessageProps extends ToolMessageProps { - activeShellPtyId?: number | null; - embeddedShellFocused?: boolean; config?: Config; } @@ -52,10 +52,6 @@ export const ShellToolMessage: React.FC = ({ renderOutputAsMarkdown = true, - activeShellPtyId, - - embeddedShellFocused, - ptyId, config, @@ -66,6 +62,7 @@ export const ShellToolMessage: React.FC = ({ borderDimColor, }) => { + const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); const isThisShellFocused = checkIsShellFocused( name, diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 13feb1682f..42642d66f9 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -235,6 +235,10 @@ export const ToolConfirmationMessage: React.FC< return undefined; } + if (handlesOwnUI) { + return availableTerminalHeight; + } + // Calculate the vertical space (in lines) consumed by UI elements // surrounding the main body content. const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom). @@ -253,7 +257,7 @@ export const ToolConfirmationMessage: React.FC< 1; // Reserve one line for 'ShowMoreLines' hint return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); - }, [availableTerminalHeight, getOptions]); + }, [availableTerminalHeight, getOptions, handlesOwnUI]); const { question, bodyContent, options } = useMemo(() => { let bodyContent: React.ReactNode | null = null; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index deef0cf91f..b02b34ec4e 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -7,7 +7,11 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; -import type { IndividualToolCallDisplay } from '../../types.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../../types.js'; import { Scrollable } from '../shared/Scrollable.js'; import { makeFakeConfig, @@ -40,10 +44,17 @@ describe('', () => { }); const baseProps = { - groupId: 1, terminalWidth: 80, }; + const createItem = ( + tools: IndividualToolCallDisplay[], + ): HistoryItem | HistoryItemWithoutId => ({ + id: 1, + type: 'tool_group', + tools, + }); + const baseMockConfig = makeFakeConfig({ model: 'gemini-pro', targetDir: os.tmpdir(), @@ -56,12 +67,18 @@ describe('', () => { describe('Golden Snapshots', () => { it('renders single successful tool call', () => { const toolCalls = [createToolCall()]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -81,9 +98,10 @@ describe('', () => { }, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig }, ); @@ -113,13 +131,19 @@ describe('', () => { status: CoreToolCallStatus.Error, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -153,13 +177,19 @@ describe('', () => { status: CoreToolCallStatus.Scheduled, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -188,16 +218,23 @@ describe('', () => { resultDisplay: 'More output here', }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -213,16 +250,23 @@ describe('', () => { 'This is a very long description that might cause wrapping issues', }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -231,12 +275,19 @@ describe('', () => { }); it('renders empty tool calls array', () => { + const toolCalls: IndividualToolCallDisplay[] = []; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: [] }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [], + }, + ], }, }, ); @@ -260,14 +311,20 @@ describe('', () => { resultDisplay: 'line1\nline2', }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -285,12 +342,18 @@ describe('', () => { outputFile: '/path/to/output.txt', }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -307,6 +370,7 @@ describe('', () => { resultDisplay: 'line1\nline2\nline3\nline4\nline5', }), ]; + const item1 = createItem(toolCalls1); const toolCalls2 = [ createToolCall({ callId: '2', @@ -315,18 +379,33 @@ describe('', () => { resultDisplay: 'line1', }), ]; + const item2 = createItem(toolCalls2); const { lastFrame, unmount } = renderWithProviders( - - + + , { config: baseMockConfig, uiState: { pendingHistoryItems: [ - { type: 'tool_group', tools: toolCalls1 }, - { type: 'tool_group', tools: toolCalls2 }, + { + type: 'tool_group', + tools: toolCalls1, + }, + { + type: 'tool_group', + tools: toolCalls2, + }, ], }, }, @@ -344,12 +423,18 @@ describe('', () => { status: CoreToolCallStatus.Success, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -366,12 +451,18 @@ describe('', () => { status: CoreToolCallStatus.Success, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -396,16 +487,23 @@ describe('', () => { resultDisplay: '', // No result }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -453,9 +551,10 @@ describe('', () => { resultDisplay, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig }, ); @@ -481,9 +580,10 @@ describe('', () => { status: CoreToolCallStatus.Scheduled, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig }, ); @@ -502,10 +602,12 @@ describe('', () => { status: CoreToolCallStatus.Executing, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( , @@ -540,9 +642,10 @@ describe('', () => { approvalMode: mode, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig }, ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 18179b6a92..4c8abc3189 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -7,27 +7,27 @@ import type React from 'react'; import { useMemo } from 'react'; import { Box, Text } from 'ink'; -import type { IndividualToolCallDisplay } from '../../types.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../../types.js'; import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; -import { isShellTool, isThisShellFocused } from './ToolShared.js'; -import { - CoreToolCallStatus, - shouldHideToolCall, -} from '@google/gemini-cli-core'; +import { isShellTool } from './ToolShared.js'; +import { shouldHideToolCall } from '@google/gemini-cli-core'; import { ShowMoreLines } from '../ShowMoreLines.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; interface ToolGroupMessageProps { - groupId: number; + item: HistoryItem | HistoryItemWithoutId; toolCalls: IndividualToolCallDisplay[]; availableTerminalHeight?: number; terminalWidth: number; - activeShellPtyId?: number | null; - embeddedShellFocused?: boolean; onShellInputSubmit?: (input: string) => void; borderTop?: boolean; borderBottom?: boolean; @@ -37,11 +37,10 @@ interface ToolGroupMessageProps { const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4; export const ToolGroupMessage: React.FC = ({ + item, toolCalls: allToolCalls, availableTerminalHeight, terminalWidth, - activeShellPtyId, - embeddedShellFocused, borderTop: borderTopOverride, borderBottom: borderBottomOverride, }) => { @@ -61,7 +60,31 @@ export const ToolGroupMessage: React.FC = ({ ); const config = useConfig(); - const { constrainHeight } = useUIState(); + const { + constrainHeight, + activePtyId, + embeddedShellFocused, + backgroundShells, + pendingHistoryItems, + } = useUIState(); + + const { borderColor, borderDimColor } = useMemo( + () => + getToolGroupBorderAppearance( + item, + activePtyId, + embeddedShellFocused, + pendingHistoryItems, + backgroundShells, + ), + [ + item, + activePtyId, + embeddedShellFocused, + pendingHistoryItems, + backgroundShells, + ], + ); // 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. @@ -80,31 +103,6 @@ export const ToolGroupMessage: React.FC = ({ [toolCalls], ); - const isEmbeddedShellFocused = visibleToolCalls.some((t) => - isThisShellFocused( - t.name, - t.status, - t.ptyId, - activeShellPtyId, - embeddedShellFocused, - ), - ); - - const hasPending = !visibleToolCalls.every( - (t) => t.status === CoreToolCallStatus.Success, - ); - - const isShellCommand = toolCalls.some((t) => isShellTool(t.name)); - const borderColor = - (isShellCommand && hasPending) || isEmbeddedShellFocused - ? theme.ui.symbol - : hasPending - ? theme.status.warning - : theme.border.default; - - const borderDimColor = - hasPending && (!isShellCommand || !isEmbeddedShellFocused); - const staticHeight = /* border */ 2 + /* marginBottom */ 1; // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), @@ -175,12 +173,7 @@ export const ToolGroupMessage: React.FC = ({ width={contentWidth} > {isShellToolCall ? ( - + ) : ( )} diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index bc805d1f1c..dd3184a19c 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -35,7 +35,7 @@ describe('ToolResultDisplay Overflow', () => { const { lastFrame } = renderWithProviders( { data={['item1']} renderItem={() => ( @@ -165,7 +165,7 @@ describe('ToolMessage Sticky Header Regression', () => { data={['item1']} renderItem={() => ( diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index d31b1e4fbd..217f5182bb 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -141,6 +141,7 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record = { '\u00B5': 'm', // "µ" toggle markup view '\u03A9': 'z', // "Ω" Option+z '\u00B8': 'Z', // "¸" Option+Shift+z + '\u2202': 'd', // "∂" delete word forward }; function nonKeyboardEventFilter( diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 9e3a056edd..eab3a82962 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -1286,7 +1286,9 @@ describe('handleAtCommand', () => { // Assert // It SHOULD be called for the tool_group expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ type: 'tool_group' }), + expect.objectContaining({ + type: 'tool_group', + }), 999, ); @@ -1343,7 +1345,9 @@ describe('handleAtCommand', () => { }); expect(containsResourceText).toBe(true); expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ type: 'tool_group' }), + expect.objectContaining({ + type: 'tool_group', + }), expect.any(Number), ); }); diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index d921651e51..bd8718b9bd 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -23,10 +23,15 @@ import { */ export function mapToDisplay( toolOrTools: ToolCall[] | ToolCall, - options: { borderTop?: boolean; borderBottom?: boolean } = {}, + options: { + borderTop?: boolean; + borderBottom?: boolean; + borderColor?: string; + borderDimColor?: boolean; + } = {}, ): HistoryItemToolGroup { const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools]; - const { borderTop, borderBottom } = options; + const { borderTop, borderBottom, borderColor, borderDimColor } = options; const toolDisplays = toolCalls.map((call): IndividualToolCallDisplay => { let description: string; @@ -104,5 +109,7 @@ export function mapToDisplay( tools: toolDisplays, borderTop, borderBottom, + borderColor, + borderDimColor, }; } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index eb94b2f51c..54c0c2231f 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -44,6 +44,7 @@ import type { Part, PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { SlashCommandProcessorResult } from '../types.js'; import { MessageType, StreamingState } from '../types.js'; + import type { LoadedSettings } from '../../config/settings.js'; // --- MOCKS --- diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index f23574858f..5fc7d628ac 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -78,6 +78,8 @@ import { type TrackedWaitingToolCall, type TrackedExecutingToolCall, } from './useToolScheduler.js'; +import { theme } from '../semantic-colors.js'; +import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { useSessionStats } from '../contexts/SessionContext.js'; @@ -250,6 +252,8 @@ export const useGeminiStream = ( mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], { borderTop: isFirstToolInGroupRef.current, borderBottom: true, + borderColor: theme.border.default, + borderDimColor: false, }), ); } @@ -290,6 +294,45 @@ export const useGeminiStream = ( getPreferredEditor, ); + const activeToolPtyId = useMemo(() => { + const executingShellTool = toolCalls.find( + (tc) => + tc.status === 'executing' && tc.request.name === 'run_shell_command', + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid; + }, [toolCalls]); + + const onExec = useCallback(async (done: Promise) => { + setIsResponding(true); + await done; + setIsResponding(false); + }, []); + + const { + handleShellCommand, + activeShellPtyId, + lastShellOutputTime, + backgroundShellCount, + isBackgroundShellVisible, + toggleBackgroundShell, + backgroundCurrentShell, + registerBackgroundShell, + dismissBackgroundShell, + backgroundShells, + } = useShellCommandProcessor( + addItem, + setPendingHistoryItem, + onExec, + onDebugMessage, + config, + geminiClient, + setShellInputFocused, + terminalWidth, + terminalHeight, + activeToolPtyId, + ); + const streamingState = useMemo( () => calculateStreamingState(isResponding, toolCalls), [isResponding, toolCalls], @@ -347,6 +390,13 @@ export const useGeminiStream = ( const historyItem = mapTrackedToolCallsToDisplay(tc, { borderTop: isFirst, borderBottom: isLastInBatch, + ...getToolGroupBorderAppearance( + { type: 'tool_group', tools: toolCalls }, + activeShellPtyId, + !!isShellFocused, + [], + backgroundShells, + ), }); addItem(historyItem); isFirst = false; @@ -362,6 +412,9 @@ export const useGeminiStream = ( setPushedToolCallIds, setIsFirstToolInGroup, addItem, + activeShellPtyId, + isShellFocused, + backgroundShells, ]); const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { @@ -371,11 +424,20 @@ export const useGeminiStream = ( const items: HistoryItemWithoutId[] = []; + const appearance = getToolGroupBorderAppearance( + { type: 'tool_group', tools: toolCalls }, + activeShellPtyId, + !!isShellFocused, + [], + backgroundShells, + ); + if (remainingTools.length > 0) { items.push( mapTrackedToolCallsToDisplay(remainingTools, { borderTop: pushedToolCallIds.size === 0, borderBottom: false, // Stay open to connect with the slice below + ...appearance, }), ); } @@ -423,20 +485,18 @@ export const useGeminiStream = ( tools: [] as IndividualToolCallDisplay[], borderTop: false, borderBottom: true, + ...appearance, }); } return items; - }, [toolCalls, pushedToolCallIds]); - - const activeToolPtyId = useMemo(() => { - const executingShellTool = toolCalls.find( - (tc) => - tc.status === 'executing' && tc.request.name === 'run_shell_command', - ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid; - }, [toolCalls]); + }, [ + toolCalls, + pushedToolCallIds, + activeShellPtyId, + isShellFocused, + backgroundShells, + ]); const lastQueryRef = useRef(null); const lastPromptIdRef = useRef(null); @@ -448,36 +508,6 @@ export const useGeminiStream = ( onComplete: (result: { userSelection: 'disable' | 'keep' }) => void; } | null>(null); - const onExec = useCallback(async (done: Promise) => { - setIsResponding(true); - await done; - setIsResponding(false); - }, []); - - const { - handleShellCommand, - activeShellPtyId, - lastShellOutputTime, - backgroundShellCount, - isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - registerBackgroundShell, - dismissBackgroundShell, - backgroundShells, - } = useShellCommandProcessor( - addItem, - setPendingHistoryItem, - onExec, - onDebugMessage, - config, - geminiClient, - setShellInputFocused, - terminalWidth, - terminalHeight, - activeToolPtyId, - ); - const activePtyId = activeShellPtyId || activeToolPtyId; const prevActiveShellPtyIdRef = useRef(null); diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 7a3a077994..b2de83cd8b 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -130,6 +130,7 @@ describe('keyMatchers', () => { positive: [ createKey('delete', { ctrl: true }), createKey('delete', { alt: true }), + createKey('d', { alt: true }), ], negative: [createKey('delete'), createKey('backspace', { ctrl: true })], }, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 8481cca71f..290ab63417 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -221,6 +221,8 @@ export type HistoryItemToolGroup = HistoryItemBase & { tools: IndividualToolCallDisplay[]; borderTop?: boolean; borderBottom?: boolean; + borderColor?: string; + borderDimColor?: boolean; }; export type HistoryItemUserShell = HistoryItemBase & { diff --git a/packages/cli/src/ui/utils/borderStyles.ts b/packages/cli/src/ui/utils/borderStyles.ts new file mode 100644 index 0000000000..b3a0cb52bb --- /dev/null +++ b/packages/cli/src/ui/utils/borderStyles.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { isShellTool } from '../components/messages/ToolShared.js'; +import { theme } from '../semantic-colors.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + HistoryItemToolGroup, + IndividualToolCallDisplay, +} from '../types.js'; +import type { BackgroundShell } from '../hooks/shellReducer.js'; +import type { TrackedToolCall } from '../hooks/useToolScheduler.js'; + +function isTrackedToolCall( + tool: IndividualToolCallDisplay | TrackedToolCall, +): tool is TrackedToolCall { + return 'request' in tool; +} + +/** + * Calculates the border color and dimming state for a tool group message. + */ +export function getToolGroupBorderAppearance( + item: + | HistoryItem + | HistoryItemWithoutId + | { type: 'tool_group'; tools: TrackedToolCall[] }, + activeShellPtyId: number | null | undefined, + embeddedShellFocused: boolean | undefined, + allPendingItems: HistoryItemWithoutId[] = [], + backgroundShells: Map = new Map(), +): { borderColor: string; borderDimColor: boolean } { + if (item.type !== 'tool_group') { + return { borderColor: '', borderDimColor: false }; + } + + // If this item has no tools, it's a closing slice for the current batch. + // We need to look at the last pending item to determine the batch's appearance. + const toolsToInspect: Array = + item.tools.length > 0 + ? item.tools + : allPendingItems + .filter( + (i): i is HistoryItemToolGroup => + i !== null && i !== undefined && i.type === 'tool_group', + ) + .slice(-1) + .flatMap((i) => i.tools); + + const hasPending = toolsToInspect.some((t) => { + if (isTrackedToolCall(t)) { + return ( + t.status !== 'success' && + t.status !== 'error' && + t.status !== 'cancelled' + ); + } else { + return ( + t.status !== CoreToolCallStatus.Success && + t.status !== CoreToolCallStatus.Error && + t.status !== CoreToolCallStatus.Cancelled + ); + } + }); + + const isEmbeddedShellFocused = toolsToInspect.some((t) => { + if (isTrackedToolCall(t)) { + return ( + isShellTool(t.request.name) && + t.status === 'executing' && + t.pid === activeShellPtyId && + !!embeddedShellFocused + ); + } else { + return ( + isShellTool(t.name) && + t.status === CoreToolCallStatus.Executing && + t.ptyId === activeShellPtyId && + !!embeddedShellFocused + ); + } + }); + + const isShellCommand = toolsToInspect.some((t) => { + if (isTrackedToolCall(t)) { + return isShellTool(t.request.name); + } else { + return isShellTool(t.name); + } + }); + + // If we have an active PTY that isn't a background shell, then the current + // pending batch is definitely a shell batch. + const isCurrentlyInShellTurn = + !!activeShellPtyId && !backgroundShells.has(activeShellPtyId); + + const isShell = + isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn); + const isPending = + hasPending || (item.tools.length === 0 && isCurrentlyInShellTurn); + + const isEffectivelyFocused = + isEmbeddedShellFocused || + (item.tools.length === 0 && + isCurrentlyInShellTurn && + !!embeddedShellFocused); + + const borderColor = + (isShell && isPending) || isEffectivelyFocused + ? theme.ui.symbol + : isPending + ? theme.status.warning + : theme.border.default; + + const borderDimColor = isPending && (!isShell || !isEffectivelyFocused); + + return { borderColor, borderDimColor }; +}