feat: add double-click to expand/collapse large paste placeholders (#17471)

This commit is contained in:
Jack Wotherspoon
2026-01-26 21:59:09 -05:00
committed by GitHub
parent 5cf06503c8
commit a79051d9f8
11 changed files with 1024 additions and 61 deletions

View File

@@ -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.

View File

@@ -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 <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
};
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
<TestWrapper />,
{
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';

View File

@@ -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<InputPromptProps> = ({
{ 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') {

View File

@@ -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

View File

@@ -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 }) => {

View File

@@ -586,6 +586,7 @@ interface UndoHistoryEntry {
cursorRow: number;
cursorCol: number;
pastedContent: Record<string, string>;
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
}
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, ExpandedPasteInfo>,
): 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<string, ExpandedPasteInfo>,
changeStartLine: number,
lineDelta: number,
changeEndLine?: number, // Inclusive
): {
newInfo: Map<string, ExpandedPasteInfo>;
detachedIds: Set<string>;
} {
const newInfo = new Map<string, ExpandedPasteInfo>();
const detachedIds = new Set<string>();
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<string, string>;
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
}
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<void> => {
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<string, ExpandedPasteInfo>;
// Vim-specific operations
/**

View File

@@ -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 = {

View File

@@ -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(

View File

@@ -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,
});
});
});

View File

@@ -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<DOMElement | null>,
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 });
};

View File

@@ -68,6 +68,7 @@ const createMockTextBufferState = (
visualToTransformedMap: [],
},
pastedContent: {},
expandedPasteInfo: new Map(),
...partial,
};
};