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();