Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 31 additions & 6 deletions packages/cli/src/cli/input.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { EventEmitter } from 'node:events';
import { emitKeypressEvents } from 'node:readline';

import { createPasteStore, shouldStorePaste } from './paste-store.js';

const ESC = '\x1B[';
const HIDE_CURSOR = `${ESC}?25l`;
const SHOW_CURSOR = `${ESC}?25h`;
Expand Down Expand Up @@ -151,6 +153,10 @@ export function createTextarea(options: TextareaOptions): Textarea {
const wasRaw = stdin.isRaw ?? false;
const hasTTY = !!stdin.isTTY;

// Large/multi-line pastes are kept here and represented in the buffer by a
// compact placeholder token, expanded back to raw text at submit (#53).
const pasteStore = createPasteStore();

let buffer = '';
// Caret index into `buffer` (0..buffer.length). Insertion and deletion
// happen at this position; left/right/home/end move it, and up/down move
Expand Down Expand Up @@ -650,11 +656,15 @@ export function createTextarea(options: TextareaOptions): Textarea {
.onPaste()
.then((text) => {
if (text) {
// The buffer is a single logical line; flatten newlines so the
// wrapped-caret math stays correct.
const clean = text.replace(/\r?\n/g, ' ');
buffer = buffer.slice(0, cursorPos) + clean + buffer.slice(cursorPos);
cursorPos += clean.length;
// A large / multi-line paste is stored behind a compact placeholder
// token so the single-line buffer (and its wrapped-caret math) stays
// intact; it's expanded back to the raw text at submit. Small,
// single-line pastes inline as before (newlines flattened).
const insert = shouldStorePaste(text)
? pasteStore.register(text)
: text.replace(/\r?\n/g, ' ');
buffer = buffer.slice(0, cursorPos) + insert + buffer.slice(cursorPos);
cursorPos += insert.length;
selectedCompletionIdx = 0;
}
redraw();
Expand All @@ -680,14 +690,18 @@ export function createTextarea(options: TextareaOptions): Textarea {
// A slash panel: submit the selected command verbatim (so partial input
// like "/co" submits as "/compact"). Otherwise submit the buffer as-is.
const chosen = active ? active.matches[selectedCompletionIdx] : undefined;
const value = chosen ? chosen.text : buffer.trim();
// Expand any paste placeholders back to their original raw text (#53)
// before recording history / emitting. Completion text never contains a
// placeholder, so only the buffer path needs expansion.
const value = chosen ? chosen.text : pasteStore.expand(buffer.trim());
// Full erase/redraw: a single-row clear would leave stale wrapped
// input rows and any open completions panel on screen.
if (textareaDrawn) eraseTextarea();
buffer = '';
cursorPos = 0;
selectedCompletionIdx = 0;
panelDismissed = false;
pasteStore.clear();
recordHistory(value);
drawTextarea();
emitter.emit('submit', value);
Expand Down Expand Up @@ -758,6 +772,17 @@ export function createTextarea(options: TextareaOptions): Textarea {
}

if (key.name === 'backspace') {
// A paste placeholder is an atomic token: backspacing at its right edge
// removes the whole token (and frees its stored text), not one char (#53).
const token = pasteStore.tokenEndingAt(buffer, cursorPos);
if (token) {
buffer = buffer.slice(0, token.start) + buffer.slice(token.end);
cursorPos = token.start;
selectedCompletionIdx = 0;
resetHistoryBrowse();
redraw();
return;
}
if (cursorPos > 0) {
const hadSlash = buffer.includes('/');
// An '@' anywhere means the path panel may need to open/close/refresh.
Expand Down
128 changes: 128 additions & 0 deletions packages/cli/src/cli/paste-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Paste store for the interactive textarea (issue #53).
*
* The textarea buffer is a single logical line that soft-wraps. A large or
* multi-line paste inserted inline explodes into many terminal rows and breaks
* the input frame / caret math. Instead we keep the raw text here and insert a
* compact placeholder token (`[Pasted text #1 +12 lines]`) into the buffer,
* expanding it back to the original text only at submit time.
*
* The store is pure and side-effect free so it can be unit-tested directly; the
* textarea owns one instance and drives it from the Ctrl+V/backspace/submit
* paths.
*/

/** Default thresholds above which a paste is stored instead of inlined. */
export const DEFAULT_PASTE_CHAR_THRESHOLD = 200;

export interface PasteStoreOptions {
/** Inline pastes at or below this many chars (and single-line). */
charThreshold?: number;
}

export interface PasteToken {
/** Byte range of the token within the buffer. */
start: number;
end: number;
/** The placeholder text itself. */
token: string;
}

export interface PasteStore {
/** Store `text` and return the placeholder token to insert into the buffer. */
register(text: string): string;
/** Replace every known placeholder token in `value` with its raw text. */
expand(value: string): string;
/**
* If a placeholder token ends exactly at `pos` (the caret), return it so the
* caller can delete the whole token atomically (backspace). Undefined if the
* caret is not immediately after a token.
*/
tokenEndingAt(value: string, pos: number): PasteToken | undefined;
/** Drop all stored entries (e.g. after submit or clear). */
clear(): void;
/** Number of stored pastes. */
readonly size: number;
}

/**
* Whether a paste should be stored behind a placeholder rather than inlined.
* True for any multi-line paste (a newline always breaks the single-line
* buffer) or any paste longer than `charThreshold`.
*/
export function shouldStorePaste(
text: string,
options: PasteStoreOptions = {},
): boolean {
const threshold = options.charThreshold ?? DEFAULT_PASTE_CHAR_THRESHOLD;
return /\r?\n/.test(text) || text.length > threshold;
}

/** Count of lines in `text` (1 for a single line; trailing newline ignored). */
function countLines(text: string): number {
const normalized = text.replace(/\r\n/g, '\n').replace(/\n$/, '');
return normalized.split('\n').length;
}

/**
* A token is `[Pasted text #<id> …]`; the id is what we look up on expand. A
* fresh `RegExp` is built per use so the `g`-flag `lastIndex` is never shared
* between `expand` and `tokenEndingAt`.
*/
function tokenRegex(): RegExp {
return /\[Pasted text #(\d+)[^\]]*\]/g;
}

export function createPasteStore(): PasteStore {
const entries = new Map<number, string>();
let nextId = 1;

function placeholderFor(id: number, text: string): string {
if (/\r?\n/.test(text)) {
const lines = countLines(text);
return `[Pasted text #${id} +${lines} line${lines === 1 ? '' : 's'}]`;
}
return `[Pasted text #${id} ${text.length} chars]`;
}

return {
register(text: string): string {
const id = nextId++;
entries.set(id, text);
return placeholderFor(id, text);
},

expand(value: string): string {
return value.replace(tokenRegex(), (match, idStr: string) => {
const id = Number(idStr);
const raw = entries.get(id);
return raw !== undefined ? raw : match; // leave unknown tokens as typed
});
},

tokenEndingAt(value: string, pos: number): PasteToken | undefined {
const re = tokenRegex();
let m: RegExpExecArray | null;
while ((m = re.exec(value)) !== null) {
const start = m.index;
const end = m.index + m[0].length;
// Only treat as ours if the id is actually stored (avoid grabbing a
// user-typed look-alike).
if (end === pos && entries.has(Number(m[1]))) {
return { start, end, token: m[0] };
}
}
return undefined;
},

clear(): void {
entries.clear();
// Ids keep incrementing across clears so a stale token left in scrollback
// never collides with a fresh entry's id.
},

get size(): number {
return entries.size;
},
};
}
110 changes: 110 additions & 0 deletions packages/cli/tests/paste-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';

import {
createPasteStore,
DEFAULT_PASTE_CHAR_THRESHOLD,
shouldStorePaste,
} from '../src/cli/paste-store.js';

describe('shouldStorePaste', () => {
it('stores any multi-line paste (even short)', () => {
expect(shouldStorePaste('a\nb')).toBe(true);
expect(shouldStorePaste('a\r\nb')).toBe(true);
});

it('inlines short single-line pastes', () => {
expect(shouldStorePaste('hello world')).toBe(false);
expect(shouldStorePaste('x'.repeat(DEFAULT_PASTE_CHAR_THRESHOLD))).toBe(false);
});

it('stores a long single-line paste over the threshold', () => {
expect(shouldStorePaste('x'.repeat(DEFAULT_PASTE_CHAR_THRESHOLD + 1))).toBe(true);
});

it('honors a custom char threshold', () => {
expect(shouldStorePaste('hello', { charThreshold: 3 })).toBe(true);
expect(shouldStorePaste('hi', { charThreshold: 3 })).toBe(false);
});
});

describe('createPasteStore', () => {
it('registers text and returns a compact line-count placeholder', () => {
const store = createPasteStore();
const token = store.register('line1\nline2\nline3');
expect(token).toBe('[Pasted text #1 +3 lines]');
expect(store.size).toBe(1);
});

it('uses a char-count placeholder for a long single line', () => {
const store = createPasteStore();
const token = store.register('y'.repeat(500));
expect(token).toBe('[Pasted text #1 500 chars]');
});

it('singularizes a one-line placeholder', () => {
const store = createPasteStore();
expect(store.register('only one line, but long enough'.repeat(20))).toBe(
'[Pasted text #1 600 chars]',
);
// A trailing newline still counts as a single line.
expect(store.register('just one\n')).toBe('[Pasted text #2 +1 line]');
});

it('expands a placeholder back to the exact original text (newlines preserved)', () => {
const store = createPasteStore();
const raw = 'first line\nsecond line\nthird';
const token = store.register(raw);
expect(store.expand(`look at this: ${token} please`)).toBe(`look at this: ${raw} please`);
});

it('expands multiple placeholders, each to its own text', () => {
const store = createPasteStore();
const a = store.register('AAA\nAAA');
const b = store.register('z'.repeat(300));
expect(store.expand(`${a} and ${b}`)).toBe(`AAA\nAAA and ${'z'.repeat(300)}`);
});

it('leaves an unknown look-alike token untouched on expand', () => {
const store = createPasteStore();
expect(store.expand('a literal [Pasted text #999 +2 lines] I typed')).toBe(
'a literal [Pasted text #999 +2 lines] I typed',
);
});

it('detects a token ending exactly at the caret for atomic deletion', () => {
const store = createPasteStore();
const token = store.register('multi\nline');
const value = `hi ${token}`;
const found = store.tokenEndingAt(value, value.length);
expect(found).toBeDefined();
expect(found?.token).toBe(token);
expect(found?.start).toBe(3);
expect(found?.end).toBe(value.length);
});

it('does not report a token when the caret is elsewhere', () => {
const store = createPasteStore();
const token = store.register('multi\nline');
const value = `${token} trailing`;
expect(store.tokenEndingAt(value, value.length)).toBeUndefined(); // caret after " trailing"
expect(store.tokenEndingAt(value, token.length - 1)).toBeUndefined(); // mid-token
});

it('does not treat an unknown look-alike token as deletable', () => {
const store = createPasteStore();
const value = '[Pasted text #42 +1 line]';
expect(store.tokenEndingAt(value, value.length)).toBeUndefined();
});

it('clears entries but keeps ids monotonic (no stale-token collisions)', () => {
const store = createPasteStore();
const first = store.register('a\nb');
expect(first).toBe('[Pasted text #1 +2 lines]');
store.clear();
expect(store.size).toBe(0);
const second = store.register('c\nd');
expect(second).toBe('[Pasted text #2 +2 lines]'); // id advanced, not reused
// The cleared entry no longer expands.
expect(store.expand(first)).toBe(first);
});
});
Loading
Loading