Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f2bccf5
feat: add double-click expansion of large text placeholder
jackwotherspoon Jan 23, 2026
a5db67a
chore: cleanup
jackwotherspoon Jan 23, 2026
4a61dd5
chore: add tests
jackwotherspoon Jan 25, 2026
f1ebb17
chore: remove debug
jackwotherspoon Jan 25, 2026
2730586
chore: add docs note
jackwotherspoon Jan 25, 2026
a131e8a
chore: update license header year
jackwotherspoon Jan 25, 2026
4d9673e
Merge branch 'main' into large-paste-transform
jackwotherspoon Jan 25, 2026
9315811
chore: lint
jackwotherspoon Jan 25, 2026
eebafa3
chore: improve expansion logic
jackwotherspoon Jan 25, 2026
88ffd62
chore: remove debug log
jackwotherspoon Jan 25, 2026
fbbeb32
chore: review frontend changes
jackwotherspoon Jan 25, 2026
09e7722
chore: update test
jackwotherspoon Jan 25, 2026
9b0fa34
chore: review comments
jackwotherspoon Jan 25, 2026
5c5ea7d
Merge branch 'main' into large-paste-transform
jackwotherspoon Jan 25, 2026
a781fc4
chore: update snapshot
jackwotherspoon Jan 25, 2026
937d73a
chore: use simulated mouse click in test
jackwotherspoon Jan 25, 2026
9cb26e8
chore: fix
jackwotherspoon Jan 25, 2026
90b22f7
chore: improve double-click
jackwotherspoon Jan 25, 2026
2523f03
Merge branch 'main' into large-paste-transform
jackwotherspoon Jan 26, 2026
46170d8
Merge branch 'main' into large-paste-transform
jackwotherspoon Jan 26, 2026
b708d9f
Merge branch 'main' into large-paste-transform
jackwotherspoon Jan 26, 2026
76b5060
chore: add placeholders back after Ctrl+X
jackwotherspoon Jan 27, 2026
cf3a3ac
chore: update comment
jackwotherspoon Jan 27, 2026
77a5c42
chore: merge main
jackwotherspoon Jan 27, 2026
4830a73
chore: update snapshots
jackwotherspoon Jan 27, 2026
3816dd4
Merge branch 'main' into large-paste-transform
jackwotherspoon Jan 27, 2026
58e27b8
Merge branch 'main' into large-paste-transform
jackwotherspoon Jan 27, 2026
e1f5acc
chore: delete packages/cli/debug.log
jackwotherspoon Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/cli/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
89 changes: 88 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
39 changes: 38 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
145 changes: 145 additions & 0 deletions packages/cli/src/ui/components/shared/text-buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const initialState: TextBufferState = {
transformationsByLine: [[]],
visualLayout: defaultVisualLayout,
pastedContent: {},
expandedPasteInfo: new Map(),
};

/**
Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading
Loading