diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md
index 16a6c9d7654..6d5f253339f 100644
--- a/docs/cli/keyboard-shortcuts.md
+++ b/docs/cli/keyboard-shortcuts.md
@@ -124,3 +124,6 @@ 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.
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 0bf70b7ceb7..605d0ac6cb2 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/render.js';
import { waitFor } from '../../test-utils/async.js';
-import { act } from 'react';
+import { act, useState } from 'react';
import type { InputPromptProps } from './InputPrompt.js';
import { InputPrompt } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
@@ -2900,6 +2900,93 @@ describe('InputPrompt', () => {
unmount();
});
+ it('should toggle paste expansion on double-click', async () => {
+ const id = '[Pasted Text: 10 lines]';
+ const largeText =
+ 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
+
+ const baseProps = props;
+ const TestWrapper = () => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const currentLines = isExpanded ? largeText.split('\n') : [id];
+ const currentText = isExpanded ? largeText : id;
+
+ const buffer = {
+ ...baseProps.buffer,
+ text: currentText,
+ lines: currentLines,
+ viewportVisualLines: currentLines,
+ allVisualLines: currentLines,
+ pastedContent: { [id]: largeText },
+ transformationsByLine: isExpanded
+ ? currentLines.map(() => [])
+ : [
+ [
+ {
+ logStart: 0,
+ logEnd: id.length,
+ logicalText: id,
+ collapsedText: id,
+ type: 'paste',
+ id,
+ },
+ ],
+ ],
+ visualScrollRow: 0,
+ visualToLogicalMap: currentLines.map(
+ (_, i) => [i, 0] as [number, number],
+ ),
+ visualToTransformedMap: currentLines.map(() => 0),
+ getLogicalPositionFromVisual: vi.fn().mockReturnValue({
+ row: 0,
+ col: 2,
+ }),
+ togglePasteExpansion: vi.fn().mockImplementation(() => {
+ setIsExpanded(!isExpanded);
+ }),
+ getExpandedPasteAtLine: vi
+ .fn()
+ .mockReturnValue(isExpanded ? id : null),
+ };
+
+ return ;
+ };
+
+ const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
+ ,
+ {
+ mouseEventsEnabled: true,
+ useAlternateBuffer: true,
+ uiActions,
+ },
+ );
+
+ // 1. Verify initial placeholder
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ });
+
+ // Simulate double-click to expand
+ await simulateClick(stdin, 5, 2);
+ await simulateClick(stdin, 5, 2);
+
+ // 2. Verify expanded content is visible
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ });
+
+ // Simulate double-click to collapse
+ await simulateClick(stdin, 5, 2);
+ await simulateClick(stdin, 5, 2);
+
+ // 3. Verify placeholder is restored
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ });
+
+ unmount();
+ });
+
it('should move cursor on mouse click with plain borders', async () => {
props.config.getUseBackgroundColor = () => false;
props.buffer.text = 'hello world';
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index e0199b8630a..6baae451cb8 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -12,10 +12,11 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
-import type { TextBuffer } from './shared/text-buffer.js';
import {
+ type TextBuffer,
logicalPosToOffset,
PASTED_TEXT_PLACEHOLDER_REGEX,
+ getTransformUnderCursor,
} from './shared/text-buffer.js';
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import chalk from 'chalk';
@@ -56,8 +57,10 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { StreamingState } from '../types.js';
import { useMouseClick } from '../hooks/useMouseClick.js';
+import { useMouseDoubleClick } from '../hooks/useMouseDoubleClick.js';
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
/**
* Returns if the terminal can be trusted to handle paste events atomically
@@ -397,6 +400,40 @@ export const InputPrompt: React.FC = ({
{ isActive: focus },
);
+ const isAlternateBuffer = useAlternateBuffer();
+
+ // Double-click to expand/collapse paste placeholders
+ useMouseDoubleClick(
+ innerBoxRef,
+ (_event, relX, relY) => {
+ if (!isAlternateBuffer) return;
+
+ const logicalPos = buffer.getLogicalPositionFromVisual(
+ buffer.visualScrollRow + relY,
+ relX,
+ );
+ if (!logicalPos) return;
+
+ // Check for paste placeholder (collapsed state)
+ const transform = getTransformUnderCursor(
+ logicalPos.row,
+ logicalPos.col,
+ buffer.transformationsByLine,
+ );
+ if (transform?.type === 'paste' && transform.id) {
+ buffer.togglePasteExpansion(transform.id);
+ return;
+ }
+
+ // Check for expanded paste region
+ const expandedId = buffer.getExpandedPasteAtLine(logicalPos.row);
+ if (expandedId) {
+ buffer.togglePasteExpansion(expandedId);
+ }
+ },
+ { isActive: focus },
+ );
+
useMouse(
(event: MouseEvent) => {
if (event.name === 'right-release') {
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
index e7a5ba5156b..60c8889f36b 100644
--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -44,6 +44,33 @@ exports[`InputPrompt > image path transformation snapshots > should snapshot exp
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
+exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = `
+"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ > [Pasted Text: 10 lines]
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
+`;
+
+exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
+"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ > line1
+ line2
+ line3
+ line4
+ line5
+ line6
+ line7
+ line8
+ line9
+ line10
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
+`;
+
+exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = `
+"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ > [Pasted Text: 10 lines]
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
+`;
+
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts
index 6966d3b6959..6242202b761 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.test.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts
@@ -57,6 +57,7 @@ const initialState: TextBufferState = {
transformationsByLine: [[]],
visualLayout: defaultVisualLayout,
pastedContent: {},
+ expandedPasteInfo: new Map(),
};
/**
@@ -531,6 +532,150 @@ describe('textBufferReducer', () => {
expect(state.cursorCol).toBe(5);
});
});
+
+ describe('toggle_paste_expansion action', () => {
+ const placeholder = '[Pasted Text: 6 lines]';
+ const content = 'line1\nline2\nline3\nline4\nline5\nline6';
+
+ it('should expand a placeholder correctly', () => {
+ const stateWithPlaceholder = createStateWithTransformations({
+ lines: ['prefix ' + placeholder + ' suffix'],
+ cursorRow: 0,
+ cursorCol: 0,
+ pastedContent: { [placeholder]: content },
+ });
+
+ const action: TextBufferAction = {
+ type: 'toggle_paste_expansion',
+ payload: { id: placeholder },
+ };
+
+ const state = textBufferReducer(stateWithPlaceholder, action);
+
+ expect(state.lines).toEqual([
+ 'prefix line1',
+ 'line2',
+ 'line3',
+ 'line4',
+ 'line5',
+ 'line6 suffix',
+ ]);
+ expect(state.expandedPasteInfo.has(placeholder)).toBe(true);
+ const info = state.expandedPasteInfo.get(placeholder);
+ expect(info).toEqual({
+ startLine: 0,
+ lineCount: 6,
+ prefix: 'prefix ',
+ suffix: ' suffix',
+ });
+ // Cursor should be at the end of expanded content (before suffix)
+ expect(state.cursorRow).toBe(5);
+ expect(state.cursorCol).toBe(5); // length of 'line6'
+ });
+
+ it('should collapse an expanded placeholder correctly', () => {
+ const expandedState = createStateWithTransformations({
+ lines: [
+ 'prefix line1',
+ 'line2',
+ 'line3',
+ 'line4',
+ 'line5',
+ 'line6 suffix',
+ ],
+ cursorRow: 5,
+ cursorCol: 5,
+ pastedContent: { [placeholder]: content },
+ expandedPasteInfo: new Map([
+ [
+ placeholder,
+ {
+ startLine: 0,
+ lineCount: 6,
+ prefix: 'prefix ',
+ suffix: ' suffix',
+ },
+ ],
+ ]),
+ });
+
+ const action: TextBufferAction = {
+ type: 'toggle_paste_expansion',
+ payload: { id: placeholder },
+ };
+
+ const state = textBufferReducer(expandedState, action);
+
+ expect(state.lines).toEqual(['prefix ' + placeholder + ' suffix']);
+ expect(state.expandedPasteInfo.has(placeholder)).toBe(false);
+ // Cursor should be at the end of the collapsed placeholder
+ expect(state.cursorRow).toBe(0);
+ expect(state.cursorCol).toBe(('prefix ' + placeholder).length);
+ });
+
+ it('should expand single-line content correctly', () => {
+ const singleLinePlaceholder = '[Pasted Text: 10 chars]';
+ const singleLineContent = 'some text';
+ const stateWithPlaceholder = createStateWithTransformations({
+ lines: [singleLinePlaceholder],
+ cursorRow: 0,
+ cursorCol: 0,
+ pastedContent: { [singleLinePlaceholder]: singleLineContent },
+ });
+
+ const state = textBufferReducer(stateWithPlaceholder, {
+ type: 'toggle_paste_expansion',
+ payload: { id: singleLinePlaceholder },
+ });
+
+ expect(state.lines).toEqual(['some text']);
+ expect(state.cursorRow).toBe(0);
+ expect(state.cursorCol).toBe(9);
+ });
+
+ it('should return current state if placeholder ID not found in pastedContent', () => {
+ const action: TextBufferAction = {
+ type: 'toggle_paste_expansion',
+ payload: { id: 'unknown' },
+ };
+ const state = textBufferReducer(initialState, action);
+ expect(state).toBe(initialState);
+ });
+
+ it('should preserve expandedPasteInfo when lines change from edits outside the region', () => {
+ // Start with an expanded paste at line 0 (3 lines long)
+ const placeholder = '[Pasted Text: 3 lines]';
+ const expandedState = createStateWithTransformations({
+ lines: ['line1', 'line2', 'line3', 'suffix'],
+ cursorRow: 3,
+ cursorCol: 0,
+ pastedContent: { [placeholder]: 'line1\nline2\nline3' },
+ expandedPasteInfo: new Map([
+ [
+ placeholder,
+ {
+ startLine: 0,
+ lineCount: 3,
+ prefix: '',
+ suffix: '',
+ },
+ ],
+ ]),
+ });
+
+ expect(expandedState.expandedPasteInfo.size).toBe(1);
+
+ // Insert a newline at the end - this changes lines but is OUTSIDE the expanded region
+ const stateAfterInsert = textBufferReducer(expandedState, {
+ type: 'insert',
+ payload: '\n',
+ });
+
+ // Lines changed, but expandedPasteInfo should be PRESERVED and optionally shifted (no shift here since edit is after)
+ expect(stateAfterInsert.expandedPasteInfo.size).toBe(1);
+ expect(stateAfterInsert.expandedPasteInfo.has(placeholder)).toBe(true);
+ });
+ });
});
const getBufferState = (result: { current: TextBuffer }) => {
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 6624e98a8f1..bfa02a43796 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -586,6 +586,7 @@ interface UndoHistoryEntry {
cursorRow: number;
cursorCol: number;
pastedContent: Record;
+ expandedPasteInfo: Map;
}
function calculateInitialCursorPosition(
@@ -814,6 +815,110 @@ export function getTransformUnderCursor(
return null;
}
+export interface ExpandedPasteInfo {
+ startLine: number;
+ lineCount: number;
+ prefix: string;
+ suffix: string;
+}
+
+/**
+ * Check if a line index falls within an expanded paste region.
+ * Returns the paste placeholder ID if found, null otherwise.
+ */
+export function getExpandedPasteAtLine(
+ lineIndex: number,
+ expandedPasteInfo: Map,
+): string | null {
+ for (const [id, info] of expandedPasteInfo) {
+ if (
+ lineIndex >= info.startLine &&
+ lineIndex < info.startLine + info.lineCount
+ ) {
+ return id;
+ }
+ }
+ return null;
+}
+
+/**
+ * Surgery for expanded paste regions when lines are added or removed.
+ * Adjusts startLine indices and detaches any region that is partially or fully deleted.
+ */
+export function shiftExpandedRegions(
+ expandedPasteInfo: Map,
+ changeStartLine: number,
+ lineDelta: number,
+ changeEndLine?: number, // Inclusive
+): {
+ newInfo: Map;
+ detachedIds: Set;
+} {
+ const newInfo = new Map();
+ const detachedIds = new Set();
+ if (expandedPasteInfo.size === 0) return { newInfo, detachedIds };
+
+ const effectiveEndLine = changeEndLine ?? changeStartLine;
+
+ for (const [id, info] of expandedPasteInfo) {
+ const infoEndLine = info.startLine + info.lineCount - 1;
+
+ // 1. Check for overlap/intersection with the changed range
+ const isOverlapping =
+ changeStartLine <= infoEndLine && effectiveEndLine >= info.startLine;
+
+ if (isOverlapping) {
+ // If the change is a deletion (lineDelta < 0) that touches this region, we detach.
+ // If it's an insertion, we only detach if it's a multi-line insertion (lineDelta > 0)
+ // that isn't at the very start of the region (which would shift it).
+ // Regular character typing (lineDelta === 0) does NOT detach.
+ if (
+ lineDelta < 0 ||
+ (lineDelta > 0 &&
+ changeStartLine > info.startLine &&
+ changeStartLine <= infoEndLine)
+ ) {
+ detachedIds.add(id);
+ continue; // Detach by not adding to newInfo
+ }
+ }
+
+ // 2. Shift regions that start at or after the change point
+ if (info.startLine >= changeStartLine) {
+ newInfo.set(id, {
+ ...info,
+ startLine: info.startLine + lineDelta,
+ });
+ } else {
+ newInfo.set(id, info);
+ }
+ }
+
+ return { newInfo, detachedIds };
+}
+
+/**
+ * Detach any expanded paste region if the cursor is within it.
+ * This converts the expanded content to regular text that can no longer be collapsed.
+ * Returns the state unchanged if cursor is not in an expanded region.
+ */
+export function detachExpandedPaste(state: TextBufferState): TextBufferState {
+ const expandedId = getExpandedPasteAtLine(
+ state.cursorRow,
+ state.expandedPasteInfo,
+ );
+ if (!expandedId) return state;
+
+ const newExpandedInfo = new Map(state.expandedPasteInfo);
+ newExpandedInfo.delete(expandedId);
+ const { [expandedId]: _, ...newPastedContent } = state.pastedContent;
+ return {
+ ...state,
+ expandedPasteInfo: newExpandedInfo,
+ pastedContent: newPastedContent,
+ };
+}
+
/**
* Represents an atomic placeholder that should be deleted as a unit.
* Extensible to support future placeholder types.
@@ -1272,16 +1377,18 @@ export interface TextBufferState {
viewportHeight: number;
visualLayout: VisualLayout;
pastedContent: Record;
+ expandedPasteInfo: Map;
}
const historyLimit = 100;
export const pushUndo = (currentState: TextBufferState): TextBufferState => {
- const snapshot = {
+ const snapshot: UndoHistoryEntry = {
lines: [...currentState.lines],
cursorRow: currentState.cursorRow,
cursorCol: currentState.cursorCol,
pastedContent: { ...currentState.pastedContent },
+ expandedPasteInfo: new Map(currentState.expandedPasteInfo),
};
const newStack = [...currentState.undoStack, snapshot];
if (newStack.length > historyLimit) {
@@ -1383,7 +1490,8 @@ export type TextBufferAction =
| { type: 'vim_move_to_first_line' }
| { type: 'vim_move_to_last_line' }
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
- | { type: 'vim_escape_insert_mode' };
+ | { type: 'vim_escape_insert_mode' }
+ | { type: 'toggle_paste_expansion'; payload: { id: string } };
export interface TextBufferOptions {
inputFilter?: (text: string) => string;
@@ -1422,7 +1530,7 @@ function textBufferReducerLogic(
}
case 'insert': {
- const nextState = pushUndoLocal(state);
+ const nextState = detachExpandedPaste(pushUndoLocal(state));
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
@@ -1468,6 +1576,7 @@ function textBufferReducerLogic(
const before = cpSlice(lineContent, 0, newCursorCol);
const after = cpSlice(lineContent, newCursorCol);
+ let lineDelta = 0;
if (parts.length > 1) {
newLines[newCursorRow] = before + parts[0];
const remainingParts = parts.slice(1);
@@ -1478,6 +1587,7 @@ function textBufferReducerLogic(
0,
lastPartOriginal + after,
);
+ lineDelta = parts.length - 1;
newCursorRow = newCursorRow + parts.length - 1;
newCursorCol = cpLen(lastPartOriginal);
} else {
@@ -1485,6 +1595,16 @@ function textBufferReducerLogic(
newCursorCol = cpLen(before) + cpLen(parts[0]);
}
+ const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
+ nextState.expandedPasteInfo,
+ nextState.cursorRow,
+ lineDelta,
+ );
+
+ for (const id of detachedIds) {
+ delete newPastedContent[id];
+ }
+
return {
...nextState,
lines: newLines,
@@ -1492,6 +1612,7 @@ function textBufferReducerLogic(
cursorCol: newCursorCol,
preferredCol: null,
pastedContent: newPastedContent,
+ expandedPasteInfo: newExpandedInfo,
};
}
@@ -1507,10 +1628,13 @@ function textBufferReducerLogic(
}
case 'backspace': {
- const { cursorRow, cursorCol, lines, transformationsByLine } = state;
+ const stateWithUndo = pushUndoLocal(state);
+ const currentState = detachExpandedPaste(stateWithUndo);
+ const { cursorRow, cursorCol, lines, transformationsByLine } =
+ currentState;
// Early return if at start of buffer
- if (cursorCol === 0 && cursorRow === 0) return state;
+ if (cursorCol === 0 && cursorRow === 0) return currentState;
// Check if cursor is at end of an atomic placeholder
const transformations = transformationsByLine[cursorRow] ?? [];
@@ -1521,7 +1645,7 @@ function textBufferReducerLogic(
);
if (placeholder) {
- const nextState = pushUndoLocal(state);
+ const nextState = currentState;
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(newLines[cursorRow], 0, placeholder.start) +
@@ -1551,13 +1675,14 @@ function textBufferReducerLogic(
}
// Standard backspace logic
- const nextState = pushUndoLocal(state);
+ const nextState = currentState;
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
const currentLine = (r: number) => newLines[r] ?? '';
+ let lineDelta = 0;
if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow);
newLines[newCursorRow] =
@@ -1570,16 +1695,31 @@ function textBufferReducerLogic(
const newCol = cpLen(prevLineContent);
newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
newLines.splice(newCursorRow, 1);
+ lineDelta = -1;
newCursorRow--;
newCursorCol = newCol;
}
+ const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
+ nextState.expandedPasteInfo,
+ nextState.cursorRow + lineDelta, // shift based on the line that was removed
+ lineDelta,
+ nextState.cursorRow,
+ );
+
+ const newPastedContent = { ...nextState.pastedContent };
+ for (const id of detachedIds) {
+ delete newPastedContent[id];
+ }
+
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
+ pastedContent: newPastedContent,
+ expandedPasteInfo: newExpandedInfo,
};
}
@@ -1767,7 +1907,10 @@ function textBufferReducerLogic(
}
case 'delete': {
- const { cursorRow, cursorCol, lines, transformationsByLine } = state;
+ const stateWithUndo = pushUndoLocal(state);
+ const currentState = detachExpandedPaste(stateWithUndo);
+ const { cursorRow, cursorCol, lines, transformationsByLine } =
+ currentState;
// Check if cursor is at start of an atomic placeholder
const transformations = transformationsByLine[cursorRow] ?? [];
@@ -1778,7 +1921,7 @@ function textBufferReducerLogic(
);
if (placeholder) {
- const nextState = pushUndoLocal(state);
+ const nextState = currentState;
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(newLines[cursorRow], 0, placeholder.start) +
@@ -1809,37 +1952,51 @@ function textBufferReducerLogic(
// Standard delete logic
const lineContent = currentLine(cursorRow);
+ let lineDelta = 0;
+ const nextState = currentState;
+ const newLines = [...nextState.lines];
+
if (cursorCol < currentLineLen(cursorRow)) {
- const nextState = pushUndoLocal(state);
- const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) +
cpSlice(lineContent, cursorCol + 1);
- return {
- ...nextState,
- lines: newLines,
- preferredCol: null,
- };
} else if (cursorRow < lines.length - 1) {
- const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
- const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
- return {
- ...nextState,
- lines: newLines,
- preferredCol: null,
- };
+ lineDelta = -1;
+ } else {
+ return currentState;
}
- return state;
+
+ const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
+ nextState.expandedPasteInfo,
+ nextState.cursorRow,
+ lineDelta,
+ nextState.cursorRow + (lineDelta < 0 ? 1 : 0),
+ );
+
+ const newPastedContent = { ...nextState.pastedContent };
+ for (const id of detachedIds) {
+ delete newPastedContent[id];
+ }
+
+ return {
+ ...nextState,
+ lines: newLines,
+ preferredCol: null,
+ pastedContent: newPastedContent,
+ expandedPasteInfo: newExpandedInfo,
+ };
}
case 'delete_word_left': {
- const { cursorRow, cursorCol } = state;
- if (cursorCol === 0 && cursorRow === 0) return state;
+ const stateWithUndo = pushUndoLocal(state);
+ const currentState = detachExpandedPaste(stateWithUndo);
+ const { cursorRow, cursorCol } = currentState;
+ if (cursorCol === 0 && cursorRow === 0) return currentState;
- const nextState = pushUndoLocal(state);
+ const nextState = currentState;
const newLines = [...nextState.lines];
let newCursorRow = cursorRow;
let newCursorCol = cursorCol;
@@ -1875,15 +2032,17 @@ function textBufferReducerLogic(
}
case 'delete_word_right': {
- const { cursorRow, cursorCol, lines } = state;
+ const stateWithUndo = pushUndoLocal(state);
+ const currentState = detachExpandedPaste(stateWithUndo);
+ const { cursorRow, cursorCol, lines } = currentState;
const lineContent = currentLine(cursorRow);
const lineLen = cpLen(lineContent);
if (cursorCol >= lineLen && cursorRow === lines.length - 1) {
- return state;
+ return currentState;
}
- const nextState = pushUndoLocal(state);
+ const nextState = currentState;
const newLines = [...nextState.lines];
if (cursorCol >= lineLen) {
@@ -1906,10 +2065,12 @@ function textBufferReducerLogic(
}
case 'kill_line_right': {
- const { cursorRow, cursorCol, lines } = state;
+ const stateWithUndo = pushUndoLocal(state);
+ const currentState = detachExpandedPaste(stateWithUndo);
+ const { cursorRow, cursorCol, lines } = currentState;
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
- const nextState = pushUndoLocal(state);
+ const nextState = currentState;
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
return {
@@ -1918,7 +2079,7 @@ function textBufferReducerLogic(
};
} else if (cursorRow < lines.length - 1) {
// Act as a delete
- const nextState = pushUndoLocal(state);
+ const nextState = currentState;
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
@@ -1929,13 +2090,15 @@ function textBufferReducerLogic(
preferredCol: null,
};
}
- return state;
+ return currentState;
}
case 'kill_line_left': {
- const { cursorRow, cursorCol } = state;
+ const stateWithUndo = pushUndoLocal(state);
+ const currentState = detachExpandedPaste(stateWithUndo);
+ const { cursorRow, cursorCol } = currentState;
if (cursorCol > 0) {
- const nextState = pushUndoLocal(state);
+ const nextState = currentState;
const lineContent = currentLine(cursorRow);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
@@ -1946,18 +2109,19 @@ function textBufferReducerLogic(
preferredCol: null,
};
}
- return state;
+ return currentState;
}
case 'undo': {
const stateToRestore = state.undoStack[state.undoStack.length - 1];
if (!stateToRestore) return state;
- const currentSnapshot = {
+ const currentSnapshot: UndoHistoryEntry = {
lines: [...state.lines],
cursorRow: state.cursorRow,
cursorCol: state.cursorCol,
pastedContent: { ...state.pastedContent },
+ expandedPasteInfo: new Map(state.expandedPasteInfo),
};
return {
...state,
@@ -1971,11 +2135,12 @@ function textBufferReducerLogic(
const stateToRestore = state.redoStack[state.redoStack.length - 1];
if (!stateToRestore) return state;
- const currentSnapshot = {
+ const currentSnapshot: UndoHistoryEntry = {
lines: [...state.lines],
cursorRow: state.cursorRow,
cursorCol: state.cursorCol,
pastedContent: { ...state.pastedContent },
+ expandedPasteInfo: new Map(state.expandedPasteInfo),
};
return {
...state,
@@ -1988,7 +2153,7 @@ function textBufferReducerLogic(
case 'replace_range': {
const { startRow, startCol, endRow, endCol, text } = action.payload;
const nextState = pushUndoLocal(state);
- return replaceRangeInternal(
+ const newState = replaceRangeInternal(
nextState,
startRow,
startCol,
@@ -1996,6 +2161,29 @@ function textBufferReducerLogic(
endCol,
text,
);
+
+ const oldLineCount = endRow - startRow + 1;
+ const newLineCount =
+ newState.lines.length - (nextState.lines.length - oldLineCount);
+ const lineDelta = newLineCount - oldLineCount;
+
+ const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
+ nextState.expandedPasteInfo,
+ startRow,
+ lineDelta,
+ endRow,
+ );
+
+ const newPastedContent = { ...newState.pastedContent };
+ for (const id of detachedIds) {
+ delete newPastedContent[id];
+ }
+
+ return {
+ ...newState,
+ pastedContent: newPastedContent,
+ expandedPasteInfo: newExpandedInfo,
+ };
}
case 'move_to_offset': {
@@ -2051,6 +2239,133 @@ function textBufferReducerLogic(
case 'vim_escape_insert_mode':
return handleVimAction(state, action as VimAction);
+ case 'toggle_paste_expansion': {
+ const { id } = action.payload;
+ const info = state.expandedPasteInfo.get(id);
+
+ if (info) {
+ const nextState = pushUndoLocal(state);
+ // COLLAPSE: Restore original line with placeholder
+ const newLines = [...nextState.lines];
+ newLines.splice(
+ info.startLine,
+ info.lineCount,
+ info.prefix + id + info.suffix,
+ );
+
+ const lineDelta = 1 - info.lineCount;
+ const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
+ nextState.expandedPasteInfo,
+ info.startLine,
+ lineDelta,
+ info.startLine + info.lineCount - 1,
+ );
+ newExpandedInfo.delete(id); // Already shifted, now remove self
+
+ const newPastedContent = { ...nextState.pastedContent };
+ for (const detachedId of detachedIds) {
+ if (detachedId !== id) {
+ delete newPastedContent[detachedId];
+ }
+ }
+
+ // Move cursor to end of collapsed placeholder
+ const newCursorRow = info.startLine;
+ const newCursorCol = cpLen(info.prefix) + cpLen(id);
+
+ return {
+ ...nextState,
+ lines: newLines,
+ cursorRow: newCursorRow,
+ cursorCol: newCursorCol,
+ preferredCol: null,
+ pastedContent: newPastedContent,
+ expandedPasteInfo: newExpandedInfo,
+ };
+ } else {
+ // EXPAND: Replace placeholder with content
+ const content = state.pastedContent[id];
+ if (!content) return state;
+
+ // Find line and position containing exactly this placeholder
+ let lineIndex = -1;
+ let placeholderStart = -1;
+ for (let i = 0; i < state.lines.length; i++) {
+ const transforms = state.transformationsByLine[i] ?? [];
+ const transform = transforms.find(
+ (t) => t.type === 'paste' && t.id === id,
+ );
+ if (transform) {
+ lineIndex = i;
+ placeholderStart = transform.logStart;
+ break;
+ }
+ }
+
+ if (lineIndex === -1) return state;
+
+ const nextState = pushUndoLocal(state);
+
+ const line = nextState.lines[lineIndex];
+ const prefix = cpSlice(line, 0, placeholderStart);
+ const suffix = cpSlice(line, placeholderStart + cpLen(id));
+
+ // Split content into lines
+ const contentLines = content.split('\n');
+ const newLines = [...nextState.lines];
+
+ let expandedLines: string[];
+ if (contentLines.length === 1) {
+ // Single-line content
+ expandedLines = [prefix + contentLines[0] + suffix];
+ } else {
+ // Multi-line content
+ expandedLines = [
+ prefix + contentLines[0],
+ ...contentLines.slice(1, -1),
+ contentLines[contentLines.length - 1] + suffix,
+ ];
+ }
+
+ newLines.splice(lineIndex, 1, ...expandedLines);
+
+ const lineDelta = expandedLines.length - 1;
+ const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
+ nextState.expandedPasteInfo,
+ lineIndex,
+ lineDelta,
+ lineIndex,
+ );
+
+ const newPastedContent = { ...nextState.pastedContent };
+ for (const detachedId of detachedIds) {
+ delete newPastedContent[detachedId];
+ }
+
+ newExpandedInfo.set(id, {
+ startLine: lineIndex,
+ lineCount: expandedLines.length,
+ prefix,
+ suffix,
+ });
+
+ // Move cursor to end of expanded content (before suffix)
+ const newCursorRow = lineIndex + expandedLines.length - 1;
+ const lastExpandedLine = expandedLines[expandedLines.length - 1];
+ const newCursorCol = cpLen(lastExpandedLine) - cpLen(suffix);
+
+ return {
+ ...nextState,
+ lines: newLines,
+ cursorRow: newCursorRow,
+ cursorCol: newCursorCol,
+ preferredCol: null,
+ pastedContent: newPastedContent,
+ expandedPasteInfo: newExpandedInfo,
+ };
+ }
+ }
+
default: {
const exhaustiveCheck: never = action;
debugLogger.error(`Unknown action encountered: ${exhaustiveCheck}`);
@@ -2095,6 +2410,7 @@ export function textBufferReducer(
) {
const shouldResetPreferred =
oldInside !== newInside || movedBetweenTransforms;
+
return {
...newState,
preferredCol: shouldResetPreferred ? null : newState.preferredCol,
@@ -2152,6 +2468,7 @@ export function useTextBuffer({
viewportHeight: viewport.height,
visualLayout,
pastedContent: {},
+ expandedPasteInfo: new Map(),
};
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
@@ -2169,6 +2486,7 @@ export function useTextBuffer({
visualLayout,
transformationsByLine,
pastedContent,
+ expandedPasteInfo,
} = state;
const text = useMemo(() => lines.join('\n'), [lines]);
@@ -2454,7 +2772,12 @@ export function useTextBuffer({
const openInExternalEditor = useCallback(async (): Promise => {
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
const filePath = pathMod.join(tmpDir, 'buffer.txt');
- fs.writeFileSync(filePath, text, 'utf8');
+ // Expand paste placeholders so user sees full content in editor
+ const expandedText = text.replace(
+ PASTED_TEXT_PLACEHOLDER_REGEX,
+ (match) => pastedContent[match] || match,
+ );
+ fs.writeFileSync(filePath, expandedText, 'utf8');
let command: string | undefined = undefined;
const args = [filePath];
@@ -2488,6 +2811,17 @@ export function useTextBuffer({
let newText = fs.readFileSync(filePath, 'utf8');
newText = newText.replace(/\r\n?/g, '\n');
+
+ // Attempt to re-collapse unchanged pasted content back into placeholders
+ const sortedPlaceholders = Object.entries(pastedContent).sort(
+ (a, b) => b[1].length - a[1].length,
+ );
+ for (const [id, content] of sortedPlaceholders) {
+ if (newText.includes(content)) {
+ newText = newText.replace(content, id);
+ }
+ }
+
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
} catch (err) {
coreEvents.emitFeedback(
@@ -2509,7 +2843,7 @@ export function useTextBuffer({
/* ignore */
}
}
- }, [text, stdin, setRawMode, getPreferredEditor]);
+ }, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
const handleInput = useCallback(
(key: Key): void => {
@@ -2650,11 +2984,81 @@ export function useTextBuffer({
[visualLayout, lines],
);
+ const getLogicalPositionFromVisual = useCallback(
+ (visRow: number, visCol: number): { row: number; col: number } | null => {
+ const {
+ visualLines,
+ visualToLogicalMap,
+ transformedToLogicalMaps,
+ visualToTransformedMap,
+ } = visualLayout;
+
+ // Clamp visRow to valid range
+ const clampedVisRow = Math.max(
+ 0,
+ Math.min(visRow, visualLines.length - 1),
+ );
+ const visualLine = visualLines[clampedVisRow] || '';
+
+ if (!visualToLogicalMap[clampedVisRow]) {
+ return null;
+ }
+
+ const [logRow] = visualToLogicalMap[clampedVisRow];
+ const transformedToLogicalMap = transformedToLogicalMaps?.[logRow] ?? [];
+
+ // Where does this visual line begin within the transformed line?
+ const startColInTransformed =
+ visualToTransformedMap?.[clampedVisRow] ?? 0;
+
+ // Handle wide characters: convert visual X position to character offset
+ const codePoints = toCodePoints(visualLine);
+ let currentVisX = 0;
+ let charOffset = 0;
+
+ for (const char of codePoints) {
+ const charWidth = getCachedStringWidth(char);
+ if (visCol < currentVisX + charWidth) {
+ if (charWidth > 1 && visCol >= currentVisX + charWidth / 2) {
+ charOffset++;
+ }
+ break;
+ }
+ currentVisX += charWidth;
+ charOffset++;
+ }
+
+ charOffset = Math.min(charOffset, codePoints.length);
+
+ const transformedCol = Math.min(
+ startColInTransformed + charOffset,
+ Math.max(0, transformedToLogicalMap.length - 1),
+ );
+
+ const row = logRow;
+ const col =
+ transformedToLogicalMap[transformedCol] ?? cpLen(lines[logRow] ?? '');
+
+ return { row, col };
+ },
+ [visualLayout, lines],
+ );
+
const getOffset = useCallback(
(): number => logicalPosToOffset(lines, cursorRow, cursorCol),
[lines, cursorRow, cursorCol],
);
+ const togglePasteExpansion = useCallback((id: string): void => {
+ dispatch({ type: 'toggle_paste_expansion', payload: { id } });
+ }, []);
+
+ const getExpandedPasteAtLineCallback = useCallback(
+ (lineIndex: number): string | null =>
+ getExpandedPasteAtLine(lineIndex, expandedPasteInfo),
+ [expandedPasteInfo],
+ );
+
const returnValue: TextBuffer = useMemo(
() => ({
lines,
@@ -2686,6 +3090,10 @@ export function useTextBuffer({
moveToOffset,
getOffset,
moveToVisualPosition,
+ getLogicalPositionFromVisual,
+ getExpandedPasteAtLine: getExpandedPasteAtLineCallback,
+ togglePasteExpansion,
+ expandedPasteInfo,
deleteWordLeft,
deleteWordRight,
@@ -2757,6 +3165,10 @@ export function useTextBuffer({
moveToOffset,
getOffset,
moveToVisualPosition,
+ getLogicalPositionFromVisual,
+ getExpandedPasteAtLineCallback,
+ togglePasteExpansion,
+ expandedPasteInfo,
deleteWordLeft,
deleteWordRight,
killLineRight,
@@ -2926,6 +3338,29 @@ export interface TextBuffer {
getOffset: () => number;
moveToOffset(offset: number): void;
moveToVisualPosition(visualRow: number, visualCol: number): void;
+ /**
+ * Convert visual coordinates to logical position without moving cursor.
+ * Returns null if the position is out of bounds.
+ */
+ getLogicalPositionFromVisual(
+ visualRow: number,
+ visualCol: number,
+ ): { row: number; col: number } | null;
+ /**
+ * Check if a line index falls within an expanded paste region.
+ * Returns the paste placeholder ID if found, null otherwise.
+ */
+ getExpandedPasteAtLine(lineIndex: number): string | null;
+ /**
+ * Toggle expansion state for a paste placeholder.
+ * If collapsed, expands to show full content inline.
+ * If expanded, collapses back to placeholder.
+ */
+ togglePasteExpansion(id: string): void;
+ /**
+ * The current expanded paste info map (read-only).
+ */
+ expandedPasteInfo: Map;
// Vim-specific operations
/**
diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
index d258b06cc93..53235900606 100644
--- a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
+++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
@@ -35,6 +35,7 @@ const createTestState = (
transformationsByLine: [[]],
visualLayout: defaultVisualLayout,
pastedContent: {},
+ expandedPasteInfo: new Map(),
});
describe('vim-buffer-actions', () => {
@@ -906,7 +907,13 @@ describe('vim-buffer-actions', () => {
it('should preserve undo stack in operations', () => {
const state = createTestState(['hello'], 0, 0);
state.undoStack = [
- { lines: ['previous'], cursorRow: 0, cursorCol: 0, pastedContent: {} },
+ {
+ lines: ['previous'],
+ cursorRow: 0,
+ cursorCol: 0,
+ pastedContent: {},
+ expandedPasteInfo: new Map(),
+ },
];
const action = {
diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts
index 8243aeabd14..67aa50faeb0 100644
--- a/packages/cli/src/ui/components/shared/vim-buffer-actions.ts
+++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts
@@ -10,6 +10,7 @@ import {
getPositionFromOffsets,
replaceRangeInternal,
pushUndo,
+ detachExpandedPaste,
isWordCharStrict,
isWordCharWithCombining,
isCombiningMark,
@@ -105,7 +106,7 @@ export function handleVimAction(
}
if (endRow !== cursorRow || endCol !== cursorCol) {
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
@@ -135,7 +136,7 @@ export function handleVimAction(
}
if (startRow !== cursorRow || startCol !== cursorCol) {
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
startRow,
@@ -188,7 +189,7 @@ export function handleVimAction(
}
if (endRow !== cursorRow || endCol !== cursorCol) {
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
@@ -211,7 +212,7 @@ export function handleVimAction(
if (totalLines === 1 || linesToDelete >= totalLines) {
// If there's only one line, or we're deleting all remaining lines,
// clear the content but keep one empty line (text editors should never be completely empty)
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
return {
...nextState,
lines: [''],
@@ -221,7 +222,7 @@ export function handleVimAction(
};
}
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(cursorRow, linesToDelete);
@@ -243,7 +244,7 @@ export function handleVimAction(
if (lines.length === 0) return state;
const linesToChange = Math.min(count, lines.length - cursorRow);
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
cursorRow,
@@ -269,7 +270,7 @@ export function handleVimAction(
case 'vim_change_to_end_of_line': {
const currentLine = lines[cursorRow] || '';
if (cursorCol < cpLen(currentLine)) {
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
@@ -292,7 +293,7 @@ export function handleVimAction(
// Change N characters to the left
const startCol = Math.max(0, cursorCol - count);
return replaceRangeInternal(
- pushUndo(state),
+ detachExpandedPaste(pushUndo(state)),
cursorRow,
startCol,
cursorRow,
@@ -308,7 +309,7 @@ export function handleVimAction(
if (totalLines === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
- pushUndo(state),
+ detachExpandedPaste(pushUndo(state)),
0,
0,
0,
@@ -316,7 +317,7 @@ export function handleVimAction(
'',
);
} else {
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
cursorRow,
linesToChange,
@@ -344,7 +345,7 @@ export function handleVimAction(
if (state.lines.length === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
- pushUndo(state),
+ detachExpandedPaste(pushUndo(state)),
0,
0,
0,
@@ -354,7 +355,7 @@ export function handleVimAction(
} else {
const startRow = Math.max(0, cursorRow - count + 1);
const linesToChange = cursorRow - startRow + 1;
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
startRow,
linesToChange,
@@ -392,7 +393,7 @@ export function handleVimAction(
// Right
// Change N characters to the right
return replaceRangeInternal(
- pushUndo(state),
+ detachExpandedPaste(pushUndo(state)),
cursorRow,
cursorCol,
cursorRow,
@@ -624,7 +625,7 @@ export function handleVimAction(
if (cursorCol < lineLength) {
const deleteCount = Math.min(count, lineLength - cursorCol);
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
@@ -656,7 +657,7 @@ export function handleVimAction(
case 'vim_open_line_below': {
const { cursorRow, lines } = state;
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
// Insert newline at end of current line
const endOfLine = cpLen(lines[cursorRow] || '');
@@ -672,7 +673,7 @@ export function handleVimAction(
case 'vim_open_line_above': {
const { cursorRow } = state;
- const nextState = pushUndo(state);
+ const nextState = detachExpandedPaste(pushUndo(state));
// Insert newline at beginning of current line
const resultState = replaceRangeInternal(
diff --git a/packages/cli/src/ui/hooks/useMouseDoubleClick.test.ts b/packages/cli/src/ui/hooks/useMouseDoubleClick.test.ts
new file mode 100644
index 00000000000..0a58acfe19f
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useMouseDoubleClick.test.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act } from 'react';
+import { renderHook } from '../../test-utils/render.js';
+import { useMouseDoubleClick } from './useMouseDoubleClick.js';
+import * as MouseContext from '../contexts/MouseContext.js';
+import type { MouseEvent } from '../contexts/MouseContext.js';
+import type { DOMElement } from 'ink';
+
+describe('useMouseDoubleClick', () => {
+ const mockHandler = vi.fn();
+ const mockContainerRef = {
+ current: {} as DOMElement,
+ };
+
+ // Mock getBoundingBox from ink
+ vi.mock('ink', async () => {
+ const actual = await vi.importActual('ink');
+ return {
+ ...actual,
+ getBoundingBox: () => ({ x: 0, y: 0, width: 80, height: 24 }),
+ };
+ });
+
+ let mouseCallback: (event: MouseEvent) => void;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+
+ // Mock useMouse to capture the callback
+ vi.spyOn(MouseContext, 'useMouse').mockImplementation((callback) => {
+ mouseCallback = callback;
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('should detect double-click within threshold', async () => {
+ renderHook(() => useMouseDoubleClick(mockContainerRef, mockHandler));
+
+ const event1: MouseEvent = {
+ name: 'left-press',
+ col: 10,
+ row: 5,
+ shift: false,
+ meta: false,
+ ctrl: false,
+ button: 'left',
+ };
+ const event2: MouseEvent = {
+ name: 'left-press',
+ col: 10,
+ row: 5,
+ shift: false,
+ meta: false,
+ ctrl: false,
+ button: 'left',
+ };
+ await act(async () => {
+ mouseCallback(event1);
+ vi.advanceTimersByTime(200);
+ mouseCallback(event2);
+ });
+
+ expect(mockHandler).toHaveBeenCalledWith(event2, 9, 4);
+ });
+
+ it('should NOT detect double-click if time exceeds threshold', async () => {
+ renderHook(() => useMouseDoubleClick(mockContainerRef, mockHandler));
+
+ const event1: MouseEvent = {
+ name: 'left-press',
+ col: 10,
+ row: 5,
+ shift: false,
+ meta: false,
+ ctrl: false,
+ button: 'left',
+ };
+ const event2: MouseEvent = {
+ name: 'left-press',
+ col: 10,
+ row: 5,
+ shift: false,
+ meta: false,
+ ctrl: false,
+ button: 'left',
+ };
+
+ await act(async () => {
+ mouseCallback(event1);
+ vi.advanceTimersByTime(500); // Threshold is 400ms
+ mouseCallback(event2);
+ });
+
+ expect(mockHandler).not.toHaveBeenCalled();
+ });
+
+ it('should NOT detect double-click if distance exceeds tolerance', async () => {
+ renderHook(() => useMouseDoubleClick(mockContainerRef, mockHandler));
+
+ const event1: MouseEvent = {
+ name: 'left-press',
+ col: 10,
+ row: 5,
+ shift: false,
+ meta: false,
+ ctrl: false,
+ button: 'left',
+ };
+ const event2: MouseEvent = {
+ name: 'left-press',
+ col: 15,
+ row: 10,
+ shift: false,
+ meta: false,
+ ctrl: false,
+ button: 'left',
+ };
+
+ await act(async () => {
+ mouseCallback(event1);
+ vi.advanceTimersByTime(200);
+ mouseCallback(event2);
+ });
+
+ expect(mockHandler).not.toHaveBeenCalled();
+ });
+
+ it('should respect isActive option', () => {
+ renderHook(() =>
+ useMouseDoubleClick(mockContainerRef, mockHandler, { isActive: false }),
+ );
+
+ expect(MouseContext.useMouse).toHaveBeenCalledWith(expect.any(Function), {
+ isActive: false,
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useMouseDoubleClick.ts b/packages/cli/src/ui/hooks/useMouseDoubleClick.ts
new file mode 100644
index 00000000000..46dbce52aa3
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useMouseDoubleClick.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { getBoundingBox, type DOMElement } from 'ink';
+import type React from 'react';
+import { useRef, useCallback } from 'react';
+import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
+
+const DOUBLE_CLICK_THRESHOLD_MS = 400;
+const DOUBLE_CLICK_DISTANCE_TOLERANCE = 2;
+
+export const useMouseDoubleClick = (
+ containerRef: React.RefObject,
+ handler: (event: MouseEvent, relativeX: number, relativeY: number) => void,
+ options: { isActive?: boolean } = {},
+) => {
+ const { isActive = true } = options;
+ const handlerRef = useRef(handler);
+ handlerRef.current = handler;
+
+ const lastClickRef = useRef<{
+ time: number;
+ col: number;
+ row: number;
+ } | null>(null);
+
+ const onMouse = useCallback(
+ (event: MouseEvent) => {
+ if (event.name !== 'left-press' || !containerRef.current) return;
+
+ const now = Date.now();
+ const lastClick = lastClickRef.current;
+
+ // Check if this is a valid double-click
+ if (
+ lastClick &&
+ now - lastClick.time < DOUBLE_CLICK_THRESHOLD_MS &&
+ Math.abs(event.col - lastClick.col) <=
+ DOUBLE_CLICK_DISTANCE_TOLERANCE &&
+ Math.abs(event.row - lastClick.row) <= DOUBLE_CLICK_DISTANCE_TOLERANCE
+ ) {
+ // Double-click detected
+ const { x, y, width, height } = getBoundingBox(containerRef.current);
+ // Terminal mouse events are 1-based, Ink layout is 0-based.
+ const mouseX = event.col - 1;
+ const mouseY = event.row - 1;
+
+ const relativeX = mouseX - x;
+ const relativeY = mouseY - y;
+
+ if (
+ relativeX >= 0 &&
+ relativeX < width &&
+ relativeY >= 0 &&
+ relativeY < height
+ ) {
+ handlerRef.current(event, relativeX, relativeY);
+ }
+ lastClickRef.current = null; // Reset after double-click
+ } else {
+ // First click, record it
+ lastClickRef.current = { time: now, col: event.col, row: event.row };
+ }
+ },
+ [containerRef],
+ );
+
+ useMouse(onMouse, { isActive });
+};
diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx
index bc0d15d9d1a..e3f6f34bbef 100644
--- a/packages/cli/src/ui/hooks/vim.test.tsx
+++ b/packages/cli/src/ui/hooks/vim.test.tsx
@@ -68,6 +68,7 @@ const createMockTextBufferState = (
visualToTransformedMap: [],
},
pastedContent: {},
+ expandedPasteInfo: new Map(),
...partial,
};
};