feat: Ctrl+O to expand paste placeholder (#18103)

This commit is contained in:
Jack Wotherspoon
2026-02-09 21:04:34 -05:00
committed by GitHub
parent 89d4556c45
commit 9081743a7f
15 changed files with 512 additions and 58 deletions
+5
View File
@@ -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<Record<Command, string>> = {
'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.',
-1
View File
@@ -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(),
};
+47 -37
View File
@@ -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<string | null>(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<NodeJS.Timeout | null>(null);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(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,
@@ -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>): Key => ({
name: '',
ctrl: false,
@@ -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(
<InputPrompt {...props} buffer={buffer} />,
{ 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(
<InputPrompt
{...props}
buffer={method === 'terminal-paste' ? buffer : props.buffer}
/>,
);
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'];
@@ -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<InputPromptProps> = ({
buffer,
onSubmit,
@@ -402,6 +458,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} 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<InputPromptProps> = ({
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<InputPromptProps> = ({
}
// 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<InputPromptProps> = ({
}
}
// 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 === '' &&
@@ -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<Omit<UIState, 'buffer'>> & {
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(
@@ -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<StatusDisplayProps> = ({
);
}
if (uiState.warningMessage) {
return <Text color={theme.status.warning}>{uiState.warningMessage}</Text>;
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.text
) {
return (
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.ctrlDPressedOnce) {
@@ -59,6 +65,15 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Hint &&
uiState.transientMessage.text
) {
return (
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
@@ -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"`;
@@ -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;
@@ -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;
@@ -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<UIState | null>(null);
@@ -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<T>(durationMs: number) {
const [message, setMessage] = useState<T | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(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;
}
+12
View File
@@ -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<AppEvents>();