From 00fdb302118ede5adc2a42679123b421f04c4165 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 30 Jan 2026 16:11:14 -0800 Subject: [PATCH 1/3] Support ctrl-C and Ctrl-D correctly Refactor so InputPrompt has priority over AppContainer for input handling. (#17993) --- packages/cli/src/ui/AppContainer.test.tsx | 84 ++++++++++++++----- packages/cli/src/ui/AppContainer.tsx | 28 ++++--- .../src/ui/components/InputPrompt.test.tsx | 30 ++++++- .../cli/src/ui/components/InputPrompt.tsx | 53 ++++++------ .../ui/components/shared/text-buffer.test.ts | 44 ++++++++++ .../src/ui/components/shared/text-buffer.ts | 16 ++++ 6 files changed, 193 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index ef0a24cd92..638eb53d5d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -371,7 +371,9 @@ describe('AppContainer State Management', () => { mockedUseTextBuffer.mockReturnValue({ text: '', setText: vi.fn(), - // Add other properties if AppContainer uses them + lines: [''], + cursor: [0, 0], + handleInput: vi.fn().mockReturnValue(false), }); mockedUseLogger.mockReturnValue({ getPreviousUserMessages: vi.fn().mockResolvedValue([]), @@ -1900,7 +1902,7 @@ describe('AppContainer State Management', () => { }); describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { - let handleGlobalKeypress: (key: Key) => void; + let handleGlobalKeypress: (key: Key) => boolean; let mockHandleSlashCommand: Mock; let mockCancelOngoingRequest: Mock; let rerender: () => void; @@ -1935,9 +1937,11 @@ describe('AppContainer State Management', () => { beforeEach(() => { // Capture the keypress handler from the AppContainer - mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => { - handleGlobalKeypress = callback; - }); + mockedUseKeypress.mockImplementation( + (callback: (key: Key) => boolean) => { + handleGlobalKeypress = callback; + }, + ); // Mock slash command handler mockHandleSlashCommand = vi.fn(); @@ -1961,6 +1965,9 @@ describe('AppContainer State Management', () => { mockedUseTextBuffer.mockReturnValue({ text: '', setText: vi.fn(), + lines: [''], + cursor: [0, 0], + handleInput: vi.fn().mockReturnValue(false), }); vi.useFakeTimers(); @@ -2020,19 +2027,6 @@ describe('AppContainer State Management', () => { }); describe('CTRL+D', () => { - it('should do nothing if text buffer is not empty', async () => { - mockedUseTextBuffer.mockReturnValue({ - text: 'some text', - setText: vi.fn(), - }); - await setupKeypressTest(); - - pressKey({ name: 'd', ctrl: true }, 2); - - expect(mockHandleSlashCommand).not.toHaveBeenCalled(); - unmount(); - }); - it('should quit on second press if buffer is empty', async () => { await setupKeypressTest(); @@ -2047,6 +2041,50 @@ describe('AppContainer State Management', () => { unmount(); }); + it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => { + mockedUseTextBuffer.mockReturnValue({ + text: 'some text', + setText: vi.fn(), + lines: ['some text'], + cursor: [0, 9], // At the end + handleInput: vi.fn().mockReturnValue(false), + }); + await setupKeypressTest(); + + // Capture return value + let result = true; + const originalPressKey = (key: Partial) => { + act(() => { + result = handleGlobalKeypress({ + name: 'd', + shift: false, + alt: false, + ctrl: true, + cmd: false, + ...key, + } as Key); + }); + rerender(); + }; + + originalPressKey({ name: 'd', ctrl: true }); + + // AppContainer's handler should return true if it reaches it + expect(result).toBe(true); + // But it should only be called once, so count is 1, not quitting yet. + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + + originalPressKey({ name: 'd', ctrl: true }); + // Now count is 2, it should quit. + expect(mockHandleSlashCommand).toHaveBeenCalledWith( + '/quit', + undefined, + undefined, + false, + ); + unmount(); + }); + it('should reset press count after a timeout', async () => { await setupKeypressTest(); @@ -2066,7 +2104,7 @@ describe('AppContainer State Management', () => { }); describe('Copy Mode (CTRL+S)', () => { - let handleGlobalKeypress: (key: Key) => void; + let handleGlobalKeypress: (key: Key) => boolean; let rerender: () => void; let unmount: () => void; @@ -2096,9 +2134,11 @@ describe('AppContainer State Management', () => { beforeEach(() => { mocks.mockStdout.write.mockClear(); - mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => { - handleGlobalKeypress = callback; - }); + mockedUseKeypress.mockImplementation( + (callback: (key: Key) => boolean) => { + handleGlobalKeypress = callback; + }, + ); vi.useFakeTimers(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e1d23115ca..6de7a313ed 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -532,6 +532,14 @@ export const AppContainer = (props: AppContainerProps) => { shellModeActive, getPreferredEditor, }); + const bufferRef = useRef(buffer); + useEffect(() => { + bufferRef.current = buffer; + }, [buffer]); + + const stableSetText = useCallback((text: string) => { + bufferRef.current.setText(text); + }, []); // Initialize input history from logger (past sessions) useEffect(() => { @@ -826,7 +834,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } } }, - setText: (text: string) => buffer.setText(text), + setText: stableSetText, }), [ setAuthState, @@ -844,7 +852,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openPermissionsDialog, addConfirmUpdateExtensionRequest, toggleDebugProfiler, - buffer, + stableSetText, ], ); @@ -1405,7 +1413,7 @@ Logging in with Google... Restarting Gemini CLI to continue. if (ctrlCPressCount > 1) { // eslint-disable-next-line @typescript-eslint/no-floating-promises handleSlashCommand('/quit', undefined, undefined, false); - } else { + } else if (ctrlCPressCount > 0) { ctrlCTimerRef.current = setTimeout(() => { setCtrlCPressCount(0); ctrlCTimerRef.current = null; @@ -1424,7 +1432,7 @@ Logging in with Google... Restarting Gemini CLI to continue. if (ctrlDPressCount > 1) { // eslint-disable-next-line @typescript-eslint/no-floating-promises handleSlashCommand('/quit', undefined, undefined, false); - } else { + } else if (ctrlDPressCount > 0) { ctrlDTimerRef.current = setTimeout(() => { setCtrlDPressCount(0); ctrlDTimerRef.current = null; @@ -1465,7 +1473,7 @@ Logging in with Google... Restarting Gemini CLI to continue. }); const handleGlobalKeypress = useCallback( - (key: Key) => { + (key: Key): boolean => { if (copyModeEnabled) { setCopyModeEnabled(false); enableMouseEvents(); @@ -1492,9 +1500,6 @@ Logging in with Google... Restarting Gemini CLI to continue. setCtrlCPressCount((prev) => prev + 1); return true; } else if (keyMatchers[Command.EXIT](key)) { - if (buffer.text.length > 0) { - return false; - } setCtrlDPressCount((prev) => prev + 1); return true; } @@ -1538,9 +1543,7 @@ Logging in with Google... Restarting Gemini CLI to continue. return true; } else if ( keyMatchers[Command.FOCUS_SHELL_INPUT](key) && - (activePtyId || - (isBackgroundShellVisible && backgroundShells.size > 0)) && - buffer.text.length === 0 + (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) ) { if (key.name === 'tab' && key.shift) { // Always change focus @@ -1625,7 +1628,6 @@ Logging in with Google... Restarting Gemini CLI to continue. config, ideContextState, setCtrlCPressCount, - buffer.text.length, setCtrlDPressCount, handleSlashCommand, cancelOngoingRequest, @@ -1647,7 +1649,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ], ); - useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); + useKeypress(handleGlobalKeypress, { isActive: true }); useEffect(() => { // Respect hideWindowTitle settings diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 2c5de87e0e..226a086ae9 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -45,6 +45,8 @@ import { StreamingState } from '../types.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; import type { UIState } from '../contexts/UIStateContext.js'; import { isLowColorDepth } from '../utils/terminalUtils.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import type { Key } from '../hooks/useKeypress.js'; vi.mock('../hooks/useShellHistory.js'); vi.mock('../hooks/useCommandCompletion.js'); @@ -169,7 +171,16 @@ describe('InputPrompt', () => { allVisualLines: [''], visualCursor: [0, 0], visualScrollRow: 0, - handleInput: vi.fn(), + handleInput: vi.fn((key: Key) => { + if (keyMatchers[Command.CLEAR_INPUT](key)) { + if (mockBuffer.text.length > 0) { + mockBuffer.setText(''); + return true; + } + return false; + } + return false; + }), move: vi.fn(), moveToOffset: vi.fn((offset: number) => { mockBuffer.cursor = [0, offset]; @@ -499,6 +510,23 @@ describe('InputPrompt', () => { unmount(); }); + it('should clear the buffer and reset completion on Ctrl+C', async () => { + mockBuffer.text = 'some text'; + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + stdin.write('\u0003'); // Ctrl+C + }); + + await waitFor(() => { + expect(mockBuffer.setText).toHaveBeenCalledWith(''); + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); + }); + unmount(); + }); + describe('clipboard image paste', () => { beforeEach(() => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 52f37916bf..f1bb8f331c 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -604,6 +604,12 @@ export const InputPrompt: React.FC = ({ return true; } + if (keyMatchers[Command.CLEAR_SCREEN](key)) { + setBannerVisible(false); + onClearScreen(); + return true; + } + if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { setReverseSearchActive(true); setTextBeforeReverseSearch(buffer.text); @@ -611,12 +617,6 @@ export const InputPrompt: React.FC = ({ return true; } - if (keyMatchers[Command.CLEAR_SCREEN](key)) { - setBannerVisible(false); - onClearScreen(); - return true; - } - if (reverseSearchActive || commandSearchActive) { const isCommandSearch = commandSearchActive; @@ -881,14 +881,6 @@ export const InputPrompt: React.FC = ({ buffer.move('end'); return true; } - // Ctrl+C (Clear input) - if (keyMatchers[Command.CLEAR_INPUT](key)) { - if (buffer.text.length > 0) { - buffer.setText(''); - resetCompletionState(); - } - return false; - } // Kill line commands if (keyMatchers[Command.KILL_LINE_RIGHT](key)) { @@ -933,17 +925,23 @@ export const InputPrompt: React.FC = ({ // Fall back to the text buffer's default input handling for all other keys const handled = buffer.handleInput(key); - // Clear ghost text when user types regular characters (not navigation/control keys) - if ( - completion.promptCompletion.text && - key.sequence && - key.sequence.length === 1 && - !key.alt && - !key.ctrl && - !key.cmd - ) { - completion.promptCompletion.clear(); - setExpandedSuggestionIndex(-1); + if (handled) { + if (keyMatchers[Command.CLEAR_INPUT](key)) { + resetCompletionState(); + } + + // Clear ghost text when user types regular characters (not navigation/control keys) + if ( + completion.promptCompletion.text && + key.sequence && + key.sequence.length === 1 && + !key.alt && + !key.ctrl && + !key.cmd + ) { + completion.promptCompletion.clear(); + setExpandedSuggestionIndex(-1); + } } return handled; }, @@ -982,7 +980,10 @@ export const InputPrompt: React.FC = ({ ], ); - useKeypress(handleInput, { isActive: !isEmbeddedShellFocused }); + useKeypress(handleInput, { + isActive: !isEmbeddedShellFocused, + priority: true, + }); const linesToRender = buffer.viewportVisualLines; const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = 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 16e8c10ee2..bec6cc5f58 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -1515,6 +1515,50 @@ describe('useTextBuffer', () => { expect(getBufferState(result).text).toBe(''); }); + it('should handle CLEAR_INPUT (Ctrl+C)', () => { + const { result } = renderHook(() => + useTextBuffer({ + initialText: 'hello', + viewport, + isValidPath: () => false, + }), + ); + expect(getBufferState(result).text).toBe('hello'); + let handled = false; + act(() => { + handled = result.current.handleInput({ + name: 'c', + shift: false, + alt: false, + ctrl: true, + cmd: false, + insertable: false, + sequence: '\u0003', + }); + }); + expect(handled).toBe(true); + expect(getBufferState(result).text).toBe(''); + }); + + it('should NOT handle CLEAR_INPUT if buffer is empty', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + let handled = true; + act(() => { + handled = result.current.handleInput({ + name: 'c', + shift: false, + alt: false, + ctrl: true, + cmd: false, + insertable: false, + sequence: '\u0003', + }); + }); + expect(handled).toBe(false); + }); + it('should handle "Backspace" key', () => { const { result } = renderHook(() => useTextBuffer({ diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index e40dcdf362..6243f9d6d1 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -2930,6 +2930,13 @@ export function useTextBuffer({ move('end'); return true; } + if (keyMatchers[Command.CLEAR_INPUT](key)) { + if (text.length > 0) { + setText(''); + return true; + } + return false; + } if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) { deleteWordLeft(); return true; @@ -2943,6 +2950,13 @@ export function useTextBuffer({ return true; } if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) { + const lastLineIdx = lines.length - 1; + if ( + cursorRow === lastLineIdx && + cursorCol === cpLen(lines[lastLineIdx] ?? '') + ) { + return false; + } del(); return true; } @@ -2974,6 +2988,8 @@ export function useTextBuffer({ cursorCol, lines, singleLine, + setText, + text, ], ); From 7469ea0fcaa44d57041c6e03dbaac8baa7f29862 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 30 Jan 2026 17:07:41 -0800 Subject: [PATCH 2/3] Fix truncation for AskQuestion (#18001) --- .../src/ui/components/AskUserDialog.test.tsx | 158 ++++++++++++------ .../cli/src/ui/components/AskUserDialog.tsx | 62 +++++-- .../__snapshots__/AskUserDialog.test.tsx.snap | 64 +++++-- .../messages/ToolConfirmationMessage.tsx | 2 +- 4 files changed, 210 insertions(+), 76 deletions(-) diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 645321dfc0..a30fb9b4af 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -10,6 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; +import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; // Helper to write to stdin with proper act() wrapping const writeKey = (stdin: { write: (data: string) => void }, key: string) => { @@ -42,7 +43,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -108,7 +108,6 @@ describe('AskUserDialog', () => { onSubmit={onSubmit} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -129,7 +128,6 @@ describe('AskUserDialog', () => { onSubmit={onSubmit} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -159,33 +157,49 @@ describe('AskUserDialog', () => { }); }); - it('shows scroll arrows when options exceed available height', async () => { - const questions: Question[] = [ - { - question: 'Choose an option', - header: 'Scroll Test', - options: Array.from({ length: 15 }, (_, i) => ({ - label: `Option ${i + 1}`, - description: `Description ${i + 1}`, - })), - multiSelect: false, - }, - ]; + describe.each([ + { useAlternateBuffer: true, expectedArrows: false }, + { useAlternateBuffer: false, expectedArrows: true }, + ])( + 'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)', + ({ useAlternateBuffer, expectedArrows }) => { + it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, async () => { + const questions: Question[] = [ + { + question: 'Choose an option', + header: 'Scroll Test', + options: Array.from({ length: 15 }, (_, i) => ({ + label: `Option ${i + 1}`, + description: `Description ${i + 1}`, + })), + multiSelect: false, + }, + ]; - const { lastFrame } = renderWithProviders( - , - ); + const { lastFrame } = renderWithProviders( + , + { useAlternateBuffer }, + ); - await waitFor(() => { - expect(lastFrame()).toMatchSnapshot(); - }); - }); + await waitFor(() => { + if (expectedArrows) { + expect(lastFrame()).toContain('▲'); + expect(lastFrame()).toContain('▼'); + } else { + expect(lastFrame()).not.toContain('▲'); + expect(lastFrame()).not.toContain('▼'); + } + expect(lastFrame()).toMatchSnapshot(); + }); + }); + }, + ); it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { const { stdin, lastFrame } = renderWithProviders( @@ -194,7 +208,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -246,7 +259,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -261,7 +273,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -276,7 +287,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -308,7 +318,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -351,7 +360,6 @@ describe('AskUserDialog', () => { onSubmit={onSubmit} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -420,7 +428,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -450,7 +457,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -496,7 +502,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -533,7 +538,6 @@ describe('AskUserDialog', () => { onSubmit={onSubmit} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -567,7 +571,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -590,7 +593,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -613,7 +615,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -649,7 +650,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -681,7 +681,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -729,7 +728,6 @@ describe('AskUserDialog', () => { onSubmit={onSubmit} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -780,7 +778,6 @@ describe('AskUserDialog', () => { onSubmit={onSubmit} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -807,7 +804,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={onCancel} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -854,7 +850,6 @@ describe('AskUserDialog', () => { onSubmit={vi.fn()} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -914,7 +909,6 @@ describe('AskUserDialog', () => { onSubmit={onSubmit} onCancel={vi.fn()} width={120} - availableHeight={20} />, { width: 120 }, ); @@ -946,4 +940,72 @@ describe('AskUserDialog', () => { }); }); }); + + it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => { + const questions: Question[] = [ + { + question: 'Choose an option', + header: 'Context Test', + options: Array.from({ length: 10 }, (_, i) => ({ + label: `Option ${i + 1}`, + description: `Description ${i + 1}`, + })), + multiSelect: false, + }, + ]; + + const mockUIState = { + availableTerminalHeight: 5, // Small height to force scroll arrows + } as UIState; + + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer: false }, + ); + + // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) + expect(lastFrame()).toContain('▲'); + expect(lastFrame()).toContain('▼'); + }); + + it('does NOT truncate the question when in alternate buffer mode even with small height', () => { + const longQuestion = + 'This is a very long question ' + 'with many words '.repeat(10); + const questions: Question[] = [ + { + question: longQuestion, + header: 'Alternate Buffer Test', + options: [{ label: 'Option 1', description: 'Desc 1' }], + multiSelect: false, + }, + ]; + + const mockUIState = { + availableTerminalHeight: 5, + } as UIState; + + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer: true }, + ); + + // Should NOT contain the truncation message + expect(lastFrame()).not.toContain('hidden ...'); + // Should contain the full long question (or at least its parts) + expect(lastFrame()).toContain('This is a very long question'); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index e2892feade..ba4c14510f 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -5,7 +5,14 @@ */ import type React from 'react'; -import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react'; +import { + useCallback, + useMemo, + useRef, + useEffect, + useReducer, + useContext, +} from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { Question } from '@google/gemini-cli-core'; @@ -21,6 +28,8 @@ import { getCachedStringWidth } from '../utils/textUtils.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { DialogFooter } from './shared/DialogFooter.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; +import { UIStateContext } from '../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; interface AskUserDialogState { answers: { [key: string]: string }; @@ -121,7 +130,7 @@ interface AskUserDialogProps { /** * Height constraint for scrollable content. */ - availableHeight: number; + availableHeight?: number; } interface ReviewViewProps { @@ -199,7 +208,7 @@ interface TextQuestionViewProps { onSelectionChange?: (answer: string) => void; onEditingCustomOption?: (editing: boolean) => void; availableWidth: number; - availableHeight: number; + availableHeight?: number; initialAnswer?: string; progressHeader?: React.ReactNode; keyboardHints?: React.ReactNode; @@ -216,6 +225,7 @@ const TextQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const isAlternateBuffer = useAlternateBuffer(); const prefix = '> '; const horizontalPadding = 1; // 1 for cursor const bufferWidth = @@ -279,13 +289,20 @@ const TextQuestionView: React.FC = ({ const INPUT_HEIGHT = 2; // TextInput + margin const FOOTER_HEIGHT = 2; // DialogFooter + margin const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT; - const questionHeight = Math.max(1, availableHeight - overhead); + const questionHeight = + availableHeight && !isAlternateBuffer + ? Math.max(1, availableHeight - overhead) + : undefined; return ( {progressHeader} - + {question.question} @@ -389,7 +406,7 @@ interface ChoiceQuestionViewProps { onSelectionChange?: (answer: string) => void; onEditingCustomOption?: (editing: boolean) => void; availableWidth: number; - availableHeight: number; + availableHeight?: number; initialAnswer?: string; progressHeader?: React.ReactNode; keyboardHints?: React.ReactNode; @@ -406,6 +423,7 @@ const ChoiceQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const isAlternateBuffer = useAlternateBuffer(); const numOptions = (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); const numLen = String(numOptions).length; @@ -711,18 +729,27 @@ const ChoiceQuestionView: React.FC = ({ const TITLE_MARGIN = 1; const FOOTER_HEIGHT = 2; // DialogFooter + margin const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT; - const listHeight = Math.max(1, availableHeight - overhead); - const questionHeight = Math.min(3, Math.max(1, listHeight - 4)); - const maxItemsToShow = Math.max( - 1, - Math.floor((listHeight - questionHeight) / 2), - ); + const listHeight = availableHeight + ? Math.max(1, availableHeight - overhead) + : undefined; + const questionHeight = + listHeight && !isAlternateBuffer + ? Math.min(15, Math.max(1, listHeight - 4)) + : undefined; + const maxItemsToShow = + listHeight && questionHeight + ? Math.max(1, Math.floor((listHeight - questionHeight) / 2)) + : selectionItems.length; return ( {progressHeader} - + {question.question} {question.multiSelect && ( @@ -824,8 +851,15 @@ export const AskUserDialog: React.FC = ({ onCancel, onActiveTextInputChange, width, - availableHeight, + availableHeight: availableHeightProp, }) => { + const uiState = useContext(UIStateContext); + const availableHeight = + availableHeightProp ?? + (uiState?.constrainHeight !== false + ? uiState?.availableTerminalHeight + : undefined); + const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState); const { answers, isEditingCustomOption, submitted } = state; 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 fdb34f4adb..7f5d630bc1 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -1,5 +1,56 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = ` +"Choose an option + +▲ +● 1. Option 1 + Description 1 + 2. Option 2 + Description 2 +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; + +exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = ` +"Choose an option + +● 1. Option 1 + Description 1 + 2. Option 2 + Description 2 + 3. Option 3 + Description 3 + 4. Option 4 + Description 4 + 5. Option 5 + Description 5 + 6. Option 6 + Description 6 + 7. Option 7 + Description 7 + 8. Option 8 + Description 8 + 9. Option 9 + Description 9 + 10. Option 10 + Description 10 + 11. Option 11 + Description 11 + 12. Option 12 + Description 12 + 13. Option 13 + Description 13 + 14. Option 14 + Description 14 + 15. Option 15 + Description 15 + 16. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; + exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = ` "What should we name this component? @@ -104,19 +155,6 @@ Which database should we use? Enter to select · ←/→ to switch questions · Esc to cancel" `; -exports[`AskUserDialog > shows scroll arrows when options exceed available height 1`] = ` -"Choose an option - -▲ -● 1. Option 1 - Description 1 - 2. Option 2 - Description 2 -▼ - -Enter to select · ↑/↓ to navigate · Esc to cancel" -`; - exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = ` "← □ License │ □ README │ ≡ Review → diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 2272c1a4dd..a50669bd40 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -271,7 +271,7 @@ export const ToolConfirmationMessage: React.FC< handleConfirm(ToolConfirmationOutcome.Cancel); }} width={terminalWidth} - availableHeight={availableBodyContentHeight() ?? 10} + availableHeight={availableBodyContentHeight()} /> ); return { question: '', bodyContent, options: [] }; From b0f38104d788a17080463da0dcad76f9dc65d14e Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Fri, 30 Jan 2026 22:32:58 -0500 Subject: [PATCH 3/3] fix(workflow): update maintainer check logic to be inclusive and case-insensitive (#18009) --- .github/workflows/gemini-scheduled-stale-pr-closer.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml index 04b6e37246..01696d7728 100644 --- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -48,14 +48,15 @@ jobs: org: context.repo.owner, team_slug: 'gemini-cli-maintainers' }); - maintainerLogins = new Set(members.map(m => m.login)); + maintainerLogins = new Set(members.map(m => m.login.toLowerCase())); } catch (e) { core.warning('Failed to fetch team members'); } const isMaintainer = (login, assoc) => { - if (maintainerLogins.size > 0) return maintainerLogins.has(login); - return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); + const isTeamMember = maintainerLogins.has(login.toLowerCase()); + const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); + return isTeamMember || isRepoMaintainer; }; // 2. Determine which PRs to check