Skip to content

Commit 733de65

Browse files
committed
feat(input): collapse pasted text into [Pasted #N · M lines] placeholders
Before this, pasting 200 lines of code shoved 200 rendered lines into the input box — the TUI got pushed off-screen and the user couldn't see what they were sending. Detect paste-shaped useInput ticks (any newline, or 100+ chars in one tick, since typed input doesn't arrive that way) and replace the content with a compact placeholder while stashing the original in a side map. Submit expands placeholders back to real text before the agent sees them.
1 parent 74fac19 commit 733de65

4 files changed

Lines changed: 188 additions & 4 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.37",
3+
"version": "2.0.0-pre.38",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/ui/Input.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { logInputEvent } from "./debug-input.js";
44
import {
55
backspace,
66
deleteForward,
7+
expandPastes,
78
initialInputState,
89
insertChar,
10+
insertPaste,
911
killToEnd,
1012
killToStart,
1113
killWordBack,
14+
looksLikePaste,
1215
moveEnd,
1316
moveLeft,
1417
moveRight,
@@ -287,7 +290,7 @@ export function Input({
287290
// registry's not-found path surface the typo.
288291
const trimmed = state.buffer.trim();
289292
if (trimmed.length > 0) {
290-
onSubmit(trimmed);
293+
onSubmit(expandPastes(trimmed, state.pastedContents));
291294
setState(initialInputState());
292295
setSuggestionIdx(0);
293296
setHistoryIdx(-1);
@@ -360,7 +363,7 @@ export function Input({
360363
setPathIdx(0);
361364
lastAtTokenRef.current = null;
362365
}
363-
setState(insertChar(state, input));
366+
setState(looksLikePaste(input) ? insertPaste(state, input) : insertChar(state, input));
364367
}
365368
});
366369

src/ui/input-state.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ import { describe, expect, it } from "vitest";
22
import {
33
backspace,
44
deleteForward,
5+
expandPastes,
6+
formatPastePlaceholder,
57
initialInputState,
68
insertChar,
9+
insertPaste,
710
killToEnd,
811
killToStart,
912
killWordBack,
1013
killWordForward,
14+
looksLikePaste,
1115
moveEnd,
1216
moveLeft,
1317
moveRight,
@@ -214,3 +218,101 @@ describe("undo", () => {
214218
expect(s.buffer).toBe("");
215219
});
216220
});
221+
222+
describe("paste detection (looksLikePaste)", () => {
223+
it("returns false for a single typed character", () => {
224+
expect(looksLikePaste("a")).toBe(false);
225+
});
226+
227+
it("returns false for a short typed run", () => {
228+
expect(looksLikePaste("hello world")).toBe(false);
229+
});
230+
231+
it("returns true for any input containing a newline", () => {
232+
// Typed \\<Enter> is handled by the key.return branch in Input.tsx,
233+
// not the printable-text path — so a \\n landing here is paste.
234+
expect(looksLikePaste("a\nb")).toBe(true);
235+
});
236+
237+
it("returns true for a long single-line paste with no newlines", () => {
238+
expect(looksLikePaste("x".repeat(120))).toBe(true);
239+
});
240+
241+
it("returns false for a single newline alone (not really a paste signal)", () => {
242+
// Edge case: a 1-char input with just \\n. Treated as paste under
243+
// our heuristic which is conservative — fine for safety, the
244+
// resulting placeholder just reads "0 lines" worth.
245+
expect(looksLikePaste("\n")).toBe(true);
246+
});
247+
});
248+
249+
describe("formatPastePlaceholder", () => {
250+
it("renders line count for multi-line content", () => {
251+
const text = "line1\nline2\nline3";
252+
expect(formatPastePlaceholder(1, text)).toBe("[Pasted #1 · 3 lines]");
253+
});
254+
255+
it("renders char count for single-line content", () => {
256+
const text = "x".repeat(250);
257+
expect(formatPastePlaceholder(7, text)).toBe("[Pasted #7 · 250 chars]");
258+
});
259+
260+
it("uses unique ids", () => {
261+
const a = formatPastePlaceholder(1, "x");
262+
const b = formatPastePlaceholder(2, "x");
263+
expect(a).not.toBe(b);
264+
});
265+
});
266+
267+
describe("insertPaste + expandPastes", () => {
268+
it("inserts a placeholder, stores content, and round-trips on expand", () => {
269+
const code = "function foo() {\n return 42;\n}";
270+
const s = insertPaste(initialInputState(), code);
271+
// Buffer contains only the placeholder, not the 32-char code body.
272+
expect(s.buffer).toBe("[Pasted #1 · 3 lines]");
273+
expect(s.pastedContents[1]?.content).toBe(code);
274+
expect(s.pastedContents[1]?.lines).toBe(3);
275+
expect(s.nextPasteId).toBe(2);
276+
// Submit path expands it back.
277+
expect(expandPastes(s.buffer, s.pastedContents)).toBe(code);
278+
});
279+
280+
it("supports text typed around a placeholder", () => {
281+
let s = initialInputState();
282+
for (const ch of "explain: ") s = insertChar(s, ch);
283+
s = insertPaste(s, "alpha\nbeta\ngamma");
284+
for (const ch of " thanks") s = insertChar(s, ch);
285+
expect(s.buffer).toBe("explain: [Pasted #1 · 3 lines] thanks");
286+
expect(expandPastes(s.buffer, s.pastedContents)).toBe("explain: alpha\nbeta\ngamma thanks");
287+
});
288+
289+
it("assigns unique ids when multiple pastes happen in one buffer", () => {
290+
let s = insertPaste(initialInputState(), "first paste");
291+
for (const ch of " · then ") s = insertChar(s, ch);
292+
s = insertPaste(s, "second\npaste\nhere");
293+
expect(s.pastedContents[1]?.content).toBe("first paste");
294+
expect(s.pastedContents[2]?.content).toBe("second\npaste\nhere");
295+
expect(expandPastes(s.buffer, s.pastedContents)).toBe("first paste · then second\npaste\nhere");
296+
});
297+
298+
it("leaves unrecognized placeholder-shaped text alone", () => {
299+
// User literally typed something matching the pattern. We have no
300+
// id for it; expand should leave it untouched rather than corrupt it.
301+
const text = "see also [Pasted #999 · 5 lines]";
302+
expect(expandPastes(text, {})).toBe(text);
303+
});
304+
305+
it("silently drops orphaned placeholders when content was edited away", () => {
306+
const s = insertPaste(initialInputState(), "to be deleted");
307+
// User selected and removed the placeholder somehow; buffer is empty
308+
// but pastedContents still has the entry. expandPastes on empty buffer
309+
// just yields empty.
310+
expect(expandPastes("", s.pastedContents)).toBe("");
311+
});
312+
313+
it("insertPaste with empty content is a no-op", () => {
314+
const before = initialInputState();
315+
const after = insertPaste(before, "");
316+
expect(after).toEqual(before);
317+
});
318+
});

src/ui/input-state.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,98 @@
1818

1919
export type Action = "type" | "kill" | "yank" | "delete" | "move" | "undo" | "init";
2020

21+
export interface PastedContent {
22+
id: number;
23+
content: string;
24+
lines: number;
25+
}
26+
2127
export interface InputState {
2228
buffer: string;
2329
cursor: number;
2430
killRing: string[];
2531
undoStack: Array<{ buffer: string; cursor: number }>;
2632
lastAction: Action;
33+
/** Map of paste id → original content for placeholder expansion at submit. */
34+
pastedContents: Record<number, PastedContent>;
35+
/** Monotonic counter; next paste in this buffer gets this id. */
36+
nextPasteId: number;
2737
}
2838

2939
const MAX_UNDO = 100;
3040
const MAX_KILL_RING = 60;
3141

3242
export function initialInputState(): InputState {
33-
return { buffer: "", cursor: 0, killRing: [], undoStack: [], lastAction: "init" };
43+
return {
44+
buffer: "",
45+
cursor: 0,
46+
killRing: [],
47+
undoStack: [],
48+
lastAction: "init",
49+
pastedContents: {},
50+
nextPasteId: 1,
51+
};
52+
}
53+
54+
/**
55+
* A useInput tick is a paste rather than a keystroke when it contains a
56+
* newline (typed `\<Enter>` is handled in the key.return branch, not as
57+
* printable text — so any `\n` here came from the OS paste buffer) or
58+
* when it's substantially longer than a normal keystroke / IME chunk.
59+
*/
60+
export function looksLikePaste(input: string): boolean {
61+
return input.includes("\n") || input.length >= 100;
62+
}
63+
64+
const PASTE_PLACEHOLDER_RE = /\[Pasted #(\d+) · \d+ (?:lines|chars)\]/g;
65+
66+
/**
67+
* Render the placeholder shown in the visible buffer when text is pasted.
68+
* Multi-line pastes use a line count; single-line long pastes show char
69+
* count — different signals for what the user's eye expects to track.
70+
*/
71+
export function formatPastePlaceholder(id: number, content: string): string {
72+
const lines = content.split("\n").length;
73+
if (lines > 1) return `[Pasted #${id} · ${lines} lines]`;
74+
return `[Pasted #${id} · ${content.length} chars]`;
75+
}
76+
77+
/**
78+
* Stash pasted content under a fresh id and insert a placeholder at the
79+
* cursor. The buffer stays short and readable; the real text re-inflates
80+
* at submit via expandPastes(). Pastes follow normal insertChar undo
81+
* snapshots — Ctrl-Z will pop the placeholder *and* the side entry stays
82+
* in pastedContents, harmless because nothing will reference it.
83+
*/
84+
export function insertPaste(state: InputState, content: string): InputState {
85+
if (!content) return state;
86+
const id = state.nextPasteId;
87+
const placeholder = formatPastePlaceholder(id, content);
88+
const inserted = insertChar(state, placeholder);
89+
return {
90+
...inserted,
91+
pastedContents: {
92+
...state.pastedContents,
93+
[id]: { id, content, lines: content.split("\n").length },
94+
},
95+
nextPasteId: id + 1,
96+
};
97+
}
98+
99+
/**
100+
* Replace `[Pasted #N · ...]` placeholders with their original content.
101+
* Called at submit time so the agent sees the real text. Orphaned ids
102+
* (placeholder deleted out of the buffer) are silently dropped — only
103+
* the ones still present in the buffer expand. Placeholders we don't
104+
* recognize (e.g. user typed something that matches the pattern) pass
105+
* through unchanged so we don't corrupt their literal input.
106+
*/
107+
export function expandPastes(buffer: string, pastedContents: Record<number, PastedContent>): string {
108+
return buffer.replace(PASTE_PLACEHOLDER_RE, (match, idStr) => {
109+
const id = Number.parseInt(idStr, 10);
110+
const entry = pastedContents[id];
111+
return entry ? entry.content : match;
112+
});
34113
}
35114

36115
export function setBuffer(state: InputState, buffer: string): InputState {

0 commit comments

Comments
 (0)