diff --git a/package.json b/package.json index 2f12d6e3ec..3ef7a6f370 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.7.0-nightly.20250917.0b10ba2c" }, "scripts": { - "start": "node scripts/start.js", + "start": "cross-env node scripts/start.js", "start:a2a-server": "CODER_AGENT_PORT=41242 npm run start --workspace @google/gemini-cli-a2a-server", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "auth:npm": "npx google-artifactregistry-auth", diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index c153a93aa2..0da33fd992 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; import { render } from 'ink'; import { AppContainer } from './ui/AppContainer.js'; import { loadCliConfig, parseArguments } from './config/config.js'; @@ -212,10 +213,19 @@ export async function startInteractiveUI( ); }; - const instance = render(, { - exitOnCtrlC: false, - isScreenReaderEnabled: config.getScreenReader(), - }); + const instance = render( + process.env['DEBUG'] ? ( + + + + ) : ( + + ), + { + exitOnCtrlC: false, + isScreenReaderEnabled: config.getScreenReader(), + }, + ); checkForUpdates() .then((info) => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 85559aa2da..6bb73b2530 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -85,6 +85,7 @@ import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { FocusContext } from './contexts/FocusContext.js'; import type { ExtensionUpdateState } from './state/extensions.js'; import { checkForAllExtensionUpdates } from '../config/extension.js'; @@ -1210,7 +1211,9 @@ Logging in with Google... Please restart Gemini CLI to continue. startupWarnings: props.startupWarnings || [], }} > - + + + diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 4ca52c2e9d..db7255afb0 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -18,6 +18,7 @@ import { OverflowProvider } from '../contexts/OverflowContext.js'; import { theme } from '../semantic-colors.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useFocusState } from '../contexts/FocusContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; @@ -32,6 +33,7 @@ export const Composer = () => { const config = useConfig(); const settings = useSettings(); const uiState = useUIState(); + const isFocused = useFocusState(); const uiActions = useUIActions(); const { vimEnabled, vimMode } = useVimMode(); const terminalWidth = process.stdout.columns; @@ -192,7 +194,7 @@ export const Composer = () => { setShellModeActive={uiActions.setShellModeActive} approvalMode={showAutoAcceptIndicator} onEscapePromptChange={uiActions.onEscapePromptChange} - focus={uiState.isFocused} + focus={isFocused} vimHandleInput={uiActions.vimHandleInput} isShellFocused={uiState.shellFocused} placeholder={ diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 2bd0296781..118a3a4979 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -1495,6 +1495,53 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots expect(stripAnsi('')).toBe(''); }); }); + + describe('Memoization', () => { + it('should keep action references stable across re-renders', () => { + // We pass a stable `isValidPath` so that callbacks that depend on it + // are not recreated on every render. + const isValidPath = () => false; + const { result, rerender } = renderHook(() => + useTextBuffer({ viewport, isValidPath }), + ); + + const initialInsert = result.current.insert; + const initialBackspace = result.current.backspace; + const initialMove = result.current.move; + const initialHandleInput = result.current.handleInput; + + rerender(); + + expect(result.current.insert).toBe(initialInsert); + expect(result.current.backspace).toBe(initialBackspace); + expect(result.current.move).toBe(initialMove); + expect(result.current.handleInput).toBe(initialHandleInput); + }); + + it('should have memoized actions that operate on the latest state', () => { + const isValidPath = () => false; + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath }), + ); + + // Store a reference to the memoized insert function. + const memoizedInsert = result.current.insert; + + // Update the buffer state. + act(() => { + result.current.insert('hello'); + }); + expect(getBufferState(result).text).toBe('hello'); + + // Now, call the original memoized function reference. + act(() => { + memoizedInsert(' world'); + }); + + // It should have operated on the updated state. + expect(getBufferState(result).text).toBe('hello world'); + }); + }); }); describe('offsetToLogicalPos', () => { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 2799c36665..33548238f6 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1984,71 +1984,135 @@ export function useTextBuffer({ dispatch({ type: 'move_to_offset', payload: { offset } }); }, []); - const returnValue: TextBuffer = { - lines, - text, - cursor: [cursorRow, cursorCol], - preferredCol, - selectionAnchor, + const returnValue: TextBuffer = useMemo( + () => ({ + lines, + text, + cursor: [cursorRow, cursorCol], + preferredCol, + selectionAnchor, - allVisualLines: visualLines, - viewportVisualLines: renderedVisualLines, - visualCursor, - visualScrollRow, - visualToLogicalMap, + allVisualLines: visualLines, + viewportVisualLines: renderedVisualLines, + visualCursor, + visualScrollRow, + visualToLogicalMap, - setText, - insert, - newline, - backspace, - del, - move, - undo, - redo, - replaceRange, - replaceRangeByOffset, - moveToOffset, - deleteWordLeft, - deleteWordRight, + setText, + insert, + newline, + backspace, + del, + move, + undo, + redo, + replaceRange, + replaceRangeByOffset, + moveToOffset, + deleteWordLeft, + deleteWordRight, - killLineRight, - killLineLeft, - handleInput, - openInExternalEditor, - // Vim-specific operations - vimDeleteWordForward, - vimDeleteWordBackward, - vimDeleteWordEnd, - vimChangeWordForward, - vimChangeWordBackward, - vimChangeWordEnd, - vimDeleteLine, - vimChangeLine, - vimDeleteToEndOfLine, - vimChangeToEndOfLine, - vimChangeMovement, - vimMoveLeft, - vimMoveRight, - vimMoveUp, - vimMoveDown, - vimMoveWordForward, - vimMoveWordBackward, - vimMoveWordEnd, - vimDeleteChar, - vimInsertAtCursor, - vimAppendAtCursor, - vimOpenLineBelow, - vimOpenLineAbove, - vimAppendAtLineEnd, - vimInsertAtLineStart, - vimMoveToLineStart, - vimMoveToLineEnd, - vimMoveToFirstNonWhitespace, - vimMoveToFirstLine, - vimMoveToLastLine, - vimMoveToLine, - vimEscapeInsertMode, - }; + killLineRight, + killLineLeft, + handleInput, + openInExternalEditor, + // Vim-specific operations + vimDeleteWordForward, + vimDeleteWordBackward, + vimDeleteWordEnd, + vimChangeWordForward, + vimChangeWordBackward, + vimChangeWordEnd, + vimDeleteLine, + vimChangeLine, + vimDeleteToEndOfLine, + vimChangeToEndOfLine, + vimChangeMovement, + vimMoveLeft, + vimMoveRight, + vimMoveUp, + vimMoveDown, + vimMoveWordForward, + vimMoveWordBackward, + vimMoveWordEnd, + vimDeleteChar, + vimInsertAtCursor, + vimAppendAtCursor, + vimOpenLineBelow, + vimOpenLineAbove, + vimAppendAtLineEnd, + vimInsertAtLineStart, + vimMoveToLineStart, + vimMoveToLineEnd, + vimMoveToFirstNonWhitespace, + vimMoveToFirstLine, + vimMoveToLastLine, + vimMoveToLine, + vimEscapeInsertMode, + }), + [ + lines, + text, + cursorRow, + cursorCol, + preferredCol, + selectionAnchor, + visualLines, + renderedVisualLines, + visualCursor, + visualScrollRow, + setText, + insert, + newline, + backspace, + del, + move, + undo, + redo, + replaceRange, + replaceRangeByOffset, + moveToOffset, + deleteWordLeft, + deleteWordRight, + killLineRight, + killLineLeft, + handleInput, + openInExternalEditor, + vimDeleteWordForward, + vimDeleteWordBackward, + vimDeleteWordEnd, + vimChangeWordForward, + vimChangeWordBackward, + vimChangeWordEnd, + vimDeleteLine, + vimChangeLine, + vimDeleteToEndOfLine, + vimChangeToEndOfLine, + vimChangeMovement, + vimMoveLeft, + vimMoveRight, + vimMoveUp, + vimMoveDown, + vimMoveWordForward, + vimMoveWordBackward, + vimMoveWordEnd, + vimDeleteChar, + vimInsertAtCursor, + vimAppendAtCursor, + vimOpenLineBelow, + vimOpenLineAbove, + vimAppendAtLineEnd, + vimInsertAtLineStart, + vimMoveToLineStart, + vimMoveToLineEnd, + vimMoveToFirstNonWhitespace, + vimMoveToFirstLine, + vimMoveToLastLine, + vimMoveToLine, + vimEscapeInsertMode, + visualToLogicalMap, + ], + ); return returnValue; } diff --git a/packages/cli/src/ui/contexts/FocusContext.tsx b/packages/cli/src/ui/contexts/FocusContext.tsx new file mode 100644 index 0000000000..791f2aac22 --- /dev/null +++ b/packages/cli/src/ui/contexts/FocusContext.tsx @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createContext, useContext } from 'react'; + +export const FocusContext = createContext(true); + +export const useFocusState = () => useContext(FocusContext); diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx index 7e46286993..dbb89628da 100644 --- a/packages/cli/src/ui/contexts/SessionContext.test.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx @@ -113,6 +113,88 @@ describe('SessionStatsContext', () => { expect(stats?.lastPromptTokenCount).toBe(100); }); + it('should not update metrics if the data is the same', () => { + const contextRef: MutableRefObject< + ReturnType | undefined + > = { current: undefined }; + + let renderCount = 0; + const CountingTestHarness = () => { + contextRef.current = useSessionStats(); + renderCount++; + return null; + }; + + render( + + + , + ); + + expect(renderCount).toBe(1); + + const metrics: SessionMetrics = { + models: { + 'gemini-pro': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 10, + candidates: 20, + total: 30, + cached: 0, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }; + + act(() => { + uiTelemetryService.emit('update', { metrics, lastPromptTokenCount: 10 }); + }); + + expect(renderCount).toBe(2); + + act(() => { + uiTelemetryService.emit('update', { metrics, lastPromptTokenCount: 10 }); + }); + + expect(renderCount).toBe(2); + + const newMetrics = { + ...metrics, + models: { + 'gemini-pro': { + api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 }, + tokens: { + prompt: 20, + candidates: 40, + total: 60, + cached: 0, + thoughts: 0, + tool: 0, + }, + }, + }, + }; + act(() => { + uiTelemetryService.emit('update', { + metrics: newMetrics, + lastPromptTokenCount: 20, + }); + }); + + expect(renderCount).toBe(3); + }); + it('should throw an error when useSessionStats is used outside of a provider', () => { // Suppress console.error for this test since we expect an error const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx index 676a12fcbd..16b78fded0 100644 --- a/packages/cli/src/ui/contexts/SessionContext.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -14,10 +14,129 @@ import { useEffect, } from 'react'; -import type { SessionMetrics, ModelMetrics } from '@google/gemini-cli-core'; +import type { + SessionMetrics, + ModelMetrics, + ToolCallStats, +} from '@google/gemini-cli-core'; import { uiTelemetryService, sessionId } from '@google/gemini-cli-core'; -// --- Interface Definitions --- +export enum ToolCallDecision { + ACCEPT = 'accept', + REJECT = 'reject', + MODIFY = 'modify', + AUTO_ACCEPT = 'auto_accept', +} + +function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean { + if ( + a.api.totalRequests !== b.api.totalRequests || + a.api.totalErrors !== b.api.totalErrors || + a.api.totalLatencyMs !== b.api.totalLatencyMs + ) { + return false; + } + if ( + a.tokens.prompt !== b.tokens.prompt || + a.tokens.candidates !== b.tokens.candidates || + a.tokens.total !== b.tokens.total || + a.tokens.cached !== b.tokens.cached || + a.tokens.thoughts !== b.tokens.thoughts || + a.tokens.tool !== b.tokens.tool + ) { + return false; + } + return true; +} + +function areToolCallStatsEqual(a: ToolCallStats, b: ToolCallStats): boolean { + if ( + a.count !== b.count || + a.success !== b.success || + a.fail !== b.fail || + a.durationMs !== b.durationMs + ) { + return false; + } + if ( + a.decisions[ToolCallDecision.ACCEPT] !== + b.decisions[ToolCallDecision.ACCEPT] || + a.decisions[ToolCallDecision.REJECT] !== + b.decisions[ToolCallDecision.REJECT] || + a.decisions[ToolCallDecision.MODIFY] !== + b.decisions[ToolCallDecision.MODIFY] || + a.decisions[ToolCallDecision.AUTO_ACCEPT] !== + b.decisions[ToolCallDecision.AUTO_ACCEPT] + ) { + return false; + } + return true; +} + +function areMetricsEqual(a: SessionMetrics, b: SessionMetrics): boolean { + if (a === b) return true; + if (!a || !b) return false; + + // Compare files + if ( + a.files.totalLinesAdded !== b.files.totalLinesAdded || + a.files.totalLinesRemoved !== b.files.totalLinesRemoved + ) { + return false; + } + + // Compare tools + const toolsA = a.tools; + const toolsB = b.tools; + if ( + toolsA.totalCalls !== toolsB.totalCalls || + toolsA.totalSuccess !== toolsB.totalSuccess || + toolsA.totalFail !== toolsB.totalFail || + toolsA.totalDurationMs !== toolsB.totalDurationMs + ) { + return false; + } + + // Compare tool decisions + if ( + toolsA.totalDecisions[ToolCallDecision.ACCEPT] !== + toolsB.totalDecisions[ToolCallDecision.ACCEPT] || + toolsA.totalDecisions[ToolCallDecision.REJECT] !== + toolsB.totalDecisions[ToolCallDecision.REJECT] || + toolsA.totalDecisions[ToolCallDecision.MODIFY] !== + toolsB.totalDecisions[ToolCallDecision.MODIFY] || + toolsA.totalDecisions[ToolCallDecision.AUTO_ACCEPT] !== + toolsB.totalDecisions[ToolCallDecision.AUTO_ACCEPT] + ) { + return false; + } + + // Compare tools.byName + const toolsByNameAKeys = Object.keys(toolsA.byName); + const toolsByNameBKeys = Object.keys(toolsB.byName); + if (toolsByNameAKeys.length !== toolsByNameBKeys.length) return false; + + for (const key of toolsByNameAKeys) { + const toolA = toolsA.byName[key]; + const toolB = toolsB.byName[key]; + if (!toolB || !areToolCallStatsEqual(toolA, toolB)) { + return false; + } + } + + // Compare models + const modelsAKeys = Object.keys(a.models); + const modelsBKeys = Object.keys(b.models); + if (modelsAKeys.length !== modelsBKeys.length) return false; + + for (const key of modelsAKeys) { + if (!b.models[key] || !areModelMetricsEqual(a.models[key], b.models[key])) { + return false; + } + } + + return true; +} export type { SessionMetrics, ModelMetrics }; @@ -80,11 +199,19 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({ metrics: SessionMetrics; lastPromptTokenCount: number; }) => { - setStats((prevState) => ({ - ...prevState, - metrics, - lastPromptTokenCount, - })); + setStats((prevState) => { + if ( + prevState.lastPromptTokenCount === lastPromptTokenCount && + areMetricsEqual(prevState.metrics, metrics) + ) { + return prevState; + } + return { + ...prevState, + metrics, + lastPromptTokenCount, + }; + }); }; uiTelemetryService.on('update', handleUpdate); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 669ac65533..ae57f2e587 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -78,7 +78,6 @@ export interface UIState { ctrlCPressedOnce: boolean; ctrlDPressedOnce: boolean; showEscapePrompt: boolean; - isFocused: boolean; elapsedTime: number; currentLoadingPhrase: string; historyRemountKey: number; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index c52247b35a..bdbc1d922d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -114,7 +114,7 @@ vi.mock('./useStateAndRef.js', () => ({ } ref.current = val; }); - return [ref, setVal]; + return [val, ref, setVal]; }), })); @@ -2216,6 +2216,72 @@ describe('useGeminiStream', () => { }); }); + it('should memoize pendingHistoryItems', () => { + mockUseReactToolScheduler.mockReturnValue([ + [], + mockScheduleToolCalls, + mockCancelAllToolCalls, + mockMarkToolsAsSubmitted, + ]); + + const { result, rerender } = renderHook(() => + useGeminiStream( + mockConfig.getGeminiClient(), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + const firstResult = result.current.pendingHistoryItems; + rerender(); + const secondResult = result.current.pendingHistoryItems; + + expect(firstResult).toStrictEqual(secondResult); + + const newToolCalls: TrackedToolCall[] = [ + { + request: { callId: 'call1', name: 'tool1', args: {} }, + status: 'executing', + tool: { + name: 'tool1', + displayName: 'tool1', + description: 'desc1', + build: vi.fn(), + }, + invocation: { + getDescription: () => 'Mock description', + }, + } as unknown as TrackedExecutingToolCall, + ]; + + mockUseReactToolScheduler.mockReturnValue([ + newToolCalls, + mockScheduleToolCalls, + mockCancelAllToolCalls, + mockMarkToolsAsSubmitted, + ]); + + rerender(); + const thirdResult = result.current.pendingHistoryItems; + + expect(thirdResult).not.toStrictEqual(secondResult); + }); + it('should reset thought to null when user cancels', async () => { // Mock a stream that yields a thought then gets cancelled mockSendMessageStream.mockReturnValue( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 3566695419..da7c2c34c9 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -111,7 +111,7 @@ export const useGeminiStream = ( const turnCancelledRef = useRef(false); const [isResponding, setIsResponding] = useState(false); const [thought, setThought] = useState(null); - const [pendingHistoryItemRef, setPendingHistoryItem] = + const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); const processedMemoryToolsRef = useRef>(new Set()); const { startNewPrompt, getPromptCount } = useSessionStats(); @@ -1015,10 +1015,13 @@ export const useGeminiStream = ( ], ); - const pendingHistoryItems = [ - pendingHistoryItemRef.current, - pendingToolCallGroupDisplay, - ].filter((i) => i !== undefined && i !== null); + const pendingHistoryItems = useMemo( + () => + [pendingHistoryItem, pendingToolCallGroupDisplay].filter( + (i) => i !== undefined && i !== null, + ), + [pendingHistoryItem, pendingToolCallGroupDisplay], + ); useEffect(() => { const saveRestorableToolCalls = async () => { diff --git a/packages/cli/src/ui/hooks/useStateAndRef.ts b/packages/cli/src/ui/hooks/useStateAndRef.ts index d073a1dc62..8a10bab4cc 100644 --- a/packages/cli/src/ui/hooks/useStateAndRef.ts +++ b/packages/cli/src/ui/hooks/useStateAndRef.ts @@ -15,7 +15,7 @@ export const useStateAndRef = < >( initialValue: T, ) => { - const [_, setState] = React.useState(initialValue); + const [state, setState] = React.useState(initialValue); const ref = React.useRef(initialValue); const setStateInternal = React.useCallback( @@ -32,5 +32,5 @@ export const useStateAndRef = < [], ); - return [ref, setStateInternal] as const; + return [state, ref, setStateInternal] as const; };