Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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.
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 @@ -11,10 +11,11 @@ import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.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 @@ -52,8 +53,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 @@ -391,6 +394,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
143 changes: 143 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,148 @@ 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 clear expandedPasteInfo when lines change from other edits', () => {
// Start with an expanded paste
const expandedState = createStateWithTransformations({
lines: ['prefix line1', 'line2', 'line3 suffix'],
cursorRow: 0,
cursorCol: 0,
pastedContent: { [placeholder]: 'line1\nline2\nline3' },
expandedPasteInfo: new Map([
[
placeholder,
{
startLine: 0,
lineCount: 3,
prefix: 'prefix ',
suffix: ' suffix',
},
],
]),
});

expect(expandedState.expandedPasteInfo.size).toBe(1);

// Insert a newline - this changes lines and should clear expandedPasteInfo
const stateAfterInsert = textBufferReducer(expandedState, {
type: 'insert',
payload: '\n',
});

// Lines changed, so expandedPasteInfo should be cleared
expect(stateAfterInsert.expandedPasteInfo.size).toBe(0);
});
});
});

const getBufferState = (result: { current: TextBuffer }) => {
Expand Down
Loading
Loading