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,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;