Skip to content

Commit 2201dbd

Browse files
jackwotherspoonYuna Seol
authored andcommitted
feat: add double-click to expand/collapse large paste placeholders (#17471)
1 parent 5259ce8 commit 2201dbd

File tree

11 files changed

+1024
-61
lines changed

11 files changed

+1024
-61
lines changed

docs/cli/keyboard-shortcuts.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,6 @@ available combinations.
124124
single-line input, navigate backward or forward through prompt history.
125125
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
126126
the numbered radio option and confirm when the full number is entered.
127+
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
128+
buffer mode: Expand to view full content inline. Double-click again to
129+
collapse.

packages/cli/src/ui/components/InputPrompt.test.tsx

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
createMockSettings,
1010
} from '../../test-utils/render.js';
1111
import { waitFor } from '../../test-utils/async.js';
12-
import { act } from 'react';
12+
import { act, useState } from 'react';
1313
import type { InputPromptProps } from './InputPrompt.js';
1414
import { InputPrompt } from './InputPrompt.js';
1515
import type { TextBuffer } from './shared/text-buffer.js';
@@ -2900,6 +2900,93 @@ describe('InputPrompt', () => {
29002900
unmount();
29012901
});
29022902

2903+
it('should toggle paste expansion on double-click', async () => {
2904+
const id = '[Pasted Text: 10 lines]';
2905+
const largeText =
2906+
'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
2907+
2908+
const baseProps = props;
2909+
const TestWrapper = () => {
2910+
const [isExpanded, setIsExpanded] = useState(false);
2911+
const currentLines = isExpanded ? largeText.split('\n') : [id];
2912+
const currentText = isExpanded ? largeText : id;
2913+
2914+
const buffer = {
2915+
...baseProps.buffer,
2916+
text: currentText,
2917+
lines: currentLines,
2918+
viewportVisualLines: currentLines,
2919+
allVisualLines: currentLines,
2920+
pastedContent: { [id]: largeText },
2921+
transformationsByLine: isExpanded
2922+
? currentLines.map(() => [])
2923+
: [
2924+
[
2925+
{
2926+
logStart: 0,
2927+
logEnd: id.length,
2928+
logicalText: id,
2929+
collapsedText: id,
2930+
type: 'paste',
2931+
id,
2932+
},
2933+
],
2934+
],
2935+
visualScrollRow: 0,
2936+
visualToLogicalMap: currentLines.map(
2937+
(_, i) => [i, 0] as [number, number],
2938+
),
2939+
visualToTransformedMap: currentLines.map(() => 0),
2940+
getLogicalPositionFromVisual: vi.fn().mockReturnValue({
2941+
row: 0,
2942+
col: 2,
2943+
}),
2944+
togglePasteExpansion: vi.fn().mockImplementation(() => {
2945+
setIsExpanded(!isExpanded);
2946+
}),
2947+
getExpandedPasteAtLine: vi
2948+
.fn()
2949+
.mockReturnValue(isExpanded ? id : null),
2950+
};
2951+
2952+
return <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
2953+
};
2954+
2955+
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
2956+
<TestWrapper />,
2957+
{
2958+
mouseEventsEnabled: true,
2959+
useAlternateBuffer: true,
2960+
uiActions,
2961+
},
2962+
);
2963+
2964+
// 1. Verify initial placeholder
2965+
await waitFor(() => {
2966+
expect(stdout.lastFrame()).toMatchSnapshot();
2967+
});
2968+
2969+
// Simulate double-click to expand
2970+
await simulateClick(stdin, 5, 2);
2971+
await simulateClick(stdin, 5, 2);
2972+
2973+
// 2. Verify expanded content is visible
2974+
await waitFor(() => {
2975+
expect(stdout.lastFrame()).toMatchSnapshot();
2976+
});
2977+
2978+
// Simulate double-click to collapse
2979+
await simulateClick(stdin, 5, 2);
2980+
await simulateClick(stdin, 5, 2);
2981+
2982+
// 3. Verify placeholder is restored
2983+
await waitFor(() => {
2984+
expect(stdout.lastFrame()).toMatchSnapshot();
2985+
});
2986+
2987+
unmount();
2988+
});
2989+
29032990
it('should move cursor on mouse click with plain borders', async () => {
29042991
props.config.getUseBackgroundColor = () => false;
29052992
props.buffer.text = 'hello world';

packages/cli/src/ui/components/InputPrompt.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
1212
import { theme } from '../semantic-colors.js';
1313
import { useInputHistory } from '../hooks/useInputHistory.js';
1414
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
15-
import type { TextBuffer } from './shared/text-buffer.js';
1615
import {
16+
type TextBuffer,
1717
logicalPosToOffset,
1818
PASTED_TEXT_PLACEHOLDER_REGEX,
19+
getTransformUnderCursor,
1920
} from './shared/text-buffer.js';
2021
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
2122
import chalk from 'chalk';
@@ -56,8 +57,10 @@ import { useUIState } from '../contexts/UIStateContext.js';
5657
import { useSettings } from '../contexts/SettingsContext.js';
5758
import { StreamingState } from '../types.js';
5859
import { useMouseClick } from '../hooks/useMouseClick.js';
60+
import { useMouseDoubleClick } from '../hooks/useMouseDoubleClick.js';
5961
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
6062
import { useUIActions } from '../contexts/UIActionsContext.js';
63+
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
6164

6265
/**
6366
* Returns if the terminal can be trusted to handle paste events atomically
@@ -397,6 +400,40 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
397400
{ isActive: focus },
398401
);
399402

403+
const isAlternateBuffer = useAlternateBuffer();
404+
405+
// Double-click to expand/collapse paste placeholders
406+
useMouseDoubleClick(
407+
innerBoxRef,
408+
(_event, relX, relY) => {
409+
if (!isAlternateBuffer) return;
410+
411+
const logicalPos = buffer.getLogicalPositionFromVisual(
412+
buffer.visualScrollRow + relY,
413+
relX,
414+
);
415+
if (!logicalPos) return;
416+
417+
// Check for paste placeholder (collapsed state)
418+
const transform = getTransformUnderCursor(
419+
logicalPos.row,
420+
logicalPos.col,
421+
buffer.transformationsByLine,
422+
);
423+
if (transform?.type === 'paste' && transform.id) {
424+
buffer.togglePasteExpansion(transform.id);
425+
return;
426+
}
427+
428+
// Check for expanded paste region
429+
const expandedId = buffer.getExpandedPasteAtLine(logicalPos.row);
430+
if (expandedId) {
431+
buffer.togglePasteExpansion(expandedId);
432+
}
433+
},
434+
{ isActive: focus },
435+
);
436+
400437
useMouse(
401438
(event: MouseEvent) => {
402439
if (event.name === 'right-release') {

packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,33 @@ exports[`InputPrompt > image path transformation snapshots > should snapshot exp
4444
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
4545
`;
4646

47+
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = `
48+
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
49+
> [Pasted Text: 10 lines]
50+
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
51+
`;
52+
53+
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
54+
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
55+
> line1
56+
line2
57+
line3
58+
line4
59+
line5
60+
line6
61+
line7
62+
line8
63+
line9
64+
line10
65+
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
66+
`;
67+
68+
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = `
69+
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
70+
> [Pasted Text: 10 lines]
71+
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
72+
`;
73+
4774
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
4875
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
4976
> Type your message or @path/to/file

packages/cli/src/ui/components/shared/text-buffer.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const initialState: TextBufferState = {
5757
transformationsByLine: [[]],
5858
visualLayout: defaultVisualLayout,
5959
pastedContent: {},
60+
expandedPasteInfo: new Map(),
6061
};
6162

6263
/**
@@ -531,6 +532,150 @@ describe('textBufferReducer', () => {
531532
expect(state.cursorCol).toBe(5);
532533
});
533534
});
535+
536+
describe('toggle_paste_expansion action', () => {
537+
const placeholder = '[Pasted Text: 6 lines]';
538+
const content = 'line1\nline2\nline3\nline4\nline5\nline6';
539+
540+
it('should expand a placeholder correctly', () => {
541+
const stateWithPlaceholder = createStateWithTransformations({
542+
lines: ['prefix ' + placeholder + ' suffix'],
543+
cursorRow: 0,
544+
cursorCol: 0,
545+
pastedContent: { [placeholder]: content },
546+
});
547+
548+
const action: TextBufferAction = {
549+
type: 'toggle_paste_expansion',
550+
payload: { id: placeholder },
551+
};
552+
553+
const state = textBufferReducer(stateWithPlaceholder, action);
554+
555+
expect(state.lines).toEqual([
556+
'prefix line1',
557+
'line2',
558+
'line3',
559+
'line4',
560+
'line5',
561+
'line6 suffix',
562+
]);
563+
expect(state.expandedPasteInfo.has(placeholder)).toBe(true);
564+
const info = state.expandedPasteInfo.get(placeholder);
565+
expect(info).toEqual({
566+
startLine: 0,
567+
lineCount: 6,
568+
prefix: 'prefix ',
569+
suffix: ' suffix',
570+
});
571+
// Cursor should be at the end of expanded content (before suffix)
572+
expect(state.cursorRow).toBe(5);
573+
expect(state.cursorCol).toBe(5); // length of 'line6'
574+
});
575+
576+
it('should collapse an expanded placeholder correctly', () => {
577+
const expandedState = createStateWithTransformations({
578+
lines: [
579+
'prefix line1',
580+
'line2',
581+
'line3',
582+
'line4',
583+
'line5',
584+
'line6 suffix',
585+
],
586+
cursorRow: 5,
587+
cursorCol: 5,
588+
pastedContent: { [placeholder]: content },
589+
expandedPasteInfo: new Map([
590+
[
591+
placeholder,
592+
{
593+
startLine: 0,
594+
lineCount: 6,
595+
prefix: 'prefix ',
596+
suffix: ' suffix',
597+
},
598+
],
599+
]),
600+
});
601+
602+
const action: TextBufferAction = {
603+
type: 'toggle_paste_expansion',
604+
payload: { id: placeholder },
605+
};
606+
607+
const state = textBufferReducer(expandedState, action);
608+
609+
expect(state.lines).toEqual(['prefix ' + placeholder + ' suffix']);
610+
expect(state.expandedPasteInfo.has(placeholder)).toBe(false);
611+
// Cursor should be at the end of the collapsed placeholder
612+
expect(state.cursorRow).toBe(0);
613+
expect(state.cursorCol).toBe(('prefix ' + placeholder).length);
614+
});
615+
616+
it('should expand single-line content correctly', () => {
617+
const singleLinePlaceholder = '[Pasted Text: 10 chars]';
618+
const singleLineContent = 'some text';
619+
const stateWithPlaceholder = createStateWithTransformations({
620+
lines: [singleLinePlaceholder],
621+
cursorRow: 0,
622+
cursorCol: 0,
623+
pastedContent: { [singleLinePlaceholder]: singleLineContent },
624+
});
625+
626+
const state = textBufferReducer(stateWithPlaceholder, {
627+
type: 'toggle_paste_expansion',
628+
payload: { id: singleLinePlaceholder },
629+
});
630+
631+
expect(state.lines).toEqual(['some text']);
632+
expect(state.cursorRow).toBe(0);
633+
expect(state.cursorCol).toBe(9);
634+
});
635+
636+
it('should return current state if placeholder ID not found in pastedContent', () => {
637+
const action: TextBufferAction = {
638+
type: 'toggle_paste_expansion',
639+
payload: { id: 'unknown' },
640+
};
641+
const state = textBufferReducer(initialState, action);
642+
expect(state).toBe(initialState);
643+
});
644+
645+
it('should preserve expandedPasteInfo when lines change from edits outside the region', () => {
646+
// Start with an expanded paste at line 0 (3 lines long)
647+
const placeholder = '[Pasted Text: 3 lines]';
648+
const expandedState = createStateWithTransformations({
649+
lines: ['line1', 'line2', 'line3', 'suffix'],
650+
cursorRow: 3,
651+
cursorCol: 0,
652+
pastedContent: { [placeholder]: 'line1\nline2\nline3' },
653+
expandedPasteInfo: new Map([
654+
[
655+
placeholder,
656+
{
657+
startLine: 0,
658+
lineCount: 3,
659+
prefix: '',
660+
suffix: '',
661+
},
662+
],
663+
]),
664+
});
665+
666+
expect(expandedState.expandedPasteInfo.size).toBe(1);
667+
668+
// Insert a newline at the end - this changes lines but is OUTSIDE the expanded region
669+
const stateAfterInsert = textBufferReducer(expandedState, {
670+
type: 'insert',
671+
payload: '\n',
672+
});
673+
674+
// Lines changed, but expandedPasteInfo should be PRESERVED and optionally shifted (no shift here since edit is after)
675+
expect(stateAfterInsert.expandedPasteInfo.size).toBe(1);
676+
expect(stateAfterInsert.expandedPasteInfo.has(placeholder)).toBe(true);
677+
});
678+
});
534679
});
535680

536681
const getBufferState = (result: { current: TextBuffer }) => {

0 commit comments

Comments
 (0)