diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index f6cd545438..ce5990a906 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -106,6 +106,7 @@ available combinations. | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` | | Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`
`Ctrl + S` | +| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` | | Toggle current background shell visibility. | `Ctrl + B` | | Toggle background shell list. | `Ctrl + L` | | Kill the active background shell. | `Ctrl + K` | @@ -139,6 +140,7 @@ available combinations. single-line input, navigate backward or forward through prompt history. - `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to the numbered radio option and confirm when the full number is entered. -- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate - buffer mode: Expand to view full content inline. Double-click again to - collapse. +- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`) + inline when the cursor is over the placeholder. +- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to + view full content inline. Double-click again to collapse. diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 994c452d99..96e50f36d6 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -91,6 +91,7 @@ export enum Command { TOGGLE_YOLO = 'app.toggleYolo', CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode', SHOW_MORE_LINES = 'app.showMoreLines', + EXPAND_PASTE = 'app.expandPaste', FOCUS_SHELL_INPUT = 'app.focusShellInput', UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput', CLEAR_SCREEN = 'app.clearScreen', @@ -289,6 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'o', ctrl: true }, { key: 's', ctrl: true }, ], + [Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }], [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }], [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }], [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], @@ -399,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.TOGGLE_YOLO, Command.CYCLE_APPROVAL_MODE, Command.SHOW_MORE_LINES, + Command.EXPAND_PASTE, Command.TOGGLE_BACKGROUND_SHELL, Command.TOGGLE_BACKGROUND_SHELL_LIST, Command.KILL_BACKGROUND_SHELL, @@ -499,6 +502,8 @@ export const commandDescriptions: Readonly> = { 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).', [Command.SHOW_MORE_LINES]: 'Expand a height-constrained response to show additional lines when not in alternate buffer mode.', + [Command.EXPAND_PASTE]: + 'Expand or collapse a paste placeholder when cursor is over placeholder.', [Command.BACKGROUND_SHELL_SELECT]: 'Confirm selection in background shell list.', [Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.', diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 64fccf1b3e..2ac08ee977 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -200,7 +200,6 @@ const mockUIActions: UIActions = { setActiveBackgroundShellPid: vi.fn(), setIsBackgroundShellListOpen: vi.fn(), setAuthContext: vi.fn(), - handleWarning: vi.fn(), handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e9e2875399..a02512f189 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -106,7 +106,7 @@ import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; -import { appEvents, AppEvent } from '../utils/events.js'; +import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; @@ -143,6 +143,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; +import { useTimedMessage } from './hooks/useTimedMessage.js'; import { isITerm2 } from './utils/terminalUtils.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { @@ -1289,7 +1290,11 @@ Logging in with Google... Restarting Gemini CLI to continue. >(); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); - const [warningMessage, setWarningMessage] = useState(null); + + const [transientMessage, showTransientMessage] = useTimedMessage<{ + text: string; + type: TransientMessageType; + }>(WARNING_PROMPT_DURATION_MS); const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); @@ -1301,41 +1306,42 @@ Logging in with Google... Restarting Gemini CLI to continue. useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); - const warningTimeoutRef = useRef(null); const tabFocusTimeoutRef = useRef(null); - const handleWarning = useCallback((message: string) => { - setWarningMessage(message); - if (warningTimeoutRef.current) { - clearTimeout(warningTimeoutRef.current); - } - warningTimeoutRef.current = setTimeout(() => { - setWarningMessage(null); - }, WARNING_PROMPT_DURATION_MS); - }, []); + useEffect(() => { + const handleTransientMessage = (payload: { + message: string; + type: TransientMessageType; + }) => { + showTransientMessage({ text: payload.message, type: payload.type }); + }; - // Handle timeout cleanup on unmount - useEffect( - () => () => { - if (warningTimeoutRef.current) { - clearTimeout(warningTimeoutRef.current); - } + const handleSelectionWarning = () => { + showTransientMessage({ + text: 'Press Ctrl-S to enter selection mode to copy text.', + type: TransientMessageType.Warning, + }); + }; + const handlePasteTimeout = () => { + showTransientMessage({ + text: 'Paste Timed out. Possibly due to slow connection.', + type: TransientMessageType.Warning, + }); + }; + + appEvents.on(AppEvent.TransientMessage, handleTransientMessage); + appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning); + appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout); + + return () => { + appEvents.off(AppEvent.TransientMessage, handleTransientMessage); + appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning); + appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); if (tabFocusTimeoutRef.current) { clearTimeout(tabFocusTimeoutRef.current); } - }, - [], - ); - - useEffect(() => { - const handlePasteTimeout = () => { - handleWarning('Paste Timed out. Possibly due to slow connection.'); }; - appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout); - return () => { - appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); - }; - }, [handleWarning]); + }, [showTransientMessage]); useEffect(() => { if (ideNeedsRestart) { @@ -1503,7 +1509,10 @@ Logging in with Google... Restarting Gemini CLI to continue. const undoMessage = isITerm2() ? 'Undo has been moved to Option + Z' : 'Undo has been moved to Alt/Option + Z or Cmd + Z'; - handleWarning(undoMessage); + showTransientMessage({ + text: undoMessage, + type: TransientMessageType.Warning, + }); return true; } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { setShowFullTodos((prev) => !prev); @@ -1543,7 +1552,10 @@ Logging in with Google... Restarting Gemini CLI to continue. if (lastOutputTimeRef.current === capturedTime) { setEmbeddedShellFocused(false); } else { - handleWarning('Use Shift+Tab to unfocus'); + showTransientMessage({ + text: 'Use Shift+Tab to unfocus', + type: TransientMessageType.Warning, + }); } }, 150); return false; @@ -1623,7 +1635,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setIsBackgroundShellListOpen, lastOutputTimeRef, tabFocusTimeoutRef, - handleWarning, + showTransientMessage, ], ); @@ -1906,7 +1918,7 @@ Logging in with Google... Restarting Gemini CLI to continue. showDebugProfiler, customDialog, copyModeEnabled, - warningMessage, + transientMessage, bannerData, bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), @@ -2016,7 +2028,7 @@ Logging in with Google... Restarting Gemini CLI to continue. apiKeyDefaultValue, authState, copyModeEnabled, - warningMessage, + transientMessage, bannerData, bannerVisible, config, @@ -2073,7 +2085,6 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeyCancel, setBannerVisible, setShortcutsHelpVisible, - handleWarning, setEmbeddedShellFocused, dismissBackgroundShell, setActiveBackgroundShellPid, @@ -2150,7 +2161,6 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeyCancel, setBannerVisible, setShortcutsHelpVisible, - handleWarning, setEmbeddedShellFocused, dismissBackgroundShell, setActiveBackgroundShellPid, diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index c542f54bee..8b14c9c41a 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -5,7 +5,7 @@ */ import { render } from '../../test-utils/render.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { BackgroundShellDisplay } from './BackgroundShellDisplay.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; @@ -20,16 +20,12 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const mockDismissBackgroundShell = vi.fn(); const mockSetActiveBackgroundShellPid = vi.fn(); const mockSetIsBackgroundShellListOpen = vi.fn(); -const mockHandleWarning = vi.fn(); -const mockSetEmbeddedShellFocused = vi.fn(); vi.mock('../contexts/UIActionsContext.js', () => ({ useUIActions: () => ({ dismissBackgroundShell: mockDismissBackgroundShell, setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid, setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen, - handleWarning: mockHandleWarning, - setEmbeddedShellFocused: mockSetEmbeddedShellFocused, }), })); @@ -103,6 +99,10 @@ vi.mock('./shared/ScrollableList.js', () => ({ ), })); +afterEach(() => { + vi.restoreAllMocks(); +}); + const createMockKey = (overrides: Partial): Key => ({ name: '', ctrl: false, diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 9b4444a6e9..8356966c5b 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -9,7 +9,7 @@ import { createMockSettings } from '../../test-utils/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { act, useState } from 'react'; import type { InputPromptProps } from './InputPrompt.js'; -import { InputPrompt } from './InputPrompt.js'; +import { InputPrompt, tryTogglePasteExpansion } from './InputPrompt.js'; import type { TextBuffer } from './shared/text-buffer.js'; import { calculateTransformationsForLine, @@ -46,6 +46,11 @@ import { isLowColorDepth } from '../utils/terminalUtils.js'; import { cpLen } from '../utils/textUtils.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { Key } from '../hooks/useKeypress.js'; +import { + appEvents, + AppEvent, + TransientMessageType, +} from '../../utils/events.js'; vi.mock('../hooks/useShellHistory.js'); vi.mock('../hooks/useCommandCompletion.js'); @@ -69,6 +74,10 @@ vi.mock('ink', async (importOriginal) => { }; }); +afterEach(() => { + vi.restoreAllMocks(); +}); + const mockSlashCommands: SlashCommand[] = [ { name: 'clear', @@ -3826,6 +3835,260 @@ describe('InputPrompt', () => { unmount(); }); }); + + describe('Ctrl+O paste expansion', () => { + const CTRL_O = '\x0f'; // Ctrl+O key sequence + + it('Ctrl+O triggers paste expansion via keybinding', async () => { + const id = '[Pasted Text: 10 lines]'; + const toggleFn = vi.fn(); + const buffer = { + ...props.buffer, + text: id, + cursor: [0, 0] as number[], + pastedContent: { + [id]: 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10', + }, + transformationsByLine: [ + [ + { + logStart: 0, + logEnd: id.length, + logicalText: id, + collapsedText: id, + type: 'paste', + id, + }, + ], + ], + expandedPaste: null, + getExpandedPasteAtLine: vi.fn().mockReturnValue(null), + togglePasteExpansion: toggleFn, + } as unknown as TextBuffer; + + const { stdin, unmount } = renderWithProviders( + , + { uiActions }, + ); + + await act(async () => { + stdin.write(CTRL_O); + }); + + await waitFor(() => { + expect(toggleFn).toHaveBeenCalledWith(id, 0, 0); + }); + unmount(); + }); + + it.each([ + { + name: 'hint appears on large paste via Ctrl+V', + text: 'line1\nline2\nline3\nline4\nline5\nline6', + method: 'ctrl-v', + expectHint: true, + }, + { + name: 'hint does not appear for small pastes via Ctrl+V', + text: 'hello', + method: 'ctrl-v', + expectHint: false, + }, + { + name: 'hint appears on large terminal paste event', + text: 'line1\nline2\nline3\nline4\nline5\nline6', + method: 'terminal-paste', + expectHint: true, + }, + ])('$name', async ({ text, method, expectHint }) => { + vi.mocked(clipboardy.read).mockResolvedValue(text); + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); + + const emitSpy = vi.spyOn(appEvents, 'emit'); + const buffer = { + ...props.buffer, + handleInput: vi.fn().mockReturnValue(true), + } as unknown as TextBuffer; + + // Need kitty protocol enabled for terminal paste events + if (method === 'terminal-paste') { + mockedUseKittyKeyboardProtocol.mockReturnValue({ + enabled: true, + checking: false, + }); + } + + const { stdin, unmount } = renderWithProviders( + , + ); + + await act(async () => { + if (method === 'ctrl-v') { + stdin.write('\x16'); // Ctrl+V + } else { + stdin.write(`\x1b[200~${text}\x1b[201~`); + } + }); + + await waitFor(() => { + if (expectHint) { + expect(emitSpy).toHaveBeenCalledWith(AppEvent.TransientMessage, { + message: 'Press Ctrl+O to expand pasted text', + type: TransientMessageType.Hint, + }); + } else { + // If no hint expected, verify buffer was still updated + if (method === 'ctrl-v') { + expect(mockBuffer.insert).toHaveBeenCalledWith(text, { + paste: true, + }); + } else { + expect(buffer.handleInput).toHaveBeenCalled(); + } + } + }); + + if (!expectHint) { + expect(emitSpy).not.toHaveBeenCalledWith( + AppEvent.TransientMessage, + expect.any(Object), + ); + } + + emitSpy.mockRestore(); + unmount(); + }); + }); + + describe('tryTogglePasteExpansion', () => { + it.each([ + { + name: 'returns false when no pasted content exists', + cursor: [0, 0], + pastedContent: {}, + getExpandedPasteAtLine: null, + expected: false, + }, + { + name: 'expands placeholder under cursor', + cursor: [0, 2], + pastedContent: { '[Pasted Text: 6 lines]': 'content' }, + transformations: [ + { + logStart: 0, + logEnd: '[Pasted Text: 6 lines]'.length, + id: '[Pasted Text: 6 lines]', + }, + ], + expected: true, + expectedToggle: ['[Pasted Text: 6 lines]', 0, 2], + }, + { + name: 'collapses expanded paste when cursor is inside', + cursor: [1, 0], + pastedContent: { '[Pasted Text: 6 lines]': 'a\nb\nc' }, + getExpandedPasteAtLine: '[Pasted Text: 6 lines]', + expected: true, + expectedToggle: ['[Pasted Text: 6 lines]', 1, 0], + }, + { + name: 'expands placeholder when cursor is immediately after it', + cursor: [0, '[Pasted Text: 6 lines]'.length], + pastedContent: { '[Pasted Text: 6 lines]': 'content' }, + transformations: [ + { + logStart: 0, + logEnd: '[Pasted Text: 6 lines]'.length, + id: '[Pasted Text: 6 lines]', + }, + ], + expected: true, + expectedToggle: [ + '[Pasted Text: 6 lines]', + 0, + '[Pasted Text: 6 lines]'.length, + ], + }, + { + name: 'shows hint when cursor is not on placeholder but placeholders exist', + cursor: [0, 0], + pastedContent: { '[Pasted Text: 6 lines]': 'content' }, + transformationsByLine: [ + [], + [ + { + logStart: 0, + logEnd: '[Pasted Text: 6 lines]'.length, + type: 'paste', + id: '[Pasted Text: 6 lines]', + }, + ], + ], + expected: true, + expectedHint: 'Move cursor within placeholder to expand', + }, + ])( + '$name', + ({ + cursor, + pastedContent, + transformations, + transformationsByLine, + getExpandedPasteAtLine, + expected, + expectedToggle, + expectedHint, + }) => { + const id = '[Pasted Text: 6 lines]'; + const buffer = { + cursor, + pastedContent, + transformationsByLine: transformationsByLine || [ + transformations + ? transformations.map((t) => ({ + ...t, + logicalText: id, + collapsedText: id, + type: 'paste', + })) + : [], + ], + getExpandedPasteAtLine: vi + .fn() + .mockReturnValue(getExpandedPasteAtLine), + togglePasteExpansion: vi.fn(), + } as unknown as TextBuffer; + + const emitSpy = vi.spyOn(appEvents, 'emit'); + expect(tryTogglePasteExpansion(buffer)).toBe(expected); + + if (expectedToggle) { + expect(buffer.togglePasteExpansion).toHaveBeenCalledWith( + ...expectedToggle, + ); + } else { + expect(buffer.togglePasteExpansion).not.toHaveBeenCalled(); + } + + if (expectedHint) { + expect(emitSpy).toHaveBeenCalledWith(AppEvent.TransientMessage, { + message: expectedHint, + type: TransientMessageType.Hint, + }); + } else { + expect(emitSpy).not.toHaveBeenCalledWith( + AppEvent.TransientMessage, + expect.any(Object), + ); + } + emitSpy.mockRestore(); + }, + ); + }); + describe('History Navigation and Completion Suppression', () => { beforeEach(() => { props.userMessages = ['first message', 'second message']; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 49c609ec9b..122988a07f 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -17,6 +17,8 @@ import { logicalPosToOffset, PASTED_TEXT_PLACEHOLDER_REGEX, getTransformUnderCursor, + LARGE_PASTE_LINE_THRESHOLD, + LARGE_PASTE_CHAR_THRESHOLD, } from './shared/text-buffer.js'; import { cpSlice, @@ -59,6 +61,11 @@ import { getSafeLowColorBackground } from '../themes/color-utils.js'; import { isLowColorDepth } from '../utils/terminalUtils.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { + appEvents, + AppEvent, + TransientMessageType, +} from '../../utils/events.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { StreamingState } from '../types.js'; import { useMouseClick } from '../hooks/useMouseClick.js'; @@ -122,6 +129,55 @@ export const calculatePromptWidths = (mainContentWidth: number) => { } as const; }; +/** + * Returns true if the given text exceeds the thresholds for being considered a "large paste". + */ +export function isLargePaste(text: string): boolean { + const pasteLineCount = text.split('\n').length; + return ( + pasteLineCount > LARGE_PASTE_LINE_THRESHOLD || + text.length > LARGE_PASTE_CHAR_THRESHOLD + ); +} + +/** + * Attempt to toggle expansion of a paste placeholder in the buffer. + * Returns true if a toggle action was performed or hint was shown, false otherwise. + */ +export function tryTogglePasteExpansion(buffer: TextBuffer): boolean { + if (!buffer.pastedContent || Object.keys(buffer.pastedContent).length === 0) { + return false; + } + + const [row, col] = buffer.cursor; + + // 1. Check if cursor is on or immediately after a collapsed placeholder + const transform = getTransformUnderCursor( + row, + col, + buffer.transformationsByLine, + { includeEdge: true }, + ); + if (transform?.type === 'paste' && transform.id) { + buffer.togglePasteExpansion(transform.id, row, col); + return true; + } + + // 2. Check if cursor is inside an expanded paste region — collapse it + const expandedId = buffer.getExpandedPasteAtLine(row); + if (expandedId) { + buffer.togglePasteExpansion(expandedId, row, col); + return true; + } + + // 3. Placeholders exist but cursor isn't on one — show hint + appEvents.emit(AppEvent.TransientMessage, { + message: 'Move cursor within placeholder to expand', + type: TransientMessageType.Hint, + }); + return true; +} + export const InputPrompt: React.FC = ({ buffer, onSubmit, @@ -402,6 +458,12 @@ export const InputPrompt: React.FC = ({ } else { const textToInsert = await clipboardy.read(); buffer.insert(textToInsert, { paste: true }); + if (isLargePaste(textToInsert)) { + appEvents.emit(AppEvent.TransientMessage, { + message: 'Press Ctrl+O to expand pasted text', + type: TransientMessageType.Hint, + }); + } } } catch (error) { debugLogger.error('Error handling paste:', error); @@ -455,6 +517,7 @@ export const InputPrompt: React.FC = ({ logicalPos.row, logicalPos.col, buffer.transformationsByLine, + { includeEdge: true }, ); if (transform?.type === 'paste' && transform.id) { buffer.togglePasteExpansion( @@ -591,6 +654,12 @@ export const InputPrompt: React.FC = ({ } // Ensure we never accidentally interpret paste as regular input. buffer.handleInput(key); + if (key.sequence && isLargePaste(key.sequence)) { + appEvents.emit(AppEvent.TransientMessage, { + message: 'Press Ctrl+O to expand pasted text', + type: TransientMessageType.Hint, + }); + } return true; } @@ -632,6 +701,12 @@ export const InputPrompt: React.FC = ({ } } + // Ctrl+O to expand/collapse paste placeholders + if (keyMatchers[Command.EXPAND_PASTE](key)) { + const handled = tryTogglePasteExpansion(buffer); + if (handled) return true; + } + if ( key.sequence === '!' && buffer.text === '' && diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 6c3eb42248..99bfbf7969 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -9,6 +9,7 @@ import { render } from '../../test-utils/render.js'; import { Text } from 'ink'; import { StatusDisplay } from './StatusDisplay.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; +import { TransientMessageType } from '../../utils/events.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; import { createMockSettings } from '../../test-utils/settings.js'; @@ -40,7 +41,7 @@ type UIStateOverrides = Partial> & { const createMockUIState = (overrides: UIStateOverrides = {}): UIState => ({ ctrlCPressedOnce: false, - warningMessage: null, + transientMessage: null, ctrlDPressedOnce: false, showEscapePrompt: false, shortcutsHelpVisible: false, @@ -112,7 +113,10 @@ describe('StatusDisplay', () => { it('prioritizes Ctrl+C prompt over everything else (except system md)', () => { const uiState = createMockUIState({ ctrlCPressedOnce: true, - warningMessage: 'Warning', + transientMessage: { + text: 'Warning', + type: TransientMessageType.Warning, + }, activeHooks: [{ name: 'hook', eventName: 'event' }], }); const { lastFrame } = renderStatusDisplay( @@ -124,7 +128,24 @@ describe('StatusDisplay', () => { it('renders warning message', () => { const uiState = createMockUIState({ - warningMessage: 'This is a warning', + transientMessage: { + text: 'This is a warning', + type: TransientMessageType.Warning, + }, + }); + const { lastFrame } = renderStatusDisplay( + { hideContextSummary: false }, + uiState, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders hint message', () => { + const uiState = createMockUIState({ + transientMessage: { + text: 'This is a hint', + type: TransientMessageType.Hint, + }, }); const { lastFrame } = renderStatusDisplay( { hideContextSummary: false }, @@ -135,7 +156,10 @@ describe('StatusDisplay', () => { it('prioritizes warning over Ctrl+D', () => { const uiState = createMockUIState({ - warningMessage: 'Warning', + transientMessage: { + text: 'Warning', + type: TransientMessageType.Warning, + }, ctrlDPressedOnce: true, }); const { lastFrame } = renderStatusDisplay( diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 52d22cd34d..5bc9896bd7 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { TransientMessageType } from '../../utils/events.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; @@ -34,8 +35,13 @@ export const StatusDisplay: React.FC = ({ ); } - if (uiState.warningMessage) { - return {uiState.warningMessage}; + if ( + uiState.transientMessage?.type === TransientMessageType.Warning && + uiState.transientMessage.text + ) { + return ( + {uiState.transientMessage.text} + ); } if (uiState.ctrlDPressedOnce) { @@ -59,6 +65,15 @@ export const StatusDisplay: React.FC = ({ ); } + if ( + uiState.transientMessage?.type === TransientMessageType.Hint && + uiState.transientMessage.text + ) { + return ( + {uiState.transientMessage.text} + ); + } + if (uiState.queueErrorMessage) { return {uiState.queueErrorMessage}; } diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap index f250079c49..ff25546002 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap @@ -18,6 +18,8 @@ exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = ` exports[`StatusDisplay > renders Queue Error Message 1`] = `"Queue Error"`; +exports[`StatusDisplay > renders hint message 1`] = `"This is a hint"`; + exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `"|⌐■_■|"`; exports[`StatusDisplay > renders warning message 1`] = `"This is a warning"`; diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 83637f4f08..77edace6c9 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -34,8 +34,8 @@ import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js'; -const LARGE_PASTE_LINE_THRESHOLD = 5; -const LARGE_PASTE_CHAR_THRESHOLD = 500; +export const LARGE_PASTE_LINE_THRESHOLD = 5; +export const LARGE_PASTE_CHAR_THRESHOLD = 500; // Regex to match paste placeholders like [Pasted Text: 6 lines] or [Pasted Text: 501 chars #2] export const PASTED_TEXT_PLACEHOLDER_REGEX = @@ -986,11 +986,15 @@ export function getTransformUnderCursor( row: number, col: number, spansByLine: Transformation[][], + options: { includeEdge?: boolean } = {}, ): Transformation | null { const spans = spansByLine[row]; if (!spans || spans.length === 0) return null; for (const span of spans) { - if (col >= span.logStart && col < span.logEnd) { + if ( + col >= span.logStart && + (options.includeEdge ? col <= span.logEnd : col < span.logEnd) + ) { return span; } if (col < span.logStart) break; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 4c42998d16..8ad79f6b25 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -68,7 +68,6 @@ export interface UIActions { handleApiKeyCancel: () => void; setBannerVisible: (visible: boolean) => void; setShortcutsHelpVisible: (visible: boolean) => void; - handleWarning: (message: string) => void; setEmbeddedShellFocused: (value: boolean) => void; dismissBackgroundShell: (pid: number) => void; setActiveBackgroundShellPid: (pid: number) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 1459424835..88cbeb5730 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,7 @@ import type { ValidationIntent, AgentDefinition, } from '@google/gemini-cli-core'; +import { type TransientMessageType } from '../../utils/events.js'; import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; import type { ExtensionUpdateState } from '../state/extensions.js'; @@ -152,7 +153,6 @@ export interface UIState { showDebugProfiler: boolean; showFullTodos: boolean; copyModeEnabled: boolean; - warningMessage: string | null; bannerData: { defaultText: string; warningText: string; @@ -167,6 +167,10 @@ export interface UIState { isBackgroundShellListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; + transientMessage: { + text: string; + type: TransientMessageType; + } | null; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useTimedMessage.ts b/packages/cli/src/ui/hooks/useTimedMessage.ts new file mode 100644 index 0000000000..3fe5f0b9c4 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTimedMessage.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; + +/** + * A hook to manage a state value that automatically resets to null after a duration. + * Useful for transient UI messages, hints, or warnings. + */ +export function useTimedMessage(durationMs: number) { + const [message, setMessage] = useState(null); + const timeoutRef = useRef(null); + + const showMessage = useCallback( + (msg: T) => { + setMessage(msg); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setMessage(null); + }, durationMs); + }, + [durationMs], + ); + + useEffect( + () => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, + [], + ); + + return [message, showMessage] as const; +} diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts index 7e4be98987..8291528ac1 100644 --- a/packages/cli/src/utils/events.ts +++ b/packages/cli/src/utils/events.ts @@ -6,12 +6,23 @@ import { EventEmitter } from 'node:events'; +export enum TransientMessageType { + Warning = 'warning', + Hint = 'hint', +} + +export interface TransientMessagePayload { + message: string; + type: TransientMessageType; +} + export enum AppEvent { OpenDebugConsole = 'open-debug-console', Flicker = 'flicker', SelectionWarning = 'selection-warning', PasteTimeout = 'paste-timeout', TerminalBackground = 'terminal-background', + TransientMessage = 'transient-message', } export interface AppEvents { @@ -20,6 +31,7 @@ export interface AppEvents { [AppEvent.SelectionWarning]: never[]; [AppEvent.PasteTimeout]: never[]; [AppEvent.TerminalBackground]: [string]; + [AppEvent.TransientMessage]: [TransientMessagePayload]; } export const appEvents = new EventEmitter();