mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
feat: Ctrl+O to expand paste placeholder (#18103)
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user