diff --git a/packages/cli/src/cli/input.ts b/packages/cli/src/cli/input.ts index 92b7116..dfc7501 100644 --- a/packages/cli/src/cli/input.ts +++ b/packages/cli/src/cli/input.ts @@ -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`; @@ -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 @@ -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(); @@ -680,7 +690,10 @@ 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(); @@ -688,6 +701,7 @@ export function createTextarea(options: TextareaOptions): Textarea { cursorPos = 0; selectedCompletionIdx = 0; panelDismissed = false; + pasteStore.clear(); recordHistory(value); drawTextarea(); emitter.emit('submit', value); @@ -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. diff --git a/packages/cli/src/cli/paste-store.ts b/packages/cli/src/cli/paste-store.ts new file mode 100644 index 0000000..4d6ab8d --- /dev/null +++ b/packages/cli/src/cli/paste-store.ts @@ -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 # …]`; 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(); + 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; + }, + }; +} diff --git a/packages/cli/tests/paste-store.test.ts b/packages/cli/tests/paste-store.test.ts new file mode 100644 index 0000000..6ccc9a8 --- /dev/null +++ b/packages/cli/tests/paste-store.test.ts @@ -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); + }); +}); diff --git a/packages/cli/tests/textarea-paste.test.ts b/packages/cli/tests/textarea-paste.test.ts index 4be309c..3259f11 100644 --- a/packages/cli/tests/textarea-paste.test.ts +++ b/packages/cli/tests/textarea-paste.test.ts @@ -63,7 +63,9 @@ describe('textarea Ctrl+V paste', () => { textarea.close(); }); - it('flattens newlines in pasted text (single-line buffer)', async () => { + it('preserves newlines in a multiline paste via the placeholder (#53)', async () => { + // Previously the Ctrl+V path flattened newlines to spaces; a multiline paste + // is now stored behind a placeholder and expanded verbatim at submit. const onPaste = vi.fn(async () => 'line1\nline2\r\nline3'); const onSubmit = vi.fn(); const textarea = createTextarea({ prompt: '> ', onPaste }); @@ -73,7 +75,7 @@ describe('textarea Ctrl+V paste', () => { await tick(); emitKey(stdin, 'return'); - expect(onSubmit).toHaveBeenCalledWith('line1 line2 line3'); + expect(onSubmit).toHaveBeenCalledWith('line1\nline2\r\nline3'); textarea.close(); }); @@ -92,3 +94,86 @@ describe('textarea Ctrl+V paste', () => { textarea.close(); }); }); + +describe('textarea large/multiline paste placeholder (issue #53)', () => { + let originalStdin: NodeJS.ReadStream; + let stdin: FakeStdin; + + beforeEach(() => { + originalStdin = process.stdin; + stdin = makeFakeStdin(); + Object.defineProperty(process, 'stdin', { value: stdin, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true }); + }); + + it('stores a multiline paste and expands it (newlines preserved) at submit', async () => { + const pasted = 'def main():\n print("hi")\n return 0'; + const onPaste = vi.fn(async () => pasted); + const onSubmit = vi.fn(); + const textarea = createTextarea({ prompt: '> ', onPaste }); + textarea.on('submit', onSubmit); + + emitKey(stdin, 'v', { ctrl: true }); + await tick(); + emitKey(stdin, 'return'); + + // Submit emits the ORIGINAL text (placeholder expanded, newlines intact) — + // not the flattened inline form a small paste would produce. + expect(onSubmit).toHaveBeenCalledWith(pasted); + textarea.close(); + }); + + it('expands a placeholder embedded among typed text', async () => { + const pasted = 'alpha\nbeta\ngamma'; + const onPaste = vi.fn(async () => pasted); + const onSubmit = vi.fn(); + const textarea = createTextarea({ prompt: '> ', onPaste }); + textarea.on('submit', onSubmit); + + // Type "see ", then paste, then type " ok". + for (const ch of 'see ') emitKey(stdin, ch, { sequence: ch }); + emitKey(stdin, 'v', { ctrl: true }); + await tick(); + for (const ch of ' ok') emitKey(stdin, ch, { sequence: ch }); + emitKey(stdin, 'return'); + + expect(onSubmit).toHaveBeenCalledWith(`see ${pasted} ok`); + textarea.close(); + }); + + it('deletes the whole placeholder token with a single backspace (atomic)', async () => { + const onPaste = vi.fn(async () => 'a\nb\nc\nd\ne'); + const onSubmit = vi.fn(); + const textarea = createTextarea({ prompt: '> ', onPaste }); + textarea.on('submit', onSubmit); + + emitKey(stdin, 'v', { ctrl: true }); + await tick(); + // One backspace removes the entire placeholder (proving it's a single token, + // not the ~9 raw characters inlined). + emitKey(stdin, 'backspace'); + emitKey(stdin, 'return'); + + expect(onSubmit).toHaveBeenCalledWith(''); + textarea.close(); + }); + + it('still inlines a short single-line paste (flattened), unchanged', async () => { + const onPaste = vi.fn(async () => 'short paste'); + const onSubmit = vi.fn(); + const textarea = createTextarea({ prompt: '> ', onPaste }); + textarea.on('submit', onSubmit); + + emitKey(stdin, 'v', { ctrl: true }); + await tick(); + // A single backspace removes just one char (it was inlined, not a token). + emitKey(stdin, 'backspace'); + emitKey(stdin, 'return'); + + expect(onSubmit).toHaveBeenCalledWith('short past'); + textarea.close(); + }); +});