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