From 33be30ab0470f4abed8f8fec108cdb73a3bcce01 Mon Sep 17 00:00:00 2001 From: Arnav Raj <121608861+deadsmash07@users.noreply.github.com> Date: Sat, 7 Mar 2026 08:21:08 +0530 Subject: [PATCH 001/202] fix(core): whitelist TERM and COLORTERM in environment sanitization (#20514) Co-authored-by: Sri Pasumarthi Co-authored-by: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com> --- CONTRIBUTING.md | 8 +++---- .../services/environmentSanitization.test.ts | 23 +++++++++++++++++++ .../src/services/environmentSanitization.ts | 4 ++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d442f408f7..d0902b2e97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -320,11 +320,9 @@ npm run lint - Please adhere to the coding style, patterns, and conventions used throughout the existing codebase. -- Consult - [GEMINI.md](https://github.com/google-gemini/gemini-cli/blob/main/GEMINI.md) - (typically found in the project root) for specific instructions related to - AI-assisted development, including conventions for React, comments, and Git - usage. +- Consult [GEMINI.md](../GEMINI.md) (typically found in the project root) for + specific instructions related to AI-assisted development, including + conventions for React, comments, and Git usage. - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. diff --git a/packages/core/src/services/environmentSanitization.test.ts b/packages/core/src/services/environmentSanitization.test.ts index a767bb42c5..63bb6ca5a5 100644 --- a/packages/core/src/services/environmentSanitization.test.ts +++ b/packages/core/src/services/environmentSanitization.test.ts @@ -32,6 +32,29 @@ describe('sanitizeEnvironment', () => { expect(sanitized).toEqual(env); }); + it('should allow TERM and COLORTERM environment variables', () => { + const env = { + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }; + const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS); + expect(sanitized).toEqual(env); + }); + + it('should preserve TERM and COLORTERM even in strict sanitization mode', () => { + const env = { + GITHUB_SHA: 'abc123', + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + SOME_OTHER_VAR: 'value', + }; + const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS); + expect(sanitized).toEqual({ + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }); + }); + it('should allow variables prefixed with GEMINI_CLI_', () => { const env = { GEMINI_CLI_FOO: 'bar', diff --git a/packages/core/src/services/environmentSanitization.ts b/packages/core/src/services/environmentSanitization.ts index 2339a21280..9d35249a8e 100644 --- a/packages/core/src/services/environmentSanitization.ts +++ b/packages/core/src/services/environmentSanitization.ts @@ -69,6 +69,10 @@ export const ALWAYS_ALLOWED_ENVIRONMENT_VARIABLES: ReadonlySet = 'TMPDIR', 'USER', 'LOGNAME', + // Terminal capability variables (needed by editors like vim/emacs and + // interactive commands like top) + 'TERM', + 'COLORTERM', // GitHub Action-related variables 'ADDITIONAL_CONTEXT', 'AVAILABLE_LABELS', From 9a7427197bd2d8df9bf4cf5f723b4932557c5e0e Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:14:44 -0800 Subject: [PATCH 002/202] fix(billing): fix overage strategy lifecycle and settings integration (#21236) --- packages/cli/src/config/config.ts | 1 + .../cli/src/ui/hooks/creditsFlowHandler.ts | 8 --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 + packages/cli/src/ui/hooks/useGeminiStream.ts | 4 ++ .../cli/src/ui/hooks/useQuotaAndFallback.ts | 13 +--- packages/core/src/billing/billing.test.ts | 6 +- packages/core/src/billing/billing.ts | 2 + packages/core/src/code_assist/server.ts | 6 ++ packages/core/src/config/config.test.ts | 59 +++++++++++++++++++ packages/core/src/config/config.ts | 15 +++++ 10 files changed, 92 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 0d81fa39bc..a8c85975e9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -805,6 +805,7 @@ export async function loadCliConfig( fakeResponses: argv.fakeResponses, recordResponses: argv.recordResponses, retryFetchErrors: settings.general?.retryFetchErrors, + billing: settings.billing, maxAttempts: settings.general?.maxAttempts, ptyInfo: ptyInfo?.name, disableLLMCorrection: settings.tools?.disableLLMCorrection, diff --git a/packages/cli/src/ui/hooks/creditsFlowHandler.ts b/packages/cli/src/ui/hooks/creditsFlowHandler.ts index 497d4904e6..91f0997873 100644 --- a/packages/cli/src/ui/hooks/creditsFlowHandler.ts +++ b/packages/cli/src/ui/hooks/creditsFlowHandler.ts @@ -110,7 +110,6 @@ async function handleOverageMenu( isDialogPending, setOverageMenuRequest, setModelSwitchedFromQuotaError, - historyManager, } = args; logBillingEvent( @@ -155,13 +154,6 @@ async function handleOverageMenu( setModelSwitchedFromQuotaError(false); config.setQuotaErrorOccurred(false); config.setOverageStrategy('always'); - historyManager.addItem( - { - type: MessageType.INFO, - text: `Using AI Credits for this request.`, - }, - Date.now(), - ); return 'retry_with_credits'; case 'use_fallback': diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index ec8ea0751a..cfffb28196 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -271,6 +271,7 @@ describe('useGeminiStream', () => { addHistory: vi.fn(), getSessionId: vi.fn(() => 'test-session-id'), setQuotaErrorOccurred: vi.fn(), + resetBillingTurnState: vi.fn(), getQuotaErrorOccurred: vi.fn(() => false), getModel: vi.fn(() => 'gemini-2.5-pro'), getContentGeneratorConfig: vi.fn(() => ({ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 630566090b..b0b4f553a2 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1376,6 +1376,9 @@ export const useGeminiStream = ( if (!options?.isContinuation) { setModelSwitchedFromQuotaError(false); config.setQuotaErrorOccurred(false); + config.resetBillingTurnState( + settings.merged.billing?.overageStrategy, + ); suppressedToolErrorCountRef.current = 0; suppressedToolErrorNoteShownRef.current = false; lowVerbosityFailureNoteShownRef.current = false; @@ -1536,6 +1539,7 @@ export const useGeminiStream = ( setThought, maybeAddSuppressedToolErrorNote, maybeAddLowVerbosityFailureNote, + settings.merged.billing?.overageStrategy, ], ); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index 40b1f68926..533eefa676 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -67,14 +67,6 @@ export function useQuotaAndFallback({ const isDialogPending = useRef(false); const isValidationPending = useRef(false); - // Initial overage strategy from settings; runtime value read from config at call time. - const initialOverageStrategy = - (settings.merged.billing?.overageStrategy as - | 'ask' - | 'always' - | 'never' - | undefined) ?? 'ask'; - // Set up Flash fallback handler useEffect(() => { const fallbackHandler: FallbackModelHandler = async ( @@ -109,9 +101,7 @@ export function useQuotaAndFallback({ ? getResetTimeMessage(error.retryDelayMs) : undefined; - const overageStrategy = - config.getBillingSettings().overageStrategy ?? - initialOverageStrategy; + const overageStrategy = config.getBillingSettings().overageStrategy; const creditsResult = await handleCreditsFlow({ config, @@ -209,7 +199,6 @@ export function useQuotaAndFallback({ userTier, paidTier, settings, - initialOverageStrategy, setModelSwitchedFromQuotaError, onShowAuthSelection, errorVerbosity, diff --git a/packages/core/src/billing/billing.test.ts b/packages/core/src/billing/billing.test.ts index e594061ad6..e38767c418 100644 --- a/packages/core/src/billing/billing.test.ts +++ b/packages/core/src/billing/billing.test.ts @@ -229,14 +229,14 @@ describe('billing', () => { expect(isOverageEligibleModel('gemini-3.1-pro-preview')).toBe(true); }); - it('should return true for gemini-3.1-pro-preview-customtools', () => { + it('should return false for gemini-3.1-pro-preview-customtools', () => { expect(isOverageEligibleModel('gemini-3.1-pro-preview-customtools')).toBe( false, ); }); - it('should return false for gemini-3-flash-preview', () => { - expect(isOverageEligibleModel('gemini-3-flash-preview')).toBe(false); + it('should return true for gemini-3-flash-preview', () => { + expect(isOverageEligibleModel('gemini-3-flash-preview')).toBe(true); }); it('should return false for gemini-2.5-pro', () => { diff --git a/packages/core/src/billing/billing.ts b/packages/core/src/billing/billing.ts index 19afe72e16..64fd791cfd 100644 --- a/packages/core/src/billing/billing.ts +++ b/packages/core/src/billing/billing.ts @@ -12,6 +12,7 @@ import type { import { PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_FLASH_MODEL, } from '../config/models.js'; /** @@ -32,6 +33,7 @@ export const G1_CREDIT_TYPE: CreditType = 'GOOGLE_ONE_AI'; export const OVERAGE_ELIGIBLE_MODELS = new Set([ PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_FLASH_MODEL, ]); /** diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 114fa60092..52b01504d3 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -48,6 +48,7 @@ import { shouldAutoUseCredits, } from '../billing/billing.js'; import { logBillingEvent, logInvalidChunk } from '../telemetry/loggers.js'; +import { coreEvents } from '../utils/events.js'; import { CreditsUsedEvent } from '../telemetry/billingEvents.js'; import { fromCountTokenResponse, @@ -100,6 +101,11 @@ export class CodeAssistServer implements ContentGenerator { const modelIsEligible = isOverageEligibleModel(req.model); const shouldEnableCredits = modelIsEligible && autoUse; + if (shouldEnableCredits && !this.config?.getCreditsNotificationShown()) { + this.config?.setCreditsNotificationShown(true); + coreEvents.emitFeedback('info', 'Using AI Credits for this request.'); + } + const enabledCreditTypes = shouldEnableCredits ? ([G1_CREDIT_TYPE] as string[]) : undefined; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index da30b13377..31e081c350 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -2426,6 +2426,65 @@ describe('Availability Service Integration', () => { config.resetTurn(); expect(spy).toHaveBeenCalled(); }); + + it('resetTurn does NOT reset billing state', () => { + const config = new Config({ + ...baseParams, + billing: { overageStrategy: 'ask' }, + }); + + // Simulate accepting credits mid-turn + config.setOverageStrategy('always'); + config.setCreditsNotificationShown(true); + + // resetTurn should leave billing state intact + config.resetTurn(); + expect(config.getBillingSettings().overageStrategy).toBe('always'); + expect(config.getCreditsNotificationShown()).toBe(true); + }); + + it('resetBillingTurnState resets overageStrategy to configured value', () => { + const config = new Config({ + ...baseParams, + billing: { overageStrategy: 'ask' }, + }); + + config.setOverageStrategy('always'); + expect(config.getBillingSettings().overageStrategy).toBe('always'); + + config.resetBillingTurnState('ask'); + expect(config.getBillingSettings().overageStrategy).toBe('ask'); + }); + + it('resetBillingTurnState preserves overageStrategy when configured as always', () => { + const config = new Config({ + ...baseParams, + billing: { overageStrategy: 'always' }, + }); + + config.resetBillingTurnState('always'); + expect(config.getBillingSettings().overageStrategy).toBe('always'); + }); + + it('resetBillingTurnState defaults to ask when no strategy provided', () => { + const config = new Config({ + ...baseParams, + billing: { overageStrategy: 'always' }, + }); + + config.resetBillingTurnState(); + expect(config.getBillingSettings().overageStrategy).toBe('ask'); + }); + + it('resetBillingTurnState resets creditsNotificationShown', () => { + const config = new Config(baseParams); + + config.setCreditsNotificationShown(true); + expect(config.getCreditsNotificationShown()).toBe(true); + + config.resetBillingTurnState(); + expect(config.getCreditsNotificationShown()).toBe(false); + }); }); describe('Hooks configuration', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e4c0fef6eb..e3201aa521 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -685,6 +685,7 @@ export class Config implements McpContext { fallbackModelHandler?: FallbackModelHandler; validationHandler?: ValidationHandler; private quotaErrorOccurred: boolean = false; + private creditsNotificationShown: boolean = false; private modelQuotas: Map< string, { remaining: number; limit: number; resetTime?: string } @@ -1454,6 +1455,12 @@ export class Config implements McpContext { this.modelAvailabilityService.resetTurn(); } + /** Resets billing state (overageStrategy, creditsNotificationShown) once per user prompt. */ + resetBillingTurnState(overageStrategy?: OverageStrategy): void { + this.creditsNotificationShown = false; + this.billing.overageStrategy = overageStrategy ?? 'ask'; + } + getMaxSessionTurns(): number { return this.maxSessionTurns; } @@ -1466,6 +1473,14 @@ export class Config implements McpContext { return this.quotaErrorOccurred; } + setCreditsNotificationShown(value: boolean): void { + this.creditsNotificationShown = value; + } + + getCreditsNotificationShown(): boolean { + return this.creditsNotificationShown; + } + setQuota( remaining: number | undefined, limit: number | undefined, From 0fd09e01504c53a89fdeea83c6061b3986eb5c99 Mon Sep 17 00:00:00 2001 From: Jeffrey Ying Date: Fri, 6 Mar 2026 22:29:38 -0500 Subject: [PATCH 003/202] fix: expand paste placeholders in TextInput on submit (#19946) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../cli/src/ui/auth/ApiAuthDialog.test.tsx | 13 ++++- .../src/ui/components/AskUserDialog.test.tsx | 43 ++++++++++++++ .../cli/src/ui/components/AskUserDialog.tsx | 27 +++++++-- .../cli/src/ui/components/InputPrompt.tsx | 9 ++- .../ui/components/shared/TextInput.test.tsx | 57 ++++++++++++++++++- .../src/ui/components/shared/TextInput.tsx | 5 +- .../src/ui/components/shared/text-buffer.ts | 16 ++++-- 7 files changed, 149 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index 86d3204b84..da8b43dd20 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -29,9 +29,16 @@ vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); -vi.mock('../components/shared/text-buffer.js', () => ({ - useTextBuffer: vi.fn(), -})); +vi.mock('../components/shared/text-buffer.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../components/shared/text-buffer.js') + >(); + return { + ...actual, + useTextBuffer: vi.fn(), + }; +}); vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: vi.fn(() => ({ diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 1bd29241db..0857306ea8 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -1347,4 +1347,47 @@ describe('AskUserDialog', () => { }); }); }); + + it('expands paste placeholders in multi-select custom option via Done', async () => { + const questions: Question[] = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [{ label: 'TypeScript', description: '' }], + multiSelect: true, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = renderWithProviders( + , + { width: 120 }, + ); + + // Select TypeScript + writeKey(stdin, '\r'); + // Down to Other + writeKey(stdin, '\x1b[B'); + + // Simulate bracketed paste of multi-line text into the custom option + const pastedText = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6'; + const ESC = '\x1b'; + writeKey(stdin, `${ESC}[200~${pastedText}${ESC}[201~`); + + // Down to Done and submit + writeKey(stdin, '\x1b[B'); + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': `TypeScript, ${pastedText}`, + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 488a00b45e..284e4e1df8 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -24,7 +24,10 @@ import { keyMatchers, Command } from '../keyMatchers.js'; import { checkExhaustive } from '@google/gemini-cli-core'; import { TextInput } from './shared/TextInput.js'; import { formatCommand } from '../utils/keybindingUtils.js'; -import { useTextBuffer } from './shared/text-buffer.js'; +import { + useTextBuffer, + expandPastePlaceholders, +} from './shared/text-buffer.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { DialogFooter } from './shared/DialogFooter.js'; @@ -303,10 +306,12 @@ const TextQuestionView: React.FC = ({ const lastTextValueRef = useRef(textValue); useEffect(() => { if (textValue !== lastTextValueRef.current) { - onSelectionChange?.(textValue); + onSelectionChange?.( + expandPastePlaceholders(textValue, buffer.pastedContent), + ); lastTextValueRef.current = textValue; } - }, [textValue, onSelectionChange]); + }, [textValue, onSelectionChange, buffer.pastedContent]); // Handle Ctrl+C to clear all text const handleExtraKeys = useCallback( @@ -589,11 +594,15 @@ const ChoiceQuestionView: React.FC = ({ } }); if (includeCustomOption && customOption.trim()) { - answers.push(customOption.trim()); + const expanded = expandPastePlaceholders( + customOption, + customBuffer.pastedContent, + ); + answers.push(expanded.trim()); } return answers.join(', '); }, - [questionOptions], + [questionOptions, customBuffer.pastedContent], ); // Synchronize selection changes with parent - only when it actually changes @@ -758,7 +767,12 @@ const ChoiceQuestionView: React.FC = ({ } else if (itemValue.type === 'other') { // In single select, selecting other submits it if it has text if (customOptionText.trim()) { - onAnswer(customOptionText.trim()); + onAnswer( + expandPastePlaceholders( + customOptionText, + customBuffer.pastedContent, + ).trim(), + ); } } } @@ -768,6 +782,7 @@ const ChoiceQuestionView: React.FC = ({ selectedIndices, isCustomOptionSelected, customOptionText, + customBuffer.pastedContent, onAnswer, buildAnswerString, ], diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 6f2cd9ab7a..05184838ee 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -15,7 +15,7 @@ import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js'; import { type TextBuffer, logicalPosToOffset, - PASTED_TEXT_PLACEHOLDER_REGEX, + expandPastePlaceholders, getTransformUnderCursor, LARGE_PASTE_LINE_THRESHOLD, LARGE_PASTE_CHAR_THRESHOLD, @@ -346,10 +346,9 @@ export const InputPrompt: React.FC = ({ (submittedValue: string) => { let processedValue = submittedValue; if (buffer.pastedContent) { - // Replace placeholders like [Pasted Text: 6 lines] with actual content - processedValue = processedValue.replace( - PASTED_TEXT_PLACEHOLDER_REGEX, - (match) => buffer.pastedContent[match] || match, + processedValue = expandPastePlaceholders( + processedValue, + buffer.pastedContent, ); } diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx index f12714e288..7e802bbbe3 100644 --- a/packages/cli/src/ui/components/shared/TextInput.test.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -17,7 +17,8 @@ vi.mock('../../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); -vi.mock('./text-buffer.js', () => { +vi.mock('./text-buffer.js', async (importOriginal) => { + const actual = await importOriginal(); const mockTextBuffer = { text: '', lines: [''], @@ -60,6 +61,7 @@ vi.mock('./text-buffer.js', () => { }; return { + ...actual, useTextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), TextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), }; @@ -82,6 +84,7 @@ describe('TextInput', () => { cursor: [0, 0], visualCursor: [0, 0], viewportVisualLines: [''], + pastedContent: {} as Record, handleInput: vi.fn((key) => { if (key.sequence) { buffer.text += key.sequence; @@ -298,6 +301,58 @@ describe('TextInput', () => { unmount(); }); + it('expands paste placeholder to real content on submit', async () => { + const placeholder = '[Pasted Text: 6 lines]'; + const realContent = 'line1\nline2\nline3\nline4\nline5\nline6'; + mockBuffer.setText(placeholder); + mockBuffer.pastedContent = { [placeholder]: realContent }; + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + await act(async () => { + keypressHandler({ + name: 'return', + shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', + }); + }); + await waitUntilReady(); + + expect(onSubmit).toHaveBeenCalledWith(realContent); + unmount(); + }); + + it('submits text unchanged when pastedContent is empty', async () => { + mockBuffer.setText('normal text'); + mockBuffer.pastedContent = {}; + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + await act(async () => { + keypressHandler({ + name: 'return', + shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', + }); + }); + await waitUntilReady(); + + expect(onSubmit).toHaveBeenCalledWith('normal text'); + unmount(); + }); + it('calls onCancel on escape', async () => { vi.useFakeTimers(); const { waitUntilReady, unmount } = render( diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 40f44cda53..8a4745eea7 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -12,6 +12,7 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import chalk from 'chalk'; import { theme } from '../../semantic-colors.js'; import type { TextBuffer } from './text-buffer.js'; +import { expandPastePlaceholders } from './text-buffer.js'; import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; @@ -47,14 +48,14 @@ export function TextInput({ } if (keyMatchers[Command.SUBMIT](key) && onSubmit) { - onSubmit(text); + onSubmit(expandPastePlaceholders(text, buffer.pastedContent)); return true; } const handled = handleInput(key); return handled; }, - [handleInput, onCancel, onSubmit, text], + [handleInput, onCancel, onSubmit, text, buffer.pastedContent], ); useKeypress(handleKeyPress, { isActive: focus, priority: true }); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 71ee40b642..34d757a61b 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -38,6 +38,17 @@ export const LARGE_PASTE_CHAR_THRESHOLD = 500; export const PASTED_TEXT_PLACEHOLDER_REGEX = /\[Pasted Text: \d+ (?:lines|chars)(?: #\d+)?\]/g; +// Replace paste placeholder strings with their actual pasted content. +export function expandPastePlaceholders( + text: string, + pastedContent: Record, +): string { + return text.replace( + PASTED_TEXT_PLACEHOLDER_REGEX, + (match) => pastedContent[match] || match, + ); +} + export type Direction = | 'left' | 'right' @@ -3086,10 +3097,7 @@ export function useTextBuffer({ const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); const filePath = pathMod.join(tmpDir, 'buffer.txt'); // Expand paste placeholders so user sees full content in editor - const expandedText = text.replace( - PASTED_TEXT_PLACEHOLDER_REGEX, - (match) => pastedContent[match] || match, - ); + const expandedText = expandPastePlaceholders(text, pastedContent); fs.writeFileSync(filePath, expandedText, 'utf8'); dispatch({ type: 'create_undo_snapshot' }); From 9455ecd78c3f0990b6ee3dc7955eb7ffea569371 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Fri, 6 Mar 2026 19:45:36 -0800 Subject: [PATCH 004/202] fix(core): add in-memory cache to ChatRecordingService to prevent OOM (#21502) --- .../src/services/chatRecordingService.test.ts | 124 ++++++++++++++++++ .../core/src/services/chatRecordingService.ts | 76 +++++++---- 2 files changed, 177 insertions(+), 23 deletions(-) diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 5aaa0a2538..2b8e8f1977 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -245,6 +245,97 @@ describe('ChatRecordingService', () => { tool: 0, }); }); + + it('should not write to disk when queuing tokens (no last gemini message)', () => { + const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); + + // Clear spy call count after initialize writes the initial file + writeFileSyncSpy.mockClear(); + + // No gemini message recorded yet, so tokens should only be queued + chatRecordingService.recordMessageTokens({ + promptTokenCount: 5, + candidatesTokenCount: 10, + totalTokenCount: 15, + cachedContentTokenCount: 0, + }); + + // writeFileSync should NOT have been called since we only queued + expect(writeFileSyncSpy).not.toHaveBeenCalled(); + + // @ts-expect-error private property + expect(chatRecordingService.queuedTokens).toEqual({ + input: 5, + output: 10, + total: 15, + cached: 0, + thoughts: 0, + tool: 0, + }); + + writeFileSyncSpy.mockRestore(); + }); + + it('should not write to disk when queuing tokens (last message already has tokens)', () => { + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'Response', + model: 'gemini-pro', + }); + + // First recordMessageTokens updates the message and writes to disk + chatRecordingService.recordMessageTokens({ + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + cachedContentTokenCount: 0, + }); + + const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); + writeFileSyncSpy.mockClear(); + + // Second call should only queue, NOT write to disk + chatRecordingService.recordMessageTokens({ + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + cachedContentTokenCount: 0, + }); + + expect(writeFileSyncSpy).not.toHaveBeenCalled(); + writeFileSyncSpy.mockRestore(); + }); + + it('should use in-memory cache and not re-read from disk on subsequent operations', () => { + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'Response', + model: 'gemini-pro', + }); + + const readFileSyncSpy = vi.spyOn(fs, 'readFileSync'); + readFileSyncSpy.mockClear(); + + // These operations should all use the in-memory cache + chatRecordingService.recordMessageTokens({ + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + cachedContentTokenCount: 0, + }); + + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'Another response', + model: 'gemini-pro', + }); + + chatRecordingService.saveSummary('Test summary'); + + // readFileSync should NOT have been called since we use the in-memory cache + expect(readFileSyncSpy).not.toHaveBeenCalled(); + readFileSyncSpy.mockRestore(); + }); }); describe('recordToolCalls', () => { @@ -769,6 +860,39 @@ describe('ChatRecordingService', () => { expect(result[0].text).toBe('Prefix metadata or text'); expect(result[1].functionResponse!.id).toBe(callId); }); + + it('should not write to disk when no tool calls match', () => { + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'Response with no tool calls', + model: 'gemini-pro', + }); + + const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); + writeFileSyncSpy.mockClear(); + + // History with a tool call ID that doesn't exist in the conversation + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + id: 'nonexistent-call-id', + response: { output: 'some content' }, + }, + }, + ], + }, + ]; + + chatRecordingService.updateMessagesFromHistory(history); + + // No tool calls matched, so writeFileSync should NOT have been called + expect(writeFileSyncSpy).not.toHaveBeenCalled(); + writeFileSyncSpy.mockRestore(); + }); }); describe('ENOENT (missing directory) handling', () => { diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 1748ccbe20..cd8d1e53c1 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -128,6 +128,7 @@ export interface ResumedSessionData { export class ChatRecordingService { private conversationFile: string | null = null; private cachedLastConvData: string | null = null; + private cachedConversation: ConversationRecord | null = null; private sessionId: string; private projectHash: string; private kind?: 'main' | 'subagent'; @@ -167,6 +168,7 @@ export class ChatRecordingService { // Clear any cached data to force fresh reads this.cachedLastConvData = null; + this.cachedConversation = null; } else { // Create new session const chatsDir = path.join( @@ -308,17 +310,19 @@ export class ChatRecordingService { tool: respUsageMetadata.toolUsePromptTokenCount ?? 0, total: respUsageMetadata.totalTokenCount ?? 0, }; - this.updateConversation((conversation) => { - const lastMsg = this.getLastMessage(conversation); - // If the last message already has token info, it's because this new token info is for a - // new message that hasn't been recorded yet. - if (lastMsg && lastMsg.type === 'gemini' && !lastMsg.tokens) { - lastMsg.tokens = tokens; - this.queuedTokens = null; - } else { - this.queuedTokens = tokens; - } - }); + const conversation = this.readConversation(); + const lastMsg = this.getLastMessage(conversation); + // If the last message already has token info, it's because this new token info is for a + // new message that hasn't been recorded yet. + if (lastMsg && lastMsg.type === 'gemini' && !lastMsg.tokens) { + lastMsg.tokens = tokens; + this.queuedTokens = null; + this.writeConversation(conversation); + } else { + // Only queue tokens in memory; no disk I/O needed since the + // conversation record itself hasn't changed. + this.queuedTokens = tokens; + } } catch (error) { debugLogger.error( 'Error updating message tokens in chat history.', @@ -427,12 +431,32 @@ export class ChatRecordingService { /** * Loads up the conversation record from disk. + * + * NOTE: The returned object is the live in-memory cache reference. + * Any mutations to it will be visible to all subsequent reads. + * Callers that mutate the result MUST call writeConversation() to + * persist the changes to disk. */ private readConversation(): ConversationRecord { + if (this.cachedConversation) { + return this.cachedConversation; + } try { this.cachedLastConvData = fs.readFileSync(this.conversationFile!, 'utf8'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return JSON.parse(this.cachedLastConvData); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.cachedConversation = JSON.parse(this.cachedLastConvData); + if (!this.cachedConversation) { + // File is corrupt or contains "null". Fallback to an empty conversation. + this.cachedConversation = { + sessionId: this.sessionId, + projectHash: this.projectHash, + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages: [], + kind: this.kind, + }; + } + return this.cachedConversation; } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { @@ -441,7 +465,7 @@ export class ChatRecordingService { } // Placeholder empty conversation if file doesn't exist. - return { + this.cachedConversation = { sessionId: this.sessionId, projectHash: this.projectHash, startTime: new Date().toISOString(), @@ -449,6 +473,7 @@ export class ChatRecordingService { messages: [], kind: this.kind, }; + return this.cachedConversation; } } @@ -464,15 +489,19 @@ export class ChatRecordingService { // Don't write the file yet until there's at least one message. if (conversation.messages.length === 0 && !allowEmpty) return; - // Only write the file if this change would change the file. - if (this.cachedLastConvData !== JSON.stringify(conversation, null, 2)) { - conversation.lastUpdated = new Date().toISOString(); - const newContent = JSON.stringify(conversation, null, 2); - this.cachedLastConvData = newContent; - // Ensure directory exists before writing (handles cases where temp dir was cleaned) - fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true }); - fs.writeFileSync(this.conversationFile, newContent); - } + const newContent = JSON.stringify(conversation, null, 2); + // Skip the disk write if nothing actually changed (e.g. + // updateMessagesFromHistory found no matching tool calls to update). + // Compare before updating lastUpdated so the timestamp doesn't + // cause a false diff. + if (this.cachedLastConvData === newContent) return; + this.cachedConversation = conversation; + conversation.lastUpdated = new Date().toISOString(); + const contentToWrite = JSON.stringify(conversation, null, 2); + this.cachedLastConvData = contentToWrite; + // Ensure directory exists before writing (handles cases where temp dir was cleaned) + fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true }); + fs.writeFileSync(this.conversationFile, contentToWrite); } catch (error) { // Handle disk full (ENOSPC) gracefully - disable recording but allow conversation to continue if ( @@ -482,6 +511,7 @@ export class ChatRecordingService { (error as NodeJS.ErrnoException).code === 'ENOSPC' ) { this.conversationFile = null; + this.cachedConversation = null; debugLogger.warn(ENOSPC_WARNING_MESSAGE); return; // Don't throw - allow the conversation to continue } From e5d58c2b5ab2460ffb91e8681fd31b91de909632 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Fri, 6 Mar 2026 20:20:27 -0800 Subject: [PATCH 005/202] feat(cli): overhaul thinking UI (#18725) --- .../cli/src/ui/components/Composer.test.tsx | 4 +- packages/cli/src/ui/components/Composer.tsx | 6 +- .../ui/components/HistoryItemDisplay.test.tsx | 20 +++ .../src/ui/components/HistoryItemDisplay.tsx | 20 ++- .../ui/components/LoadingIndicator.test.tsx | 27 +++- .../src/ui/components/LoadingIndicator.tsx | 6 +- .../src/ui/components/MainContent.test.tsx | 81 ++++++++++- .../cli/src/ui/components/MainContent.tsx | 95 +++++++++---- .../src/ui/components/StatusDisplay.test.tsx | 2 +- .../__snapshots__/AskUserDialog.test.tsx.snap | 28 ---- .../HistoryItemDisplay.test.tsx.snap | 13 +- ...g-messages-sequentially-correctly.snap.svg | 42 ++++++ .../__snapshots__/MainContent.test.tsx.snap | 43 ++++++ .../messages/ThinkingMessage.test.tsx | 126 ++++++++++++++---- .../components/messages/ThinkingMessage.tsx | 101 +++++++------- ...normalizes-escaped-newline-tokens.snap.svg | 14 ++ ...ader-when-isFirstThinking-is-true.snap.svg | 14 ++ ...de-with-left-border-and-full-text.snap.svg | 14 ++ ...g-messages-sequentially-correctly.snap.svg | 30 +++++ ...vertical-rule-and-Thinking-header.snap.svg | 14 ++ ...description-when-subject-is-empty.snap.svg | 12 ++ .../ThinkingMessage.test.tsx.snap | 99 ++++++++++++-- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 - packages/cli/src/ui/hooks/useGeminiStream.ts | 13 +- .../src/ui/hooks/useSessionBrowser.test.ts | 31 +++++ packages/cli/src/ui/utils/textUtils.test.ts | 6 +- packages/cli/src/utils/sessionUtils.ts | 13 ++ packages/core/src/utils/sessionUtils.test.ts | 37 +++++ packages/core/src/utils/sessionUtils.ts | 35 +++-- 29 files changed, 763 insertions(+), 184 deletions(-) create mode 100644 packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 999b1531f9..9a6155da00 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -374,7 +374,7 @@ describe('Composer', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { - subject: 'Detailed in-history thought', + subject: 'Thinking about code', description: 'Full text is already in history', }, }); @@ -385,7 +385,7 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); - expect(output).toContain('LoadingIndicator: Thinking ...'); + expect(output).toContain('LoadingIndicator: Thinking...'); }); it('hides shortcuts hint while loading', async () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 51c879e772..d30f52dddf 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -239,7 +239,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.currentLoadingPhrase } thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + inlineThinkingMode === 'full' ? 'Thinking...' : undefined } elapsedTime={uiState.elapsedTime} /> @@ -282,7 +282,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.currentLoadingPhrase } thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + inlineThinkingMode === 'full' ? 'Thinking...' : undefined } elapsedTime={uiState.elapsedTime} /> @@ -390,7 +390,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { marginTop={ (showApprovalIndicator || uiState.shellModeActive) && - isNarrow + !isNarrow ? 1 : 0 } diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index f8c251fbfa..a574a9f311 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -290,6 +290,26 @@ describe('', () => { unmount(); }); + it('renders "Thinking..." header when isFirstThinking is true', async () => { + const item: HistoryItem = { + ...baseItem, + type: 'thinking', + thought: { subject: 'Thinking', description: 'test' }, + }; + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings: createMockSettings({ + merged: { ui: { inlineThinkingMode: 'full' } }, + }), + }, + ); + await waitUntilReady(); + + expect(lastFrame()).toContain(' Thinking...'); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); it('does not render thinking item when disabled', async () => { const item: HistoryItem = { ...baseItem, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f40dcf9dc9..9c8d90cd19 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -46,6 +46,8 @@ interface HistoryItemDisplayProps { commands?: readonly SlashCommand[]; availableTerminalHeightGemini?: number; isExpandable?: boolean; + isFirstThinking?: boolean; + isFirstAfterThinking?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -56,16 +58,30 @@ export const HistoryItemDisplay: React.FC = ({ commands, availableTerminalHeightGemini, isExpandable, + isFirstThinking = false, + isFirstAfterThinking = false, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); + const needsTopMarginAfterThinking = + isFirstAfterThinking && inlineThinkingMode !== 'off'; + return ( - + {/* Render standard message types */} {itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && ( - + )} {itemForDisplay.type === 'hint' && ( diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 61cd64d07a..4c4e3053ef 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -258,13 +258,32 @@ describe('', () => { const output = lastFrame(); expect(output).toBeDefined(); if (output) { - expect(output).toContain('๐Ÿ’ฌ'); + // Should NOT contain "Thinking... " prefix because the subject already starts with "Thinking" + expect(output).not.toContain('Thinking... Thinking'); expect(output).toContain('Thinking about something...'); expect(output).not.toContain('and other stuff.'); } unmount(); }); + it('should prepend "Thinking... " if the subject does not start with "Thinking"', async () => { + const props = { + thought: { + subject: 'Planning the response...', + description: 'details', + }, + elapsedTime: 5, + }; + const { lastFrame, unmount, waitUntilReady } = renderWithContext( + , + StreamingState.Responding, + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Thinking... Planning the response...'); + unmount(); + }); + it('should prioritize thought.subject over currentLoadingPhrase', async () => { const props = { thought: { @@ -280,13 +299,13 @@ describe('', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('๐Ÿ’ฌ'); + expect(output).toContain('Thinking... '); expect(output).toContain('This should be displayed'); expect(output).not.toContain('This should not be displayed'); unmount(); }); - it('should not display thought icon for non-thought loading phrases', async () => { + it('should not display thought indicator for non-thought loading phrases', async () => { const { lastFrame, unmount, waitUntilReady } = renderWithContext( ', () => { StreamingState.Responding, ); await waitUntilReady(); - expect(lastFrame()).not.toContain('๐Ÿ’ฌ'); + expect(lastFrame()).not.toContain('Thinking... '); unmount(); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index f9fff9fa9b..eba0a7d8a3 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -58,7 +58,11 @@ export const LoadingIndicator: React.FC = ({ const hasThoughtIndicator = currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE && Boolean(thought?.subject?.trim()); - const thinkingIndicator = hasThoughtIndicator ? '๐Ÿ’ฌ ' : ''; + // Avoid "Thinking... Thinking..." duplication if primaryText already starts with "Thinking" + const thinkingIndicator = + hasThoughtIndicator && !primaryText?.startsWith('Thinking') + ? 'Thinking... ' + : ''; const cancelAndTimerContent = showCancelAndTimer && diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 5ca3cbce31..e0880e624c 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -22,17 +22,19 @@ import { CoreToolCallStatus } from '@google/gemini-cli-core'; import { type IndividualToolCallDisplay } from '../types.js'; // Mock dependencies +const mockUseSettings = vi.fn().mockReturnValue({ + merged: { + ui: { + inlineThinkingMode: 'off', + }, + }, +}); + vi.mock('../contexts/SettingsContext.js', async () => { const actual = await vi.importActual('../contexts/SettingsContext.js'); return { ...actual, - useSettings: () => ({ - merged: { - ui: { - inlineThinkingMode: 'off', - }, - }, - }), + useSettings: () => mockUseSettings(), }; }); @@ -333,6 +335,13 @@ describe('MainContent', () => { beforeEach(() => { vi.mocked(useAlternateBuffer).mockReturnValue(false); + mockUseSettings.mockReturnValue({ + merged: { + ui: { + inlineThinkingMode: 'off', + }, + }, + }); }); afterEach(() => { @@ -570,6 +579,64 @@ describe('MainContent', () => { unmount(); }); + it('renders multiple thinking messages sequentially correctly', async () => { + mockUseSettings.mockReturnValue({ + merged: { + ui: { + inlineThinkingMode: 'expanded', + }, + }, + }); + vi.mocked(useAlternateBuffer).mockReturnValue(true); + + const uiState = { + ...defaultMockUiState, + history: [ + { id: 0, type: 'user' as const, text: 'Plan a solution' }, + { + id: 1, + type: 'thinking' as const, + thought: { + subject: 'Initial analysis', + description: + 'This is a multiple line paragraph for the first thinking message of how the model analyzes the problem.', + }, + }, + { + id: 2, + type: 'thinking' as const, + thought: { + subject: 'Planning execution', + description: + 'This a second multiple line paragraph for the second thinking message explaining the plan in detail so that it wraps around the terminal display.', + }, + }, + { + id: 3, + type: 'thinking' as const, + thought: { + subject: 'Refining approach', + description: + 'And finally a third multiple line paragraph for the third thinking message to refine the solution.', + }, + }, + ], + }; + + const renderResult = renderWithProviders(, { + uiState: uiState as Partial, + }); + await renderResult.waitUntilReady(); + + const output = renderResult.lastFrame(); + expect(output).toContain('Initial analysis'); + expect(output).toContain('Planning execution'); + expect(output).toContain('Refining approach'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); + }); + describe('MainContent Tool Output Height Logic', () => { const testCases = [ { diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 7386a246e7..d7e04bd351 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -62,11 +62,31 @@ export const MainContent = () => { return -1; }, [uiState.history]); + const augmentedHistory = useMemo( + () => + uiState.history.map((item, index) => { + const isExpandable = index > lastUserPromptIndex; + const prevType = + index > 0 ? uiState.history[index - 1]?.type : undefined; + const isFirstThinking = + item.type === 'thinking' && prevType !== 'thinking'; + const isFirstAfterThinking = + item.type !== 'thinking' && prevType === 'thinking'; + + return { + item, + isExpandable, + isFirstThinking, + isFirstAfterThinking, + }; + }), + [uiState.history, lastUserPromptIndex], + ); + const historyItems = useMemo( () => - uiState.history.map((h, index) => { - const isExpandable = index > lastUserPromptIndex; - return ( + augmentedHistory.map( + ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ( { : undefined } availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES} - key={h.id} - item={h} + key={item.id} + item={item} isPending={false} commands={uiState.slashCommands} isExpandable={isExpandable} + isFirstThinking={isFirstThinking} + isFirstAfterThinking={isFirstAfterThinking} /> - ); - }), + ), + ), [ - uiState.history, + augmentedHistory, mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, uiState.constrainHeight, - lastUserPromptIndex, ], ); @@ -106,18 +127,31 @@ export const MainContent = () => { const pendingItems = useMemo( () => ( - {pendingHistoryItems.map((item, i) => ( - - ))} + {pendingHistoryItems.map((item, i) => { + const prevType = + i === 0 + ? uiState.history.at(-1)?.type + : pendingHistoryItems[i - 1]?.type; + const isFirstThinking = + item.type === 'thinking' && prevType !== 'thinking'; + const isFirstAfterThinking = + item.type !== 'thinking' && prevType === 'thinking'; + + return ( + + ); + })} {showConfirmationQueue && confirmingTool && ( )} @@ -130,20 +164,25 @@ export const MainContent = () => { mainAreaWidth, showConfirmationQueue, confirmingTool, + uiState.history, ], ); const virtualizedData = useMemo( () => [ { type: 'header' as const }, - ...uiState.history.map((item, index) => ({ - type: 'history' as const, - item, - isExpandable: index > lastUserPromptIndex, - })), + ...augmentedHistory.map( + ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({ + type: 'history' as const, + item, + isExpandable, + isFirstThinking, + isFirstAfterThinking, + }), + ), { type: 'pending' as const }, ], - [uiState.history, lastUserPromptIndex], + [augmentedHistory], ); const renderItem = useCallback( @@ -171,6 +210,8 @@ export const MainContent = () => { isPending={false} commands={uiState.slashCommands} isExpandable={item.isExpandable} + isFirstThinking={item.isFirstThinking} + isFirstAfterThinking={item.isFirstAfterThinking} /> ); } else { diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 4e0402820f..fcb66ea0b2 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render } from '../../test-utils/render.js'; import { Text } from 'ink'; import { StatusDisplay } from './StatusDisplay.js'; diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index 2e115ef12c..06f509f1f6 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -115,20 +115,6 @@ Review your answers: Tests โ†’ (not answered) Docs โ†’ (not answered) -Enter to submit ยท / to edit answers ยท Esc to cancel -" -`; - -exports[`AskUserDialog > allows navigating to Review tab and back 2`] = ` -"โ† โ–ก Tests โ”‚ โ–ก Docs โ”‚ โ‰ก Review โ†’ - -Review your answers: - -โš  You have 2 unanswered questions - -Tests โ†’ (not answered) -Docs โ†’ (not answered) - Enter to submit ยท Tab/Shift+Tab to edit answers ยท Esc to cancel " `; @@ -212,20 +198,6 @@ Review your answers: License โ†’ (not answered) README โ†’ (not answered) -Enter to submit ยท / to edit answers ยท Esc to cancel -" -`; - -exports[`AskUserDialog > shows warning for unanswered questions on Review tab 2`] = ` -"โ† โ–ก License โ”‚ โ–ก README โ”‚ โ‰ก Review โ†’ - -Review your answers: - -โš  You have 2 unanswered questions - -License โ†’ (not answered) -README โ†’ (not answered) - Enter to submit ยท Tab/Shift+Tab to edit answers ยท Esc to cancel " `; diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap index b1784dc10d..d237b30f99 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -388,8 +388,17 @@ exports[` > renders InfoMessage for "info" type with multi " `; -exports[` > thinking items > renders thinking item when enabled 1`] = ` -" Thinking +exports[` > thinking items > renders "Thinking..." header when isFirstThinking is true 1`] = ` +" Thinking... + โ”‚ + โ”‚ Thinking + โ”‚ test +" +`; + +exports[` > thinking items > renders thinking item when enabled 1`] = ` +" โ”‚ + โ”‚ Thinking โ”‚ test " `; diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg new file mode 100644 index 0000000000..558118cdfb --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg @@ -0,0 +1,42 @@ + + + + + ScrollableList + AppHeader(full) + + โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + + + > + + Plan a solution + + + โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ + Thinking... + โ”‚ + โ”‚ + Initial analysis + โ”‚ + This is a multiple line paragraph for the first thinking message of how the model analyzes the + โ”‚ + problem. + โ”‚ + โ”‚ + Planning execution + โ”‚ + This a second multiple line paragraph for the second thinking message explaining the plan in + โ”‚ + detail so that it wraps around the terminal display. + โ”‚ + โ”‚ + Refining approach + โ”‚ + And finally a third multiple line paragraph for the third thinking message to refine the + โ”‚ + solution. + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index 5f0c073d7a..74acc6985d 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -151,3 +151,46 @@ AppHeader(full) Gemini message 2 " `; + +exports[`MainContent > renders multiple thinking messages sequentially correctly 1`] = ` +"ScrollableList +AppHeader(full) +โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + > Plan a solution +โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ + Thinking... + โ”‚ + โ”‚ Initial analysis + โ”‚ This is a multiple line paragraph for the first thinking message of how the model analyzes the + โ”‚ problem. + โ”‚ + โ”‚ Planning execution + โ”‚ This a second multiple line paragraph for the second thinking message explaining the plan in + โ”‚ detail so that it wraps around the terminal display. + โ”‚ + โ”‚ Refining approach + โ”‚ And finally a third multiple line paragraph for the third thinking message to refine the + โ”‚ solution. +" +`; + +exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = ` +"ScrollableList +AppHeader(full) +โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + > Plan a solution +โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ + Thinking... + โ”‚ + โ”‚ Initial analysis + โ”‚ This is a multiple line paragraph for the first thinking message of how the model analyzes the + โ”‚ problem. + โ”‚ + โ”‚ Planning execution + โ”‚ This a second multiple line paragraph for the second thinking message explaining the plan in + โ”‚ detail so that it wraps around the terminal display. + โ”‚ + โ”‚ Refining approach + โ”‚ And finally a third multiple line paragraph for the third thinking message to refine the + โ”‚ solution." +`; diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index a27923c014..1499d285f7 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -7,84 +7,156 @@ import { describe, it, expect } from 'vitest'; import { renderWithProviders } from '../../../test-utils/render.js'; import { ThinkingMessage } from './ThinkingMessage.js'; +import React from 'react'; describe('ThinkingMessage', () => { - it('renders subject line', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + it('renders subject line with vertical rule and "Thinking..." header', async () => { + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + const output = renderResult.lastFrame(); + expect(output).toContain(' Thinking...'); + expect(output).toContain('โ”‚'); + expect(output).toContain('Planning'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('uses description when subject is empty', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + const output = renderResult.lastFrame(); + expect(output).toContain('Processing details'); + expect(output).toContain('โ”‚'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('renders full mode with left border and full text', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + const output = renderResult.lastFrame(); + expect(output).toContain('โ”‚'); + expect(output).toContain('Planning'); + expect(output).toContain('I am planning the solution.'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); - it('indents summary line correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + it('renders "Thinking..." header when isFirstThinking is true', async () => { + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + const output = renderResult.lastFrame(); + expect(output).toContain(' Thinking...'); + expect(output).toContain('Summary line'); + expect(output).toContain('โ”‚'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('normalizes escaped newline tokens', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + expect(renderResult.lastFrame()).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('renders empty state gracefully', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , + const renderResult = renderWithProviders( + , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame({ allowEmpty: true })).toBe(''); - unmount(); + expect(renderResult.lastFrame({ allowEmpty: true })).toBe(''); + renderResult.unmount(); + }); + + it('renders multiple thinking messages sequentially correctly', async () => { + const renderResult = renderWithProviders( + + + + + , + ); + await renderResult.waitUntilReady(); + + expect(renderResult.lastFrame()).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); }); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 86882307e7..9591989774 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -13,6 +13,30 @@ import { normalizeEscapedNewlines } from '../../utils/textUtils.js'; interface ThinkingMessageProps { thought: ThoughtSummary; + terminalWidth: number; + isFirstThinking?: boolean; +} + +const THINKING_LEFT_PADDING = 1; + +function normalizeThoughtLines(thought: ThoughtSummary): string[] { + const subject = normalizeEscapedNewlines(thought.subject).trim(); + const description = normalizeEscapedNewlines(thought.description).trim(); + + if (!subject && !description) { + return []; + } + + if (!subject) { + return description.split('\n'); + } + + if (!description) { + return [subject]; + } + + const bodyLines = description.split('\n'); + return [subject, ...bodyLines]; } /** @@ -21,60 +45,47 @@ interface ThinkingMessageProps { */ export const ThinkingMessage: React.FC = ({ thought, + terminalWidth, + isFirstThinking, }) => { - const { summary, body } = useMemo(() => { - const subject = normalizeEscapedNewlines(thought.subject).trim(); - const description = normalizeEscapedNewlines(thought.description).trim(); + const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]); - if (!subject && !description) { - return { summary: '', body: '' }; - } - - if (!subject) { - const lines = description - .split('\n') - .map((l) => l.trim()) - .filter(Boolean); - return { - summary: lines[0] || '', - body: lines.slice(1).join('\n'), - }; - } - - return { - summary: subject, - body: description, - }; - }, [thought]); - - if (!summary && !body) { + if (fullLines.length === 0) { return null; } return ( - - {summary && ( - + + {isFirstThinking && ( + + {' '} + Thinking...{' '} + + )} + + + + {fullLines.length > 0 && ( - {summary} + {fullLines[0]} - - )} - {body && ( - - - {body} + )} + {fullLines.slice(1).map((line, index) => ( + + {line} - - )} + ))} + ); }; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg new file mode 100644 index 0000000000..660d8b4fa1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg @@ -0,0 +1,14 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Matching the Blocks + โ”‚ + Some more text + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg new file mode 100644 index 0000000000..38647281df --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg @@ -0,0 +1,14 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Summary line + โ”‚ + First body line + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg new file mode 100644 index 0000000000..0294b63f30 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg @@ -0,0 +1,14 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Planning + โ”‚ + I am planning the solution. + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg new file mode 100644 index 0000000000..b7f8a52358 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg @@ -0,0 +1,30 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Initial analysis + โ”‚ + This is a multiple line paragraph for the first thinking message of how the + โ”‚ + model analyzes the problem. + โ”‚ + โ”‚ + Planning execution + โ”‚ + This a second multiple line paragraph for the second thinking message + โ”‚ + explaining the plan in detail so that it wraps around the terminal display. + โ”‚ + โ”‚ + Refining approach + โ”‚ + And finally a third multiple line paragraph for the third thinking message to + โ”‚ + refine the solution. + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg new file mode 100644 index 0000000000..350a0cc61f --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg @@ -0,0 +1,14 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Planning + โ”‚ + test + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg new file mode 100644 index 0000000000..ce2b2a4686 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg @@ -0,0 +1,12 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Processing details + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap index 365f655d7d..da33a2a14c 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap @@ -1,30 +1,107 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ThinkingMessage > indents summary line correctly 1`] = ` -" Summary line - โ”‚ First body line -" -`; - exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = ` -" Matching the Blocks +" Thinking... + โ”‚ + โ”‚ Matching the Blocks โ”‚ Some more text " `; +exports[`ThinkingMessage > normalizes escaped newline tokens 2`] = ` +" Thinking... + โ”‚ + โ”‚ Matching the Blocks + โ”‚ Some more text" +`; + +exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 1`] = ` +" Thinking... + โ”‚ + โ”‚ Summary line + โ”‚ First body line +" +`; + +exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 2`] = ` +" Thinking... + โ”‚ + โ”‚ Summary line + โ”‚ First body line" +`; + exports[`ThinkingMessage > renders full mode with left border and full text 1`] = ` -" Planning +" Thinking... + โ”‚ + โ”‚ Planning โ”‚ I am planning the solution. " `; -exports[`ThinkingMessage > renders subject line 1`] = ` -" Planning +exports[`ThinkingMessage > renders full mode with left border and full text 2`] = ` +" Thinking... + โ”‚ + โ”‚ Planning + โ”‚ I am planning the solution." +`; + +exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 1`] = ` +" Thinking... + โ”‚ + โ”‚ Initial analysis + โ”‚ This is a multiple line paragraph for the first thinking message of how the + โ”‚ model analyzes the problem. + โ”‚ + โ”‚ Planning execution + โ”‚ This a second multiple line paragraph for the second thinking message + โ”‚ explaining the plan in detail so that it wraps around the terminal display. + โ”‚ + โ”‚ Refining approach + โ”‚ And finally a third multiple line paragraph for the third thinking message to + โ”‚ refine the solution. +" +`; + +exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 2`] = ` +" Thinking... + โ”‚ + โ”‚ Initial analysis + โ”‚ This is a multiple line paragraph for the first thinking message of how the + โ”‚ model analyzes the problem. + โ”‚ + โ”‚ Planning execution + โ”‚ This a second multiple line paragraph for the second thinking message + โ”‚ explaining the plan in detail so that it wraps around the terminal display. + โ”‚ + โ”‚ Refining approach + โ”‚ And finally a third multiple line paragraph for the third thinking message to + โ”‚ refine the solution." +`; + +exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 1`] = ` +" Thinking... + โ”‚ + โ”‚ Planning โ”‚ test " `; +exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 2`] = ` +" Thinking... + โ”‚ + โ”‚ Planning + โ”‚ test" +`; + exports[`ThinkingMessage > uses description when subject is empty 1`] = ` -" Processing details +" Thinking... + โ”‚ + โ”‚ Processing details " `; + +exports[`ThinkingMessage > uses description when subject is empty 2`] = ` +" Thinking... + โ”‚ + โ”‚ Processing details" +`; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index cfffb28196..1f2ef5f90c 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2824,7 +2824,6 @@ describe('useGeminiStream', () => { type: 'thinking', thought: expect.objectContaining({ subject: 'Full thought' }), }), - expect.any(Number), ); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index b0b4f553a2..d254902a94 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -905,17 +905,14 @@ export const useGeminiStream = ( ); const handleThoughtEvent = useCallback( - (eventValue: ThoughtSummary, userMessageTimestamp: number) => { + (eventValue: ThoughtSummary, _userMessageTimestamp: number) => { setThought(eventValue); if (getInlineThinkingMode(settings) === 'full') { - addItem( - { - type: 'thinking', - thought: eventValue, - } as HistoryItemThinking, - userMessageTimestamp, - ); + addItem({ + type: 'thinking', + thought: eventValue, + } as HistoryItemThinking); } }, [addItem, settings, setThought], diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index ceff3e9c8c..d356def6a9 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -190,6 +190,37 @@ describe('convertSessionToHistoryFormats', () => { }); }); + it('should convert thinking tokens (thoughts) to thinking history items', () => { + const messages: MessageRecord[] = [ + { + type: 'gemini', + content: 'Hi there', + thoughts: [ + { + subject: 'Thinking...', + description: 'I should say hello.', + timestamp: new Date().toISOString(), + }, + ], + } as MessageRecord, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(2); + expect(result.uiHistory[0]).toMatchObject({ + type: 'thinking', + thought: { + subject: 'Thinking...', + description: 'I should say hello.', + }, + }); + expect(result.uiHistory[1]).toMatchObject({ + type: 'gemini', + text: 'Hi there', + }); + }); + it('should prioritize displayContent for UI history but use content for client history', () => { const messages: MessageRecord[] = [ { diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index fb0c9786ae..b06fa62f5e 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -48,12 +48,14 @@ describe('textUtils', () => { it('should handle unicode characters that crash string-width', () => { // U+0602 caused string-width to crash (see #16418) const char = 'ุ‚'; - expect(getCachedStringWidth(char)).toBe(0); + expect(() => getCachedStringWidth(char)).not.toThrow(); + expect(typeof getCachedStringWidth(char)).toBe('number'); }); it('should handle unicode characters that crash string-width with ANSI codes', () => { const charWithAnsi = '\u001b[31m' + 'ุ‚' + '\u001b[0m'; - expect(getCachedStringWidth(charWithAnsi)).toBe(0); + expect(() => getCachedStringWidth(charWithAnsi)).not.toThrow(); + expect(typeof getCachedStringWidth(charWithAnsi)).toBe('number'); }); }); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index ac6987f933..3aa0131ac2 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -535,6 +535,19 @@ export function convertSessionToHistoryFormats( const uiHistory: HistoryItemWithoutId[] = []; for (const msg of messages) { + // Add thoughts if present + if (msg.type === 'gemini' && msg.thoughts && msg.thoughts.length > 0) { + for (const thought of msg.thoughts) { + uiHistory.push({ + type: 'thinking', + thought: { + subject: thought.subject, + description: thought.description, + }, + }); + } + } + // Add the message only if it has content const displayContentString = msg.displayContent ? partListUnionToString(msg.displayContent) diff --git a/packages/core/src/utils/sessionUtils.test.ts b/packages/core/src/utils/sessionUtils.test.ts index 35f9462c11..d132087ee8 100644 --- a/packages/core/src/utils/sessionUtils.test.ts +++ b/packages/core/src/utils/sessionUtils.test.ts @@ -33,6 +33,43 @@ describe('convertSessionToClientHistory', () => { ]); }); + it('should convert thinking tokens (thoughts) to model parts', () => { + const messages: ConversationRecord['messages'] = [ + { + id: '1', + type: 'user', + timestamp: '2024-01-01T10:00:00Z', + content: 'Hello', + }, + { + id: '2', + type: 'gemini', + timestamp: '2024-01-01T10:01:00Z', + content: 'Hi there', + thoughts: [ + { + subject: 'Thinking', + description: 'I should be polite.', + timestamp: '2024-01-01T10:00:50Z', + }, + ], + }, + ]; + + const history = convertSessionToClientHistory(messages); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'Hello' }] }, + { + role: 'model', + parts: [ + { text: '**Thinking** I should be polite.', thought: true }, + { text: 'Hi there' }, + ], + }, + ]); + }); + it('should ignore info, error, and slash commands', () => { const messages: ConversationRecord['messages'] = [ { diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts index b20c853ff7..4803dd4f07 100644 --- a/packages/core/src/utils/sessionUtils.ts +++ b/packages/core/src/utils/sessionUtils.ts @@ -51,15 +51,24 @@ export function convertSessionToClientHistory( parts: ensurePartArray(msg.content), }); } else if (msg.type === 'gemini') { + const modelParts: Part[] = []; + + // Add thoughts if present + if (msg.thoughts && msg.thoughts.length > 0) { + for (const thought of msg.thoughts) { + const thoughtText = thought.subject + ? `**${thought.subject}** ${thought.description}` + : thought.description; + modelParts.push({ + text: thoughtText, + thought: true, + } as Part); + } + } + const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0; if (hasToolCalls) { - const modelParts: Part[] = []; - - // TODO: Revisit if we should preserve more than just Part metadata (e.g. thoughtSignatures) - // currently those are only required within an active loop turn which resume clears - // by forcing a new user text prompt. - // Preserve original parts to maintain multimodal integrity if (msg.content) { modelParts.push(...ensurePartArray(msg.content)); @@ -114,14 +123,14 @@ export function convertSessionToClientHistory( } } else { if (msg.content) { - const parts = ensurePartArray(msg.content); + modelParts.push(...ensurePartArray(msg.content)); + } - if (parts.length > 0) { - clientHistory.push({ - role: 'model', - parts, - }); - } + if (modelParts.length > 0) { + clientHistory.push({ + role: 'model', + parts: modelParts, + }); } } } From 54b0344fc5bf55758c23c1a2a59e3c1e0f13deff Mon Sep 17 00:00:00 2001 From: Jarrod Whelan <150866123+jwhelangoog@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:04:22 -0800 Subject: [PATCH 006/202] fix(ui): unify Ctrl+O expansion hint experience across buffer modes (#21474) --- packages/cli/src/ui/AppContainer.test.tsx | 65 +++++- packages/cli/src/ui/AppContainer.tsx | 11 +- .../src/ui/components/FolderTrustDialog.tsx | 6 +- .../src/ui/components/ShowMoreLines.test.tsx | 6 +- .../cli/src/ui/components/ShowMoreLines.tsx | 3 - .../components/ShowMoreLinesLayout.test.tsx | 28 +++ .../src/ui/components/ToastDisplay.test.tsx | 2 +- .../cli/src/ui/components/ToastDisplay.tsx | 2 +- .../ui/components/ToolConfirmationQueue.tsx | 9 +- .../__snapshots__/MainContent.test.tsx.snap | 2 - .../ToolConfirmationQueue.test.tsx.snap | 1 + .../ui/components/messages/GeminiMessage.tsx | 20 +- .../messages/GeminiMessageContent.tsx | 10 +- .../messages/ToolGroupMessage.test.tsx | 197 ------------------ .../components/messages/ToolGroupMessage.tsx | 82 +------- .../ToolOverflowConsistencyChecks.test.tsx | 75 +++---- .../components/messages/ToolResultDisplay.tsx | 1 + .../ToolResultDisplayOverflow.test.tsx | 79 ------- .../src/ui/components/shared/Scrollable.tsx | 36 +++- 19 files changed, 184 insertions(+), 451 deletions(-) delete mode 100644 packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index cfa81f7c2a..6cf81ca316 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -3465,6 +3465,63 @@ describe('AppContainer State Management', () => { unmount!(); }); + it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // 1. Trigger first overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id-1'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // 2. Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // 3. Trigger second overflow (this should reset the timer) + act(() => { + capturedOverflowActions.addOverflowingId('test-id-2'); + }); + + // Advance by 1ms to allow the OverflowProvider's 0ms batching timeout to fire + // and flush the state update to AppContainer, triggering the reset. + act(() => { + vi.advanceTimersByTime(1); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // 4. Advance enough that the ORIGINAL timer would have expired + // Subtracting 1ms since we advanced it above to flush the state. + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1); + }); + // The hint should STILL be visible because the timer reset at step 3 + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // 5. Advance to the end of the NEW timer + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { let unmount: () => void; let stdin: ReturnType['stdin']; @@ -3606,7 +3663,7 @@ describe('AppContainer State Management', () => { unmount!(); }); - it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { + it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { const alternateSettings = mergeSettings({}, {}, {}, {}, true); const settingsWithAlternateBuffer = { merged: { @@ -3634,8 +3691,10 @@ describe('AppContainer State Management', () => { capturedOverflowActions.addOverflowingId('test-id'); }); - // Should NOT show hint because we are in Alternate Buffer Mode - expect(capturedUIState.showIsExpandableHint).toBe(false); + // Should NOW show hint because we are in Alternate Buffer Mode + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); unmount!(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 41cc5dec3d..30ebe221f0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -283,19 +283,18 @@ export const AppContainer = (props: AppContainerProps) => { * Manages the visibility and x-second timer for the expansion hint. * * This effect triggers the timer countdown whenever an overflow is detected - * or the user manually toggles the expansion state with Ctrl+O. We use a stable - * boolean dependency (hasOverflowState) to ensure the timer only resets on - * genuine state transitions, preventing it from infinitely resetting during - * active text streaming. + * or the user manually toggles the expansion state with Ctrl+O. + * By depending on overflowingIdsSize, the timer resets when *new* views + * overflow, but avoids infinitely resetting during single-view streaming. * * In alternate buffer mode, we don't trigger the hint automatically on overflow * to avoid noise, but the user can still trigger it manually with Ctrl+O. */ useEffect(() => { - if (hasOverflowState && !isAlternateBuffer) { + if (hasOverflowState) { triggerExpandHint(true); } - }, [hasOverflowState, isAlternateBuffer, triggerExpandHint]); + }, [hasOverflowState, overflowingIdsSize, triggerExpandHint]); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 5f154a4d1a..5bb748b28f 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -311,9 +311,5 @@ export const FolderTrustDialog: React.FC = ({ ); - return isAlternateBuffer ? ( - {content} - ) : ( - content - ); + return {content}; }; diff --git a/packages/cli/src/ui/components/ShowMoreLines.test.tsx b/packages/cli/src/ui/components/ShowMoreLines.test.tsx index 4a6829809a..dbdc8085a2 100644 --- a/packages/cli/src/ui/components/ShowMoreLines.test.tsx +++ b/packages/cli/src/ui/components/ShowMoreLines.test.tsx @@ -45,7 +45,7 @@ describe('ShowMoreLines', () => { }, ); - it('renders nothing in STANDARD mode even if overflowing', async () => { + it('renders message in STANDARD mode when overflowing', async () => { mockUseAlternateBuffer.mockReturnValue(false); mockUseOverflowState.mockReturnValue({ overflowingIds: new Set(['1']), @@ -55,7 +55,9 @@ describe('ShowMoreLines', () => { , ); await waitUntilReady(); - expect(lastFrame({ allowEmpty: true })).toBe(''); + expect(lastFrame().toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); unmount(); }); diff --git a/packages/cli/src/ui/components/ShowMoreLines.tsx b/packages/cli/src/ui/components/ShowMoreLines.tsx index 92acd2b29a..1af2befcd8 100644 --- a/packages/cli/src/ui/components/ShowMoreLines.tsx +++ b/packages/cli/src/ui/components/ShowMoreLines.tsx @@ -9,7 +9,6 @@ import { useOverflowState } from '../contexts/OverflowContext.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { theme } from '../semantic-colors.js'; -import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; interface ShowMoreLinesProps { constrainHeight: boolean; @@ -20,7 +19,6 @@ export const ShowMoreLines = ({ constrainHeight, isOverflowing: isOverflowingProp, }: ShowMoreLinesProps) => { - const isAlternateBuffer = useAlternateBuffer(); const overflowState = useOverflowState(); const streamingState = useStreamingContext(); @@ -29,7 +27,6 @@ export const ShowMoreLines = ({ (overflowState !== undefined && overflowState.overflowingIds.size > 0); if ( - !isAlternateBuffer || !isOverflowing || !constrainHeight || !( diff --git a/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx b/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx index ede092976f..b5f8eb3b8b 100644 --- a/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx +++ b/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx @@ -64,4 +64,32 @@ describe('ShowMoreLines layout and padding', () => { unmount(); }); + + it('renders in Standard mode as well', async () => { + mockUseAlternateBuffer.mockReturnValue(false); // Standard mode + + const TestComponent = () => ( + + Top + + Bottom + + ); + + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); + + const output = lastFrame({ allowEmpty: true }); + const lines = output.split('\n'); + + expect(lines).toEqual([ + 'Top', + ' Press Ctrl+O to show more lines', + '', + 'Bottom', + '', + ]); + + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/ToastDisplay.test.tsx b/packages/cli/src/ui/components/ToastDisplay.test.tsx index 668f91c8d9..b1432caa9d 100644 --- a/packages/cli/src/ui/components/ToastDisplay.test.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.test.tsx @@ -188,7 +188,7 @@ describe('ToastDisplay', () => { }); await waitUntilReady(); expect(lastFrame()).toContain( - 'Ctrl+O to show more lines of the last response', + 'Press Ctrl+O to show more lines of the last response', ); }); diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx index 6fcef1667c..869139cb39 100644 --- a/packages/cli/src/ui/components/ToastDisplay.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.tsx @@ -78,7 +78,7 @@ export const ToastDisplay: React.FC = () => { const action = uiState.constrainHeight ? 'show more' : 'collapse'; return ( - Ctrl+O to {action} lines of the last response + Press Ctrl+O to {action} lines of the last response ); } diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index 3fb1cc8c6f..b976bb3755 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -15,7 +15,6 @@ import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { StickyHeader } from './StickyHeader.js'; -import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import type { SerializableConfirmationDetails } from '@google/gemini-cli-core'; import { useUIActions } from '../contexts/UIActionsContext.js'; @@ -43,7 +42,6 @@ export const ToolConfirmationQueue: React.FC = ({ }) => { const config = useConfig(); const { getPreferredEditor } = useUIActions(); - const isAlternateBuffer = useAlternateBuffer(); const { mainAreaWidth, terminalHeight, @@ -157,10 +155,5 @@ export const ToolConfirmationQueue: React.FC = ({ ); - return isAlternateBuffer ? ( - /* Shadow the global provider to maintain isolation in ASB mode. */ - {content} - ) : ( - content - ); + return {content}; }; diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index 74acc6985d..c0043bf6f9 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -18,7 +18,6 @@ AppHeader(full) โ”‚ Line 19 โ–ˆ โ”‚ โ”‚ Line 20 โ–ˆ โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - Press Ctrl+O to show more lines " `; @@ -40,7 +39,6 @@ AppHeader(full) โ”‚ Line 19 โ–ˆ โ”‚ โ”‚ Line 20 โ–ˆ โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - Press Ctrl+O to show more lines " `; 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 a39d668825..6d9baba94f 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -16,6 +16,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai โ”‚ 4. No, suggest changes (esc) โ”‚ โ”‚ โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + Press Ctrl+O to show more lines " `; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 481f0a8a0e..bc2246d23f 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -7,12 +7,9 @@ import type React from 'react'; import { Text, Box } from 'ink'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { ShowMoreLines } from '../ShowMoreLines.js'; import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; import { useUIState } from '../../contexts/UIStateContext.js'; -import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; -import { OverflowProvider } from '../../contexts/OverflowContext.js'; interface GeminiMessageProps { text: string; @@ -31,8 +28,7 @@ export const GeminiMessage: React.FC = ({ const prefix = 'โœฆ '; const prefixWidth = prefix.length; - const isAlternateBuffer = useAlternateBuffer(); - const content = ( + return ( @@ -44,26 +40,14 @@ export const GeminiMessage: React.FC = ({ text={text} isPending={isPending} availableTerminalHeight={ - isAlternateBuffer || availableTerminalHeight === undefined + availableTerminalHeight === undefined ? undefined : Math.max(availableTerminalHeight - 1, 1) } terminalWidth={Math.max(terminalWidth - prefixWidth, 0)} renderMarkdown={renderMarkdown} /> - - - ); - - return isAlternateBuffer ? ( - /* Shadow the global provider to maintain isolation in ASB mode. */ - {content} - ) : ( - content - ); }; diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx index f3ac6c7749..1aed5e1950 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx @@ -7,9 +7,7 @@ import type React from 'react'; import { Box } from 'ink'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { ShowMoreLines } from '../ShowMoreLines.js'; import { useUIState } from '../../contexts/UIStateContext.js'; -import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; interface GeminiMessageContentProps { text: string; @@ -31,7 +29,6 @@ export const GeminiMessageContent: React.FC = ({ terminalWidth, }) => { const { renderMarkdown } = useUIState(); - const isAlternateBuffer = useAlternateBuffer(); const originalPrefix = 'โœฆ '; const prefixWidth = originalPrefix.length; @@ -41,18 +38,13 @@ export const GeminiMessageContent: React.FC = ({ text={text} isPending={isPending} availableTerminalHeight={ - isAlternateBuffer || availableTerminalHeight === undefined + availableTerminalHeight === undefined ? undefined : Math.max(availableTerminalHeight - 1, 1) } terminalWidth={Math.max(terminalWidth - prefixWidth, 0)} renderMarkdown={renderMarkdown} /> - - - ); }; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 056e6a54b4..8971d488d3 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -6,7 +6,6 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { act } from 'react'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import type { HistoryItem, @@ -767,200 +766,4 @@ describe('', () => { }, ); }); - - describe('Manual Overflow Detection', () => { - it('detects overflow for string results exceeding available height', async () => { - const toolCalls = [ - createToolCall({ - resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5', - }), - ]; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( - , - { - config: baseMockConfig, - settings: fullVerbositySettings, - useAlternateBuffer: true, - uiState: { - constrainHeight: true, - }, - }, - ); - await waitUntilReady(); - expect(lastFrame()?.toLowerCase()).toContain( - 'press ctrl+o to show more lines', - ); - unmount(); - }); - - it('detects overflow for array results exceeding available height', async () => { - // resultDisplay when array is expected to be AnsiLine[] - // AnsiLine is AnsiToken[] - const toolCalls = [ - createToolCall({ - resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]), - }), - ]; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( - , - { - config: baseMockConfig, - settings: fullVerbositySettings, - useAlternateBuffer: true, - uiState: { - constrainHeight: true, - }, - }, - ); - await waitUntilReady(); - expect(lastFrame()?.toLowerCase()).toContain( - 'press ctrl+o to show more lines', - ); - unmount(); - }); - - it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => { - const toolCalls = [ - createToolCall({ - name: 'run_shell_command', - status: CoreToolCallStatus.Executing, - ptyId: 1, - resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit) - }), - ]; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( - , - { - config: baseMockConfig, - settings: fullVerbositySettings, - useAlternateBuffer: true, - uiState: { - constrainHeight: true, - activePtyId: 1, - embeddedShellFocused: true, - }, - }, - ); - await waitUntilReady(); - expect(lastFrame()?.toLowerCase()).toContain( - 'press ctrl+o to show more lines', - ); - unmount(); - }); - - it('does not show expansion hint when content is within limits', async () => { - const toolCalls = [ - createToolCall({ - resultDisplay: 'small result', - }), - ]; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( - , - { - config: baseMockConfig, - settings: fullVerbositySettings, - useAlternateBuffer: true, - uiState: { - constrainHeight: true, - }, - }, - ); - await waitUntilReady(); - expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); - unmount(); - }); - - it('hides expansion hint when constrainHeight is false', async () => { - const toolCalls = [ - createToolCall({ - resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5', - }), - ]; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( - , - { - config: baseMockConfig, - settings: fullVerbositySettings, - useAlternateBuffer: true, - uiState: { - constrainHeight: false, - }, - }, - ); - await waitUntilReady(); - expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); - unmount(); - }); - - it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => { - // In this test, the tool output is SHORT (no local overflow). - // We will inject a dummy ID into the global overflow state. - // ToolGroupMessage should still NOT show the hint because it calculates - // overflow locally and passes it as a prop. - const toolCalls = [ - createToolCall({ - resultDisplay: 'short result', - }), - ]; - const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } = - renderWithProviders( - , - { - config: baseMockConfig, - settings: fullVerbositySettings, - useAlternateBuffer: true, - uiState: { - constrainHeight: true, - }, - }, - ); - await waitUntilReady(); - - // Manually trigger a global overflow - act(() => { - expect(capturedOverflowActions).toBeDefined(); - capturedOverflowActions!.addOverflowingId('unrelated-global-id'); - }); - - // The hint should NOT appear because ToolGroupMessage is isolated by its prop logic - expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); - unmount(); - }); - }); }); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 5ec2a18e06..05f9984d69 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -17,18 +17,12 @@ 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 { isShellTool } from './ToolShared.js'; import { shouldHideToolCall, CoreToolCallStatus, } from '@google/gemini-cli-core'; -import { ShowMoreLines } from '../ShowMoreLines.js'; import { useUIState } from '../../contexts/UIStateContext.js'; -import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; -import { - calculateShellMaxLines, - calculateToolContentMaxLines, -} from '../../utils/toolLayoutUtils.js'; import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; import { useSettings } from '../../contexts/SettingsContext.js'; @@ -83,13 +77,11 @@ export const ToolGroupMessage: React.FC = ({ const config = useConfig(); const { - constrainHeight, activePtyId, embeddedShellFocused, backgroundShells, pendingHistoryItems, } = useUIState(); - const isAlternateBuffer = useAlternateBuffer(); const { borderColor, borderDimColor } = useMemo( () => @@ -149,72 +141,6 @@ export const ToolGroupMessage: React.FC = ({ const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN; - /* - * ToolGroupMessage calculates its own overflow state locally and passes - * it as a prop to ShowMoreLines. This isolates it from global overflow - * reports in ASB mode, while allowing it to contribute to the global - * 'Toast' hint in Standard mode. - * - * Because of this prop-based isolation and the explicit mode-checks in - * AppContainer, we do not need to shadow the OverflowProvider here. - */ - const hasOverflow = useMemo(() => { - if (!availableTerminalHeightPerToolMessage) return false; - return visibleToolCalls.some((tool) => { - const isShellToolCall = isShellTool(tool.name); - const isFocused = isThisShellFocused( - tool.name, - tool.status, - tool.ptyId, - activePtyId, - embeddedShellFocused, - ); - - let maxLines: number | undefined; - - if (isShellToolCall) { - maxLines = calculateShellMaxLines({ - status: tool.status, - isAlternateBuffer, - isThisShellFocused: isFocused, - availableTerminalHeight: availableTerminalHeightPerToolMessage, - constrainHeight, - isExpandable, - }); - } - - // Standard tools and Shell tools both eventually use ToolResultDisplay's logic. - // ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget. - const contentMaxLines = calculateToolContentMaxLines({ - availableTerminalHeight: availableTerminalHeightPerToolMessage, - isAlternateBuffer, - maxLinesLimit: maxLines, - }); - - if (!contentMaxLines) return false; - - if (typeof tool.resultDisplay === 'string') { - const text = tool.resultDisplay; - const hasTrailingNewline = text.endsWith('\n'); - const contentText = hasTrailingNewline ? text.slice(0, -1) : text; - const lineCount = contentText.split('\n').length; - return lineCount > contentMaxLines; - } - if (Array.isArray(tool.resultDisplay)) { - return tool.resultDisplay.length > contentMaxLines; - } - return false; - }); - }, [ - visibleToolCalls, - availableTerminalHeightPerToolMessage, - activePtyId, - embeddedShellFocused, - isAlternateBuffer, - constrainHeight, - isExpandable, - ]); - // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), // only render if we need to close a border from previous // tool groups. borderBottomOverride=true means we must render the closing border; @@ -307,12 +233,6 @@ export const ToolGroupMessage: React.FC = ({ /> ) } - {(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && ( - - )} ); diff --git a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx index a82132d0d8..20b8d13459 100644 --- a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx @@ -8,21 +8,19 @@ import { describe, it, expect } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import { renderWithProviders } from '../../../test-utils/render.js'; import { StreamingState, type IndividualToolCallDisplay } from '../../types.js'; -import { OverflowProvider } from '../../contexts/OverflowContext.js'; import { waitFor } from '../../../test-utils/async.js'; import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { useOverflowState } from '../../contexts/OverflowContext.js'; describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => { - it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => { + it('should ensure ToolGroupMessage correctly reports overflow to the global state in Alternate Buffer (ASB) mode', async () => { /** * Logic: - * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool. - * 2. ASB mode reserves 1 + 6 = 7 lines. - * 3. Line budget = 10 - 7 = 3 lines. - * 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE. + * 1. availableTerminalHeight(13) - staticHeight(1) - ASB Reserved(6) = 6 lines per tool. + * 2. 10 lines of output > 6 lines budget => hasOverflow should be TRUE. */ - const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`); + const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`); const resultDisplay = lines.join('\n'); const toolCalls: IndividualToolCallDisplay[] = [ @@ -36,8 +34,15 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay }, ]; - const { lastFrame } = renderWithProviders( - + let latestOverflowState: ReturnType; + const StateCapture = () => { + latestOverflowState = useOverflowState(); + return null; + }; + + const { unmount, waitUntilReady } = renderWithProviders( + <> + - , + , { uiState: { streamingState: StreamingState.Idle, @@ -55,24 +60,26 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay }, ); - // In ASB mode, the hint should appear because hasOverflow is now correctly calculated. - await waitFor(() => - expect(lastFrame()?.toLowerCase()).toContain( - 'press ctrl+o to show more lines', - ), - ); + await waitUntilReady(); + + // To verify that the overflow state was indeed updated by the Scrollable component. + await waitFor(() => { + expect(latestOverflowState?.overflowingIds.size).toBeGreaterThan(0); + }); + + unmount(); }); - it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => { + it('should ensure ToolGroupMessage correctly reports overflow in Standard mode', async () => { /** * Logic: - * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool. - * 2. Standard mode reserves 1 + 2 = 3 lines. - * 3. Line budget = 10 - 3 = 7 lines. - * 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE. + * 1. availableTerminalHeight(13) passed to ToolGroupMessage. + * 2. ToolGroupMessage subtracts its static height (2) => 11 lines available for tools. + * 3. ToolResultDisplay gets 11 lines, subtracts static height (1) and Standard Reserved (2) => 8 lines. + * 4. 15 lines of output > 8 lines budget => hasOverflow should be TRUE. */ - const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`); + const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`); const resultDisplay = lines.join('\n'); const toolCalls: IndividualToolCallDisplay[] = [ @@ -86,16 +93,14 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay }, ]; - const { lastFrame } = renderWithProviders( - - - , + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , { uiState: { streamingState: StreamingState.Idle, @@ -105,11 +110,11 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay }, ); + await waitUntilReady(); + // Verify truncation is occurring (standard mode uses MaxSizedBox) await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O')); - // In Standard mode, ToolGroupMessage calculates hasOverflow correctly now. - // While Standard mode doesn't render the inline hint (ShowMoreLines returns null), - // the logic inside ToolGroupMessage is now synchronized. + unmount(); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 1c29407e91..05b94442db 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -236,6 +236,7 @@ export const ToolResultDisplay: React.FC = ({ maxHeight={maxLines ?? availableHeight} hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down) scrollToBottom={true} + reportOverflow={true} > {content} diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx deleted file mode 100644 index 2dff7d25e7..0000000000 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { ToolGroupMessage } from './ToolGroupMessage.js'; -import { renderWithProviders } from '../../../test-utils/render.js'; -import { StreamingState, type IndividualToolCallDisplay } from '../../types.js'; -import { waitFor } from '../../../test-utils/async.js'; -import { CoreToolCallStatus } from '@google/gemini-cli-core'; - -describe('ToolResultDisplay Overflow', () => { - it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => { - // Large output that will definitely overflow - const lines = []; - for (let i = 0; i < 50; i++) { - lines.push(`line ${i + 1}`); - } - const resultDisplay = lines.join('\n'); - - const toolCalls: IndividualToolCallDisplay[] = [ - { - callId: 'call-1', - name: 'test-tool', - description: 'a test tool', - status: CoreToolCallStatus.Success, - resultDisplay, - confirmationDetails: undefined, - }, - ]; - - const { lastFrame, waitUntilReady } = renderWithProviders( - , - { - uiState: { - streamingState: StreamingState.Idle, - constrainHeight: true, - }, - useAlternateBuffer: true, - }, - ); - - await waitUntilReady(); - - // In ASB mode the overflow hint can render before the scroll position - // settles. Wait for both the hint and the tail of the content so this - // snapshot is deterministic across slower CI runners. - await waitFor(() => { - const frame = lastFrame(); - expect(frame).toBeDefined(); - expect(frame?.toLowerCase()).toContain('press ctrl+o to show more lines'); - expect(frame).toContain('line 50'); - }); - - const frame = lastFrame(); - expect(frame).toBeDefined(); - if (frame) { - expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines'); - // Ensure it's AFTER the bottom border - const linesOfOutput = frame.split('\n'); - const bottomBorderIndex = linesOfOutput.findLastIndex((l) => - l.includes('โ•ฐโ”€'), - ); - const hintIndex = linesOfOutput.findIndex((l) => - l.toLowerCase().includes('press ctrl+o to show more lines'), - ); - expect(hintIndex).toBeGreaterThan(bottomBorderIndex); - expect(frame).toMatchSnapshot(); - } - }); -}); diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index 87ec6e72d6..a7227c7087 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -5,13 +5,22 @@ */ import type React from 'react'; -import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react'; +import { + useState, + useRef, + useCallback, + useMemo, + useLayoutEffect, + useEffect, + useId, +} from 'react'; import { Box, ResizeObserver, type DOMElement } from 'ink'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { useScrollable } from '../../contexts/ScrollProvider.js'; import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; +import { useOverflowActions } from '../../contexts/OverflowContext.js'; interface ScrollableProps { children?: React.ReactNode; @@ -22,6 +31,7 @@ interface ScrollableProps { hasFocus: boolean; scrollToBottom?: boolean; flexGrow?: number; + reportOverflow?: boolean; } export const Scrollable: React.FC = ({ @@ -33,10 +43,13 @@ export const Scrollable: React.FC = ({ hasFocus, scrollToBottom, flexGrow, + reportOverflow = false, }) => { const [scrollTop, setScrollTop] = useState(0); const viewportRef = useRef(null); const contentRef = useRef(null); + const overflowActions = useOverflowActions(); + const id = useId(); const [size, setSize] = useState({ innerHeight: typeof height === 'number' ? height : 0, scrollHeight: 0, @@ -52,6 +65,27 @@ export const Scrollable: React.FC = ({ scrollTopRef.current = scrollTop; }, [scrollTop]); + useEffect(() => { + if (reportOverflow && size.scrollHeight > size.innerHeight) { + overflowActions?.addOverflowingId?.(id); + } else { + overflowActions?.removeOverflowingId?.(id); + } + }, [ + reportOverflow, + size.scrollHeight, + size.innerHeight, + id, + overflowActions, + ]); + + useEffect( + () => () => { + overflowActions?.removeOverflowingId?.(id); + }, + [id, overflowActions], + ); + const viewportObserverRef = useRef(null); const contentObserverRef = useRef(null); From e89cf5d86e434fd4064ebfe3d0309b9ab388df2f Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Sat, 7 Mar 2026 11:31:09 -0800 Subject: [PATCH 007/202] fix(cli): correct shell height reporting (#21492) --- packages/cli/src/ui/AppContainer.test.tsx | 56 ----------------- packages/cli/src/ui/AppContainer.tsx | 26 -------- .../messages/ShellToolMessage.test.tsx | 8 +-- .../components/messages/ShellToolMessage.tsx | 62 ++++++++++++++++--- .../ShellToolMessage.test.tsx.snap | 21 ++----- packages/cli/src/ui/utils/toolLayoutUtils.ts | 15 ++++- 6 files changed, 74 insertions(+), 114 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 6cf81ca316..0b6eaa037b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -232,10 +232,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import * as useKeypressModule from './hooks/useKeypress.js'; import { useSuspend } from './hooks/useSuspend.js'; -import { measureElement } from 'ink'; -import { useTerminalSize } from './hooks/useTerminalSize.js'; import { - ShellExecutionService, writeToStdout, enableMouseEvents, disableMouseEvents, @@ -2197,35 +2194,6 @@ describe('AppContainer State Management', () => { }); }); - describe('Terminal Height Calculation', () => { - const mockedMeasureElement = measureElement as Mock; - const mockedUseTerminalSize = useTerminalSize as Mock; - - it('should prevent terminal height from being less than 1', async () => { - const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty'); - // Arrange: Simulate a small terminal and a large footer - mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 }); - mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen - - mockedUseGeminiStream.mockReturnValue({ - ...DEFAULT_GEMINI_STREAM_MOCK, - activePtyId: 'some-id', - }); - - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(resizePtySpy).toHaveBeenCalled()); - const lastCall = - resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1]; - // Check the height argument specifically - expect(lastCall[2]).toBe(1); - unmount!(); - }); - }); - describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { let mockHandleSlashCommand: Mock; let mockCancelOngoingRequest: Mock; @@ -3141,30 +3109,6 @@ describe('AppContainer State Management', () => { }); }); - describe('Shell Interaction', () => { - it('should not crash if resizing the pty fails', async () => { - const resizePtySpy = vi - .spyOn(ShellExecutionService, 'resizePty') - .mockImplementation(() => { - throw new Error('Cannot resize a pty that has already exited'); - }); - - mockedUseGeminiStream.mockReturnValue({ - ...DEFAULT_GEMINI_STREAM_MOCK, - activePtyId: 'some-pty-id', // Make sure activePtyId is set - }); - - // The main assertion is that the render does not throw. - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - - await waitFor(() => expect(resizePtySpy).toHaveBeenCalled()); - unmount!(); - }); - }); describe('Banner Text', () => { it('should render placeholder banner text for USE_GEMINI auth type', async () => { const config = makeFakeConfig(); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 30ebe221f0..965a63db43 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1420,32 +1420,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const initialPromptSubmitted = useRef(false); const geminiClient = config.getGeminiClient(); - useEffect(() => { - if (activePtyId) { - try { - ShellExecutionService.resizePty( - activePtyId, - Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - Math.max( - Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), - 1, - ), - ); - } catch (e) { - // This can happen in a race condition where the pty exits - // right before we try to resize it. - if ( - !( - e instanceof Error && - e.message.includes('Cannot resize a pty that has already exited') - ) - ) { - throw e; - } - } - } - }, [terminalWidth, availableTerminalHeight, activePtyId]); - useEffect(() => { if ( initialPrompt && diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 76b8f95ce7..40e5a7e781 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -195,7 +195,7 @@ describe('', () => { [ 'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large', 100, - ACTIVE_SHELL_MAX_LINES, + ACTIVE_SHELL_MAX_LINES - 3, false, ], [ @@ -207,7 +207,7 @@ describe('', () => { [ 'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined', undefined, - ACTIVE_SHELL_MAX_LINES, + ACTIVE_SHELL_MAX_LINES - 3, false, ], ])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => { @@ -301,8 +301,8 @@ describe('', () => { await waitUntilReady(); await waitFor(() => { const frame = lastFrame(); - // Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false - expect(frame.match(/Line \d+/g)?.length).toBe(15); + // Should still be constrained to 12 (15 - 3) because isExpandable is false + expect(frame.match(/Line \d+/g)?.length).toBe(12); }); expect(lastFrame()).toMatchSnapshot(); unmount(); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 3a0cdb702e..f34aa08bfb 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -24,8 +24,16 @@ import type { ToolMessageProps } from './ToolMessage.js'; import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { useUIState } from '../../contexts/UIStateContext.js'; -import { type Config } from '@google/gemini-cli-core'; -import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js'; +import { + type Config, + ShellExecutionService, + CoreToolCallStatus, +} from '@google/gemini-cli-core'; +import { + calculateShellMaxLines, + calculateToolContentMaxLines, + SHELL_CONTENT_OVERHEAD, +} from '../../utils/toolLayoutUtils.js'; export interface ShellToolMessageProps extends ToolMessageProps { config?: Config; @@ -78,6 +86,47 @@ export const ShellToolMessage: React.FC = ({ embeddedShellFocused, ); + const maxLines = calculateShellMaxLines({ + status, + isAlternateBuffer, + isThisShellFocused, + availableTerminalHeight, + constrainHeight, + isExpandable, + }); + + const availableHeight = calculateToolContentMaxLines({ + availableTerminalHeight, + isAlternateBuffer, + maxLinesLimit: maxLines, + }); + + React.useEffect(() => { + const isExecuting = status === CoreToolCallStatus.Executing; + if (isExecuting && ptyId) { + try { + const childWidth = terminalWidth - 4; // account for padding and borders + const finalHeight = + availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD; + + ShellExecutionService.resizePty( + ptyId, + Math.max(1, childWidth), + Math.max(1, finalHeight), + ); + } catch (e) { + if ( + !( + e instanceof Error && + e.message.includes('Cannot resize a pty that has already exited') + ) + ) { + throw e; + } + } + } + }, [ptyId, status, terminalWidth, availableHeight]); + const { setEmbeddedShellFocused } = useUIActions(); const wasFocusedRef = React.useRef(false); @@ -166,14 +215,7 @@ export const ShellToolMessage: React.FC = ({ terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} hasFocus={isThisShellFocused} - maxLines={calculateShellMaxLines({ - status, - isAlternateBuffer, - isThisShellFocused, - availableTerminalHeight, - constrainHeight, - isExpandable, - })} + maxLines={maxLines} /> {isThisShellFocused && config && ( > Height Constraints > defaults to ACTIVE_SHELL_MA "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โŠถ Shell Command A shell command โ”‚ โ”‚ โ”‚ -โ”‚ Line 86 โ”‚ -โ”‚ Line 87 โ”‚ -โ”‚ Line 88 โ”‚ โ”‚ Line 89 โ”‚ โ”‚ Line 90 โ”‚ โ”‚ Line 91 โ”‚ @@ -16,8 +13,8 @@ exports[` > Height Constraints > defaults to ACTIVE_SHELL_MA โ”‚ Line 95 โ”‚ โ”‚ Line 96 โ”‚ โ”‚ Line 97 โ”‚ -โ”‚ Line 98 โ–„ โ”‚ -โ”‚ Line 99 โ–ˆ โ”‚ +โ”‚ Line 98 โ”‚ +โ”‚ Line 99 โ–„ โ”‚ โ”‚ Line 100 โ–ˆ โ”‚ " `; @@ -148,9 +145,6 @@ exports[` > Height Constraints > stays constrained in altern "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โœ“ Shell Command A shell command โ”‚ โ”‚ โ”‚ -โ”‚ Line 86 โ”‚ -โ”‚ Line 87 โ”‚ -โ”‚ Line 88 โ”‚ โ”‚ Line 89 โ”‚ โ”‚ Line 90 โ”‚ โ”‚ Line 91 โ”‚ @@ -160,8 +154,8 @@ exports[` > Height Constraints > stays constrained in altern โ”‚ Line 95 โ”‚ โ”‚ Line 96 โ”‚ โ”‚ Line 97 โ”‚ -โ”‚ Line 98 โ–„ โ”‚ -โ”‚ Line 99 โ–ˆ โ”‚ +โ”‚ Line 98 โ”‚ +โ”‚ Line 99 โ–„ โ”‚ โ”‚ Line 100 โ–ˆ โ”‚ " `; @@ -170,9 +164,6 @@ exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โŠถ Shell Command A shell command โ”‚ โ”‚ โ”‚ -โ”‚ Line 86 โ”‚ -โ”‚ Line 87 โ”‚ -โ”‚ Line 88 โ”‚ โ”‚ Line 89 โ”‚ โ”‚ Line 90 โ”‚ โ”‚ Line 91 โ”‚ @@ -182,8 +173,8 @@ exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES โ”‚ Line 95 โ”‚ โ”‚ Line 96 โ”‚ โ”‚ Line 97 โ”‚ -โ”‚ Line 98 โ–„ โ”‚ -โ”‚ Line 99 โ–ˆ โ”‚ +โ”‚ Line 98 โ”‚ +โ”‚ Line 99 โ–„ โ”‚ โ”‚ Line 100 โ–ˆ โ”‚ " `; diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.ts b/packages/cli/src/ui/utils/toolLayoutUtils.ts index 8f619901f6..6ba1b85c5e 100644 --- a/packages/cli/src/ui/utils/toolLayoutUtils.ts +++ b/packages/cli/src/ui/utils/toolLayoutUtils.ts @@ -20,6 +20,13 @@ export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6; export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 2; export const TOOL_RESULT_MIN_LINES_SHOWN = 2; +/** + * The vertical space (in lines) consumed by the shell UI elements + * (1 line for the shell title/header and 2 lines for the top and bottom borders). + */ +export const SHELL_CONTENT_OVERHEAD = + TOOL_RESULT_STATIC_HEIGHT + TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT; + /** * Calculates the final height available for the content of a tool result display. * @@ -88,7 +95,9 @@ export function calculateShellMaxLines(options: { // 2. Handle cases where height is unknown (Standard mode history). if (availableTerminalHeight === undefined) { - return isAlternateBuffer ? ACTIVE_SHELL_MAX_LINES : undefined; + return isAlternateBuffer + ? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD + : undefined; } const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); @@ -103,8 +112,8 @@ export function calculateShellMaxLines(options: { // 4. Fall back to process-based constants. const isExecuting = status === CoreToolCallStatus.Executing; const shellMaxLinesLimit = isExecuting - ? ACTIVE_SHELL_MAX_LINES - : COMPLETED_SHELL_MAX_LINES; + ? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD + : COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD; return Math.min(maxLinesBasedOnHeight, shellMaxLinesLimit); } From 245b68e9f1299fe7ec2fb88425ee575238f0da67 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Sat, 7 Mar 2026 12:04:17 -0800 Subject: [PATCH 008/202] Make test suite pass when the GEMINI_SYSTEM_MD env variable or GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ (#21480) --- packages/core/src/agents/generalist-agent.test.ts | 11 ++++++++++- packages/core/src/prompts/promptProvider.test.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/generalist-agent.test.ts b/packages/core/src/agents/generalist-agent.test.ts index efdf705a19..510fad5673 100644 --- a/packages/core/src/agents/generalist-agent.test.ts +++ b/packages/core/src/agents/generalist-agent.test.ts @@ -4,13 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { GeneralistAgent } from './generalist-agent.js'; import { makeFakeConfig } from '../test-utils/config.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; import type { AgentRegistry } from './registry.js'; describe('GeneralistAgent', () => { + beforeEach(() => { + vi.stubEnv('GEMINI_SYSTEM_MD', ''); + vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('should create a valid generalist agent definition', () => { const config = makeFakeConfig(); vi.spyOn(config, 'getToolRegistry').mockReturnValue({ diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index f286d5bea8..2d96dee7ef 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { PromptProvider } from './promptProvider.js'; import type { Config } from '../config/config.js'; import { @@ -35,6 +35,9 @@ describe('PromptProvider', () => { beforeEach(() => { vi.resetAllMocks(); + vi.stubEnv('GEMINI_SYSTEM_MD', ''); + vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', ''); + mockConfig = { getToolRegistry: vi.fn().mockReturnValue({ getAllToolNames: vi.fn().mockReturnValue([]), @@ -60,6 +63,10 @@ describe('PromptProvider', () => { } as unknown as Config; }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('should handle multiple context filenames in the system prompt', () => { vi.mocked(getAllGeminiMdFilenames).mockReturnValue([ DEFAULT_CONTEXT_FILENAME, From dac3735626cb8ae2e9a1ae8743e3d3b8ace6f052 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Sat, 7 Mar 2026 21:05:38 +0000 Subject: [PATCH 009/202] Disallow underspecified types (#21485) --- eslint.config.js | 12 +++++++++++- packages/a2a-server/src/agent/task.ts | 2 ++ packages/cli/src/commands/hooks/migrate.ts | 3 +++ packages/cli/src/test-utils/mockDebugLogger.ts | 1 + packages/cli/src/test-utils/render.tsx | 1 + packages/cli/src/ui/hooks/slashCommandProcessor.ts | 2 ++ packages/cli/src/utils/activityLogger.ts | 10 ++++++---- .../src/agents/browser/browserAgentInvocation.ts | 1 + packages/core/src/agents/browser/mcpToolWrapper.ts | 1 + packages/core/src/hooks/hookAggregator.ts | 1 + .../core/src/services/FolderTrustDiscoveryService.ts | 6 +++++- packages/core/src/services/chatCompressionService.ts | 2 ++ packages/core/src/services/loopDetectionService.ts | 5 +++++ packages/core/src/telemetry/semantic.ts | 2 ++ packages/core/src/tools/mcp-tool.ts | 2 +- packages/core/src/utils/editCorrector.ts | 1 + packages/core/src/utils/googleErrors.ts | 1 + packages/core/src/utils/oauth-flow.ts | 5 +++++ 18 files changed, 51 insertions(+), 7 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index d305f75f87..d3a267f30a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -132,7 +132,16 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], + 'no-restricted-syntax': [ + 'error', + ...commonRestrictedSyntaxRules, + { + selector: + 'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]', + message: + 'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.', + }, + ], 'no-unsafe-finally': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ @@ -263,6 +272,7 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], }, }, { diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index fe15aed37b..ef15a907e6 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -832,7 +832,9 @@ export class Task { if ( part.kind !== 'data' || !part.data || + // eslint-disable-next-line no-restricted-syntax typeof part.data['callId'] !== 'string' || + // eslint-disable-next-line no-restricted-syntax typeof part.data['outcome'] !== 'string' ) { return false; diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts index 47cc8660d7..36bb2cf9aa 100644 --- a/packages/cli/src/commands/hooks/migrate.ts +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -79,6 +79,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { migrated['command'] = hook['command']; // Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command + // eslint-disable-next-line no-restricted-syntax if (typeof migrated['command'] === 'string') { migrated['command'] = migrated['command'].replace( /\$CLAUDE_PROJECT_DIR/g, @@ -93,6 +94,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { } // Map timeout field (Claude uses seconds, Gemini uses seconds) + // eslint-disable-next-line no-restricted-syntax if ('timeout' in hook && typeof hook['timeout'] === 'number') { migrated['timeout'] = hook['timeout']; } @@ -140,6 +142,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record { // Transform matcher if ( 'matcher' in definition && + // eslint-disable-next-line no-restricted-syntax typeof definition['matcher'] === 'string' ) { migratedDef['matcher'] = transformMatcher(definition['matcher']); diff --git a/packages/cli/src/test-utils/mockDebugLogger.ts b/packages/cli/src/test-utils/mockDebugLogger.ts index 02eb3b05d9..bc0cde9010 100644 --- a/packages/cli/src/test-utils/mockDebugLogger.ts +++ b/packages/cli/src/test-utils/mockDebugLogger.ts @@ -65,6 +65,7 @@ export function mockCoreDebugLogger>( return { ...actual, coreEvents: { + // eslint-disable-next-line no-restricted-syntax ...(typeof actual['coreEvents'] === 'object' && actual['coreEvents'] !== null ? actual['coreEvents'] diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 06f99c135c..39425af171 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -96,6 +96,7 @@ function isInkRenderMetrics( typeof m === 'object' && m !== null && 'output' in m && + // eslint-disable-next-line no-restricted-syntax typeof m['output'] === 'string' ); } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c3f178ad1b..20a76dcf43 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -502,7 +502,9 @@ export const useSlashCommandProcessor = ( const props = result.props as Record; if ( !props || + // eslint-disable-next-line no-restricted-syntax typeof props['name'] !== 'string' || + // eslint-disable-next-line no-restricted-syntax typeof props['displayName'] !== 'string' || !props['definition'] ) { diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts index a6f903fe49..14cef88a54 100644 --- a/packages/cli/src/utils/activityLogger.ts +++ b/packages/cli/src/utils/activityLogger.ts @@ -494,9 +494,10 @@ export class ActivityLogger extends EventEmitter { req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) { if (chunk) { + const arg0 = etc[0]; const encoding = - typeof etc[0] === 'string' && Buffer.isEncoding(etc[0]) - ? etc[0] + typeof arg0 === 'string' && Buffer.isEncoding(arg0) + ? arg0 : undefined; requestChunks.push( Buffer.isBuffer(chunk) @@ -519,9 +520,10 @@ export class ActivityLogger extends EventEmitter { ) { const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb; if (chunk) { + const arg0 = etc[0]; const encoding = - typeof etc[0] === 'string' && Buffer.isEncoding(etc[0]) - ? etc[0] + typeof arg0 === 'string' && Buffer.isEncoding(arg0) + ? arg0 : undefined; requestChunks.push( Buffer.isBuffer(chunk) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 9df543300e..b503cc1214 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -119,6 +119,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< if ( activity.type === 'THOUGHT_CHUNK' && + // eslint-disable-next-line no-restricted-syntax typeof activity.data['text'] === 'string' ) { updateOutput(`๐ŸŒ๐Ÿ’ญ ${activity.data['text']}`); diff --git a/packages/core/src/agents/browser/mcpToolWrapper.ts b/packages/core/src/agents/browser/mcpToolWrapper.ts index 1838a01b42..96b6aa9b68 100644 --- a/packages/core/src/agents/browser/mcpToolWrapper.ts +++ b/packages/core/src/agents/browser/mcpToolWrapper.ts @@ -356,6 +356,7 @@ class TypeTextDeclarativeTool extends DeclarativeTool< params: Record, ): ToolInvocation, ToolResult> { const submitKey = + // eslint-disable-next-line no-restricted-syntax typeof params['submitKey'] === 'string' && params['submitKey'] ? params['submitKey'] : undefined; diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts index 523bc823fd..73e814702e 100644 --- a/packages/core/src/hooks/hookAggregator.ts +++ b/packages/core/src/hooks/hookAggregator.ts @@ -355,6 +355,7 @@ export class HookAggregator { // Extract additionalContext from various hook types if ( 'additionalContext' in specific && + // eslint-disable-next-line no-restricted-syntax typeof specific['additionalContext'] === 'string' ) { contexts.push(specific['additionalContext']); diff --git a/packages/core/src/services/FolderTrustDiscoveryService.ts b/packages/core/src/services/FolderTrustDiscoveryService.ts index e81273af22..bdf5d76297 100644 --- a/packages/core/src/services/FolderTrustDiscoveryService.ts +++ b/packages/core/src/services/FolderTrustDiscoveryService.ts @@ -132,7 +132,11 @@ export class FolderTrustDiscoveryService { for (const event of Object.values(hooksConfig)) { if (!Array.isArray(event)) continue; for (const hook of event) { - if (this.isRecord(hook) && typeof hook['command'] === 'string') { + if ( + this.isRecord(hook) && + // eslint-disable-next-line no-restricted-syntax + typeof hook['command'] === 'string' + ) { hooks.add(hook['command']); } } diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 5303a1a82a..8dceb18f4b 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -156,11 +156,13 @@ async function truncateHistoryToBudget( } else if (responseObj && typeof responseObj === 'object') { if ( 'output' in responseObj && + // eslint-disable-next-line no-restricted-syntax typeof responseObj['output'] === 'string' ) { contentStr = responseObj['output']; } else if ( 'content' in responseObj && + // eslint-disable-next-line no-restricted-syntax typeof responseObj['content'] === 'string' ) { contentStr = responseObj['content']; diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index e87de721c6..9bc8b406f8 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -579,10 +579,12 @@ export class LoopDetectionService { } const flashConfidence = + // eslint-disable-next-line no-restricted-syntax typeof flashResult['unproductive_state_confidence'] === 'number' ? flashResult['unproductive_state_confidence'] : 0; const flashAnalysis = + // eslint-disable-next-line no-restricted-syntax typeof flashResult['unproductive_state_analysis'] === 'string' ? flashResult['unproductive_state_analysis'] : ''; @@ -628,11 +630,13 @@ export class LoopDetectionService { const mainModelConfidence = mainModelResult && + // eslint-disable-next-line no-restricted-syntax typeof mainModelResult['unproductive_state_confidence'] === 'number' ? mainModelResult['unproductive_state_confidence'] : 0; const mainModelAnalysis = mainModelResult && + // eslint-disable-next-line no-restricted-syntax typeof mainModelResult['unproductive_state_analysis'] === 'string' ? mainModelResult['unproductive_state_analysis'] : undefined; @@ -681,6 +685,7 @@ export class LoopDetectionService { if ( result && + // eslint-disable-next-line no-restricted-syntax typeof result['unproductive_state_confidence'] === 'number' ) { return result; diff --git a/packages/core/src/telemetry/semantic.ts b/packages/core/src/telemetry/semantic.ts index cb38502c91..6dae06d381 100644 --- a/packages/core/src/telemetry/semantic.ts +++ b/packages/core/src/telemetry/semantic.ts @@ -63,6 +63,7 @@ function getStringReferences(parts: AnyPart[]): StringReference[] { }); } } else if (part instanceof GenericPart) { + // eslint-disable-next-line no-restricted-syntax if (part.type === 'executableCode' && typeof part['code'] === 'string') { refs.push({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -73,6 +74,7 @@ function getStringReferences(parts: AnyPart[]): StringReference[] { }); } else if ( part.type === 'codeExecutionResult' && + // eslint-disable-next-line no-restricted-syntax typeof part['output'] === 'string' ) { refs.push({ diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 5f99262aab..f67d1f9bea 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -107,7 +107,7 @@ export function isMcpToolAnnotation( return ( typeof annotation === 'object' && annotation !== null && - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, no-restricted-syntax typeof (annotation as Record)['_serverName'] === 'string' ); } diff --git a/packages/core/src/utils/editCorrector.ts b/packages/core/src/utils/editCorrector.ts index f8ff81b97e..2c58bad98f 100644 --- a/packages/core/src/utils/editCorrector.ts +++ b/packages/core/src/utils/editCorrector.ts @@ -112,6 +112,7 @@ Return ONLY the corrected string in the specified JSON format with the key 'corr if ( result && + // eslint-disable-next-line no-restricted-syntax typeof result['corrected_string_escaping'] === 'string' && result['corrected_string_escaping'].length > 0 ) { diff --git a/packages/core/src/utils/googleErrors.ts b/packages/core/src/utils/googleErrors.ts index f7f972f568..c9acb341bb 100644 --- a/packages/core/src/utils/googleErrors.ts +++ b/packages/core/src/utils/googleErrors.ts @@ -209,6 +209,7 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null { } // Basic structural check before casting. // Since the proto definitions are loose, we primarily rely on @type presence. + // eslint-disable-next-line no-restricted-syntax if (typeof detailObj['@type'] === 'string') { // We can just cast it; the consumer will have to switch on @type // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion diff --git a/packages/core/src/utils/oauth-flow.ts b/packages/core/src/utils/oauth-flow.ts index 9d5e6b8357..e13fd37837 100644 --- a/packages/core/src/utils/oauth-flow.ts +++ b/packages/core/src/utils/oauth-flow.ts @@ -361,19 +361,24 @@ async function parseTokenEndpointResponse( data && typeof data === 'object' && 'access_token' in data && + // eslint-disable-next-line no-restricted-syntax typeof (data as Record)['access_token'] === 'string' ) { const obj = data as Record; const result: OAuthTokenResponse = { access_token: String(obj['access_token']), token_type: + // eslint-disable-next-line no-restricted-syntax typeof obj['token_type'] === 'string' ? obj['token_type'] : 'Bearer', expires_in: + // eslint-disable-next-line no-restricted-syntax typeof obj['expires_in'] === 'number' ? obj['expires_in'] : undefined, refresh_token: + // eslint-disable-next-line no-restricted-syntax typeof obj['refresh_token'] === 'string' ? obj['refresh_token'] : undefined, + // eslint-disable-next-line no-restricted-syntax scope: typeof obj['scope'] === 'string' ? obj['scope'] : undefined, }; return result; From dc6741097c24967f56474b0e861f75ffe452c29f Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Sat, 7 Mar 2026 14:56:11 -0800 Subject: [PATCH 010/202] refactor(cli): standardize on 'reload' verb for all components (#20654) Co-authored-by: Krishna Korade Co-authored-by: Jacob Richman --- docs/cli/cli-reference.md | 15 ++++++ docs/cli/gemini-md.md | 2 +- docs/cli/settings.md | 2 +- docs/cli/tutorials/mcp-setup.md | 4 +- docs/cli/tutorials/memory-management.md | 2 +- docs/core/remote-agents.md | 2 +- docs/reference/configuration.md | 4 +- packages/cli/src/config/settingsSchema.ts | 2 +- packages/cli/src/ui/AppContainer.tsx | 6 +-- .../cli/src/ui/commands/agentsCommand.test.ts | 26 +++++---- packages/cli/src/ui/commands/agentsCommand.ts | 12 ++--- .../src/ui/commands/extensionsCommand.test.ts | 26 ++++----- .../cli/src/ui/commands/extensionsCommand.ts | 24 +++++---- packages/cli/src/ui/commands/mcpCommand.ts | 16 +++--- .../cli/src/ui/commands/memoryCommand.test.ts | 54 +++++++++---------- packages/cli/src/ui/commands/memoryCommand.ts | 10 ++-- .../cli/src/ui/commands/statsCommand.test.ts | 1 + packages/cli/src/ui/constants/tips.ts | 6 +-- packages/core/src/commands/memory.test.ts | 4 +- packages/core/src/commands/memory.ts | 4 +- schemas/settings.schema.json | 4 +- 21 files changed, 125 insertions(+), 101 deletions(-) diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index c1599df69e..6cafb7dd52 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -24,6 +24,21 @@ and parameters. | -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | | `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. | +## Interactive commands + +These commands are available within the interactive REPL. + +| Command | Description | +| -------------------- | ---------------------------------------- | +| `/skills reload` | Reload discovered skills from disk | +| `/agents reload` | Reload the agent registry | +| `/commands reload` | Reload custom slash commands | +| `/memory reload` | Reload context files (e.g., `GEMINI.md`) | +| `/mcp reload` | Restart and reload MCP servers | +| `/extensions reload` | Reload all active extensions | +| `/help` | Show help for all commands | +| `/quit` | Exit the interactive session | + ## CLI Options | Option | Alias | Type | Default | Description | diff --git a/docs/cli/gemini-md.md b/docs/cli/gemini-md.md index 95f46ae095..624b2fc566 100644 --- a/docs/cli/gemini-md.md +++ b/docs/cli/gemini-md.md @@ -63,7 +63,7 @@ You can interact with the loaded context files by using the `/memory` command. - **`/memory show`**: Displays the full, concatenated content of the current hierarchical memory. This lets you inspect the exact instructional context being provided to the model. -- **`/memory refresh`**: Forces a re-scan and reload of all `GEMINI.md` files +- **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files from all configured locations. - **`/memory add `**: Appends your text to your global `~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly. diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 7c7dee4e88..182217fc0e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -102,7 +102,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | -| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | +| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | | Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | | Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | | Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 03b6e56376..8723a65892 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -101,8 +101,8 @@ The agent will: - **Server won't start?** Try running the docker command manually in your terminal to see if it prints an error (e.g., "image not found"). -- **Tools not found?** Run `/mcp refresh` to force the CLI to re-query the - server for its capabilities. +- **Tools not found?** Run `/mcp reload` to force the CLI to re-query the server + for its capabilities. ## Next steps diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index 829fbecbd4..4cbca4bda9 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -105,7 +105,7 @@ excellent for debugging why the agent might be ignoring a rule. If you edit a `GEMINI.md` file while a session is running, the agent won't know immediately. Force a reload with: -**Command:** `/memory refresh` +**Command:** `/memory reload` ## Best practices diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md index 3e5b8b06d1..a01f015672 100644 --- a/docs/core/remote-agents.md +++ b/docs/core/remote-agents.md @@ -75,7 +75,7 @@ Markdown file. Users can manage subagents using the following commands within the Gemini CLI: - `/agents list`: Displays all available local and remote subagents. -- `/agents refresh`: Reloads the agent registry. Use this after adding or +- `/agents reload`: Reloads the agent registry. Use this after adding or modifying agent definition files. - `/agents enable `: Enables a specific subagent. - `/agents disable `: Disables a specific subagent. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 73419a883a..9b89fe75a8 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -719,7 +719,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `[]` - **`context.loadMemoryFromIncludeDirectories`** (boolean): - - **Description:** Controls how /memory refresh loads GEMINI.md files. When + - **Description:** Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. - **Default:** `false` @@ -1705,7 +1705,7 @@ conventions and context. loaded, allowing you to verify the hierarchy and content being used by the AI. - See the [Commands documentation](./commands.md#memory) for full details on - the `/memory` command and its sub-commands (`show` and `refresh`). + the `/memory` command and its sub-commands (`show` and `reload`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 59d28147c4..4fa17916c4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1169,7 +1169,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: oneLine` - Controls how /memory refresh loads GEMINI.md files. + Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. `, showInDialog: true, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 965a63db43..67f2d5dd84 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1009,10 +1009,10 @@ Logging in with Google... Restarting Gemini CLI to continue. historyManager.addItem( { type: MessageType.INFO, - text: `Memory refreshed successfully. ${ + text: `Memory reloaded successfully. ${ flattenedMemory.length > 0 - ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).` - : 'No memory content found.' + ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s)` + : 'No memory content found' }`, }, Date.now(), diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index 6b0a40ed5c..5e6cc36efa 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -105,34 +105,40 @@ describe('agentsCommand', () => { ); }); - it('should reload the agent registry when refresh subcommand is called', async () => { + it('should reload the agent registry when reload subcommand is called', async () => { const reloadSpy = vi.fn().mockResolvedValue(undefined); mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ reload: reloadSpy, }); - const refreshCommand = agentsCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', ); - expect(refreshCommand).toBeDefined(); + expect(reloadCommand).toBeDefined(); - const result = await refreshCommand!.action!(mockContext, ''); + const result = await reloadCommand!.action!(mockContext, ''); expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Reloading agent registry...', + }), + ); expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Agents refreshed successfully.', + content: 'Agents reloaded successfully', }); }); - it('should show an error if agent registry is not available during refresh', async () => { + it('should show an error if agent registry is not available during reload', async () => { mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); - const refreshCommand = agentsCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', ); - const result = await refreshCommand!.action!(mockContext, ''); + const result = await reloadCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'message', diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index a7161dfb77..3658c741ff 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -322,9 +322,9 @@ const configCommand: SlashCommand = { completion: completeAllAgents, }; -const agentsRefreshCommand: SlashCommand = { - name: 'refresh', - altNames: ['reload'], +const agentsReloadCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], description: 'Reload the agent registry', kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { @@ -340,7 +340,7 @@ const agentsRefreshCommand: SlashCommand = { context.ui.addItem({ type: MessageType.INFO, - text: 'Refreshing agent registry...', + text: 'Reloading agent registry...', }); await agentRegistry.reload(); @@ -348,7 +348,7 @@ const agentsRefreshCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'Agents refreshed successfully.', + content: 'Agents reloaded successfully', }; }, }; @@ -359,7 +359,7 @@ export const agentsCommand: SlashCommand = { kind: CommandKind.BUILT_IN, subCommands: [ agentsListCommand, - agentsRefreshCommand, + agentsReloadCommand, enableCommand, disableCommand, configCommand, diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index f1a9e13416..89147a1b90 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -892,7 +892,7 @@ describe('extensionsCommand', () => { }); }); - describe('restart', () => { + describe('reload', () => { let restartAction: SlashCommand['action']; let mockRestartExtension: MockedFunction< typeof ExtensionLoader.prototype.restartExtension @@ -900,7 +900,7 @@ describe('extensionsCommand', () => { beforeEach(() => { restartAction = extensionsCommand().subCommands?.find( - (c) => c.name === 'restart', + (c) => c.name === 'reload', )?.action; expect(restartAction).not.toBeNull(); @@ -911,7 +911,7 @@ describe('extensionsCommand', () => { getExtensions: mockGetExtensions, restartExtension: mockRestartExtension, })); - mockContext.invocation!.name = 'restart'; + mockContext.invocation!.name = 'reload'; }); it('should show a message if no extensions are installed', async () => { @@ -930,7 +930,7 @@ describe('extensionsCommand', () => { }); }); - it('restarts all active extensions when --all is provided', async () => { + it('reloads all active extensions when --all is provided', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, { name: 'ext2', isActive: true }, @@ -946,13 +946,13 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: 'Restarting 2 extensions...', + text: 'Reloading 2 extensions...', }), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: '2 extensions restarted successfully.', + text: '2 extensions reloaded successfully', }), ); expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ @@ -986,7 +986,7 @@ describe('extensionsCommand', () => { ); }); - it('restarts only specified active extensions', async () => { + it('reloads only specified active extensions', async () => { const mockExtensions = [ { name: 'ext1', isActive: false }, { name: 'ext2', isActive: true }, @@ -1024,13 +1024,13 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Usage: /extensions restart |--all', + text: 'Usage: /extensions reload |--all', }), ); expect(mockRestartExtension).not.toHaveBeenCalled(); }); - it('handles errors during extension restart', async () => { + it('handles errors during extension reload', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; @@ -1043,7 +1043,7 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Failed to restart some extensions:\n ext1: Failed to restart', + text: 'Failed to reload some extensions:\n ext1: Failed to restart', }), ); }); @@ -1066,7 +1066,7 @@ describe('extensionsCommand', () => { ); }); - it('does not restart any extensions if none are found', async () => { + it('does not reload any extensions if none are found', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; @@ -1083,8 +1083,8 @@ describe('extensionsCommand', () => { ); }); - it('should suggest only enabled extension names for the restart command', async () => { - mockContext.invocation!.name = 'restart'; + it('should suggest only enabled extension names for the reload command', async () => { + mockContext.invocation!.name = 'reload'; const mockExtensions = [ { name: 'ext1', isActive: true }, { name: 'ext2', isActive: false }, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 3a48b9e173..051d337019 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -176,7 +176,7 @@ async function restartAction( if (!all && names?.length === 0) { context.ui.addItem({ type: MessageType.ERROR, - text: 'Usage: /extensions restart |--all', + text: 'Usage: /extensions reload |--all', }); return Promise.resolve(); } @@ -208,12 +208,12 @@ async function restartAction( const s = extensionsToRestart.length > 1 ? 's' : ''; - const restartingMessage = { + const reloadingMessage = { type: MessageType.INFO, - text: `Restarting ${extensionsToRestart.length} extension${s}...`, + text: `Reloading ${extensionsToRestart.length} extension${s}...`, color: theme.text.primary, }; - context.ui.addItem(restartingMessage); + context.ui.addItem(reloadingMessage); const results = await Promise.allSettled( extensionsToRestart.map(async (extension) => { @@ -254,12 +254,12 @@ async function restartAction( .join('\n '); context.ui.addItem({ type: MessageType.ERROR, - text: `Failed to restart some extensions:\n ${errorMessages}`, + text: `Failed to reload some extensions:\n ${errorMessages}`, }); } else { const infoItem: HistoryItemInfo = { type: MessageType.INFO, - text: `${extensionsToRestart.length} extension${s} restarted successfully.`, + text: `${extensionsToRestart.length} extension${s} reloaded successfully`, icon: emptyIcon, color: theme.text.primary, }; @@ -729,7 +729,8 @@ export function completeExtensions( } if ( context.invocation?.name === 'disable' || - context.invocation?.name === 'restart' + context.invocation?.name === 'restart' || + context.invocation?.name === 'reload' ) { extensions = extensions.filter((ext) => ext.isActive); } @@ -824,9 +825,10 @@ const exploreExtensionsCommand: SlashCommand = { action: exploreAction, }; -const restartCommand: SlashCommand = { - name: 'restart', - description: 'Restart all extensions', +const reloadCommand: SlashCommand = { + name: 'reload', + altNames: ['restart'], + description: 'Reload all extensions', kind: CommandKind.BUILT_IN, autoExecute: false, action: restartAction, @@ -863,7 +865,7 @@ export function extensionsCommand( listExtensionsCommand, updateExtensionsCommand, exploreExtensionsCommand, - restartCommand, + reloadCommand, ...conditionalCommands, ], action: (context, args) => diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index e488db780f..9ccaaf4273 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -149,7 +149,7 @@ const authCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Successfully authenticated and refreshed tools for '${serverName}'.`, + content: `Successfully authenticated and reloaded tools for '${serverName}'`, }; } catch (error) { return { @@ -325,10 +325,10 @@ const schemaCommand: SlashCommand = { action: (context) => listAction(context, true, true), }; -const refreshCommand: SlashCommand = { - name: 'refresh', - altNames: ['reload'], - description: 'Restarts MCP servers', +const reloadCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], + description: 'Reloads MCP servers', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( @@ -354,7 +354,7 @@ const refreshCommand: SlashCommand = { context.ui.addItem({ type: 'info', - text: 'Restarting MCP servers...', + text: 'Reloading MCP servers...', }); await mcpClientManager.restart(); @@ -460,7 +460,7 @@ async function handleEnableDisable( const mcpClientManager = config.getMcpClientManager(); if (mcpClientManager) { context.ui.addItem( - { type: 'info', text: 'Restarting MCP servers...' }, + { type: 'info', text: 'Reloading MCP servers...' }, Date.now(), ); await mcpClientManager.restart(); @@ -521,7 +521,7 @@ export const mcpCommand: SlashCommand = { descCommand, schemaCommand, authCommand, - refreshCommand, + reloadCommand, enableCommand, disableCommand, ], diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 1a2c7e3936..2d70b67357 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -39,13 +39,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { type: 'message', messageType: 'info', - content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, + content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, }; } return { type: 'message', messageType: 'info', - content: 'Memory refreshed successfully.', + content: 'Memory reloaded successfully.', }; }), showMemory: vi.fn(), @@ -63,7 +63,7 @@ describe('memoryCommand', () => { let mockContext: CommandContext; const getSubCommand = ( - name: 'show' | 'add' | 'refresh' | 'list', + name: 'show' | 'add' | 'reload' | 'list', ): SlashCommand => { const subCommand = memoryCommand.subCommands?.find( (cmd) => cmd.name === name, @@ -206,15 +206,15 @@ describe('memoryCommand', () => { }); }); - describe('/memory refresh', () => { - let refreshCommand: SlashCommand; + describe('/memory reload', () => { + let reloadCommand: SlashCommand; let mockSetUserMemory: Mock; let mockSetGeminiMdFileCount: Mock; let mockSetGeminiMdFilePaths: Mock; let mockContextManagerRefresh: Mock; beforeEach(() => { - refreshCommand = getSubCommand('refresh'); + reloadCommand = getSubCommand('reload'); mockSetUserMemory = vi.fn(); mockSetGeminiMdFileCount = vi.fn(); mockSetGeminiMdFilePaths = vi.fn(); @@ -266,7 +266,7 @@ describe('memoryCommand', () => { }); it('should use ContextManager.refresh when JIT is enabled', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + if (!reloadCommand.action) throw new Error('Command has no action'); // Enable JIT in mock config const config = mockContext.services.config; @@ -276,7 +276,7 @@ describe('memoryCommand', () => { vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content'); vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockContextManagerRefresh).toHaveBeenCalledOnce(); expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled(); @@ -284,29 +284,29 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. Loaded 18 characters from 3 file(s).', + text: 'Memory reloaded successfully. Loaded 18 characters from 3 file(s).', }, expect.any(Number), ); }); - it('should display success message when memory is refreshed with content (Legacy)', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display success message when memory is reloaded with content (Legacy)', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const successMessage = { type: 'message', messageType: MessageType.INFO, content: - 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', + 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', }; mockRefreshMemory.mockResolvedValue(successMessage); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, expect.any(Number), ); @@ -316,42 +316,42 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', + text: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', }, expect.any(Number), ); }); - it('should display success message when memory is refreshed with no content', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display success message when memory is reloaded with no content', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const successMessage = { type: 'message', messageType: MessageType.INFO, - content: 'Memory refreshed successfully. No memory content found.', + content: 'Memory reloaded successfully. No memory content found.', }; mockRefreshMemory.mockResolvedValue(successMessage); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. No memory content found.', + text: 'Memory reloaded successfully. No memory content found.', }, expect.any(Number), ); }); - it('should display an error message if refreshing fails', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display an error message if reloading fails', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const error = new Error('Failed to read memory files.'); mockRefreshMemory.mockRejectedValue(error); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockSetUserMemory).not.toHaveBeenCalled(); @@ -361,27 +361,27 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, - text: `Error refreshing memory: ${error.message}`, + text: `Error reloading memory: ${error.message}`, }, expect.any(Number), ); }); it('should not throw if config service is unavailable', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + if (!reloadCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ services: { config: null }, }); await expect( - refreshCommand.action(nullConfigContext, ''), + reloadCommand.action(nullConfigContext, ''), ).resolves.toBeUndefined(); expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index a31280f824..575c3a32eb 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -63,16 +63,16 @@ export const memoryCommand: SlashCommand = { }, }, { - name: 'refresh', - altNames: ['reload'], - description: 'Refresh the memory from the source', + name: 'reload', + altNames: ['refresh'], + description: 'Reload the memory from the source', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { context.ui.addItem( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, Date.now(), ); @@ -95,7 +95,7 @@ export const memoryCommand: SlashCommand = { { type: MessageType.ERROR, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - text: `Error refreshing memory: ${(error as Error).message}`, + text: `Error reloading memory: ${(error as Error).message}`, }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 2f36c333b9..57fff84b6b 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -20,6 +20,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { UserAccountManager: vi.fn().mockImplementation(() => ({ getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'), })), + getG1CreditBalance: vi.fn().mockReturnValue(undefined), }; }); diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index 5db682e751..488abfc9aa 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -34,7 +34,7 @@ export const INFORMATIVE_TIPS = [ 'Define custom context file names, like CONTEXT.md (settings.json)โ€ฆ', 'Set max directories to scan for context files (/settings)โ€ฆ', 'Expand your workspace with additional directories (/directory)โ€ฆ', - 'Control how /memory refresh loads context files (/settings)โ€ฆ', + 'Control how /memory reload loads context files (/settings)โ€ฆ', 'Toggle respect for .gitignore files in context (/settings)โ€ฆ', 'Toggle respect for .geminiignore files in context (/settings)โ€ฆ', 'Enable recursive file search for @-file completions (/settings)โ€ฆ', @@ -142,10 +142,10 @@ export const INFORMATIVE_TIPS = [ 'Create a project-specific GEMINI.md file with /initโ€ฆ', 'List configured MCP servers and tools with /mcp listโ€ฆ', 'Authenticate with an OAuth-enabled MCP server with /mcp authโ€ฆ', - 'Restart MCP servers with /mcp refreshโ€ฆ', + 'Reload MCP servers with /mcp reloadโ€ฆ', 'See the current instructional context with /memory showโ€ฆ', 'Add content to the instructional memory with /memory addโ€ฆ', - 'Reload instructional context from GEMINI.md files with /memory refreshโ€ฆ', + 'Reload instructional context from GEMINI.md files with /memory reloadโ€ฆ', 'List the paths of the GEMINI.md files in use with /memory listโ€ฆ', 'Choose your Gemini model with /modelโ€ฆ', 'Display the privacy notice with /privacyโ€ฆ', diff --git a/packages/core/src/commands/memory.test.ts b/packages/core/src/commands/memory.test.ts index 18c2b07f49..37ff15052f 100644 --- a/packages/core/src/commands/memory.test.ts +++ b/packages/core/src/commands/memory.test.ts @@ -136,7 +136,7 @@ describe('memory commands', () => { if (result.type === 'message') { expect(result.messageType).toBe('info'); expect(result.content).toBe( - 'Memory refreshed successfully. Loaded 33 characters from 2 file(s).', + 'Memory reloaded successfully. Loaded 33 characters from 2 file(s)', ); } }); @@ -153,7 +153,7 @@ describe('memory commands', () => { if (result.type === 'message') { expect(result.messageType).toBe('info'); expect(result.content).toBe( - 'Memory refreshed successfully. No memory content found.', + 'Memory reloaded successfully. No memory content found', ); } }); diff --git a/packages/core/src/commands/memory.ts b/packages/core/src/commands/memory.ts index e9a493e9b3..3d1696ed2b 100644 --- a/packages/core/src/commands/memory.ts +++ b/packages/core/src/commands/memory.ts @@ -64,9 +64,9 @@ export async function refreshMemory( let content: string; if (memoryContent.length > 0) { - content = `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`; + content = `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s)`; } else { - content = 'Memory refreshed successfully. No memory content found.'; + content = 'Memory reloaded successfully. No memory content found'; } return { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 1df28cdac3..b2ef942711 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1222,8 +1222,8 @@ }, "loadMemoryFromIncludeDirectories": { "title": "Load Memory From Include Directories", - "description": "Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.", - "markdownDescription": "Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.\n\n- Category: `Context`\n- Requires restart: `no`\n- Default: `false`", + "description": "Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.", + "markdownDescription": "Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.\n\n- Category: `Context`\n- Requires restart: `no`\n- Default: `false`", "default": false, "type": "boolean" }, From 237864eb6334cb58341a83535d7fb3d5203c5a2e Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Sat, 7 Mar 2026 15:17:10 -0800 Subject: [PATCH 011/202] feat(cli): Invert quota language to 'percent used' (#20100) Co-authored-by: jacob314 --- .github/workflows/ci.yml | 2 +- packages/cli/src/config/policy.test.ts | 1 - .../cli/src/ui/components/Footer.test.tsx | 2 +- .../src/ui/components/QuotaDisplay.test.tsx | 18 +- .../cli/src/ui/components/QuotaDisplay.tsx | 44 +-- .../cli/src/ui/components/QuotaStatsInfo.tsx | 27 +- .../src/ui/components/StatsDisplay.test.tsx | 22 +- .../cli/src/ui/components/StatsDisplay.tsx | 310 ++++++++++++------ .../__snapshots__/Footer.test.tsx.snap | 4 +- .../__snapshots__/QuotaDisplay.test.tsx.snap | 14 +- .../SessionSummaryDisplay.test.tsx.snap | 7 +- .../__snapshots__/StatsDisplay.test.tsx.snap | 48 ++- .../ui/components/messages/ToolMessage.tsx | 1 + .../src/ui/components/messages/ToolShared.tsx | 2 + packages/cli/src/ui/hooks/toolMapping.test.ts | 1 - packages/cli/src/ui/hooks/useToolScheduler.ts | 28 +- packages/cli/src/ui/utils/displayUtils.ts | 19 ++ packages/cli/src/ui/utils/formatters.test.ts | 36 ++ packages/cli/src/ui/utils/formatters.ts | 58 +++- .../core/src/policy/policy-engine.test.ts | 2 - packages/core/src/tools/mcp-client.test.ts | 1 - 21 files changed, 432 insertions(+), 215 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a358ad8b07..973d88f5f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,7 +169,7 @@ jobs: npm run test:ci --workspace @google/gemini-cli else # Explicitly list non-cli packages to ensure they are sharded correctly - npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present + npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false npm run test:scripts fi diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index 9baccd3359..8d368bfb91 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -183,7 +183,6 @@ describe('resolveWorkspacePolicyState', () => { setAutoAcceptWorkspacePolicies(originalValue); } }); - it('should not return workspace policies if cwd is the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index b79b005d85..667168dbc5 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -235,7 +235,7 @@ describe('