feat(coder): handle large text paste with compact placeholder references

989df27770bf · AtlantisPleb · · parent cd28d0ff688e

feat(coder): handle large text paste with compact placeholder references

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added packages/openagents-cli/src/coder-paste.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-paste.test.ts

Diff

3 files changed, +171 -10

packages/openagents-cli/src/coder-paste.ts added +85

@@ -0,0 +1,85 @@

1
/**
2
 * Types and utilities for compact paste placeholders in the coder composer.
3
 */
4
5
export const PASTE_TEXT_THRESHOLD = 800;
6
export const PASTE_MAX_LINES = 1;
7
8
export interface PastedTextContent {
9
  readonly id: number;
10
  readonly content: string;
11
}
12
13
/**
14
 * Return line count difference or line count for reference tokens.
15
 * "line1\nline2" has 1 additional line (+1 line).
16
 */
17
export function getPastedTextRefNumLines(text: string): number {
18
  return (text.match(/\r\n|\r|\n/g) ?? []).length;
19
}
20
21
/**
22
 * Format reference token: `[Pasted text #1]` or `[Pasted text #1 +10 lines]`.
23
 */
24
export function formatPastedTextRef(id: number, numLines: number): string {
25
  if (numLines <= 0) {
26
    return `[Pasted text #${id}]`;
27
  }
28
  return `[Pasted text #${id} +${numLines} ${numLines === 1 ? "line" : "lines"}]`;
29
}
30
31
/**
32
 * Regex matching reference tokens: `[Pasted text #1]`, `[Pasted text #1 +1 line]`, `[Pasted text #1 +10 lines]`.
33
 */
34
export const PASTED_TEXT_REF_REGEX = /\[Pasted text #(\d+)(?: \+\d+ lines?)?\]/g;
35
36
/**
37
 * Replace all `[Pasted text #N]` references with their actual content.
38
 */
39
export function expandPastedTextRefs(
40
  input: string,
41
  pastedContents: ReadonlyMap<number, PastedTextContent> | Record<number, PastedTextContent>,
42
): string {
43
  const isMap = pastedContents instanceof Map;
44
  const getEntry = (id: number): PastedTextContent | undefined => {
45
    if (isMap) {
46
      return (pastedContents as ReadonlyMap<number, PastedTextContent>).get(id);
47
    }
48
    return (pastedContents as Record<number, PastedTextContent>)[id];
49
  };
50
51
  const matches = [...input.matchAll(PASTED_TEXT_REF_REGEX)];
52
  let expanded = input;
53
  for (let i = matches.length - 1; i >= 0; i--) {
54
    const match = matches[i];
55
    if (match === undefined || match.index === undefined) continue;
56
    const id = parseInt(match[1] ?? "0", 10);
57
    const entry = getEntry(id);
58
    if (entry !== undefined) {
59
      expanded =
60
        expanded.slice(0, match.index) +
61
        entry.content +
62
        expanded.slice(match.index + match[0].length);
63
    }
64
  }
65
  return expanded;
66
}
67
68
/**
69
 * Check if a pasted string qualifies for compact placeholder representation.
70
 */
71
export function shouldCollapsePaste(text: string, maxLines = PASTE_MAX_LINES): boolean {
72
  const numLines = getPastedTextRefNumLines(text);
73
  return text.length > PASTE_TEXT_THRESHOLD || numLines > maxLines;
74
}
75
76
/**
77
 * Remove trailing reference token if the backspace lands on it.
78
 */
79
export function backspaceComposer(composer: string): string {
80
  const trailingToken = /\[Pasted text #\d+(?: \+\d+ lines?)?\]$/.exec(composer);
81
  if (trailingToken !== null) {
82
    return composer.slice(0, -trailingToken[0].length);
83
  }
84
  return composer.slice(0, -1);
85
}
packages/openagents-cli/src/coder-ui.ts modified +27 -4

@@ -43,6 +43,14 @@ import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./c

43 43
import type { CoderTask, CoderTaskStatus } from "./coder-tasks.js";
44 44
import { coderTierLabel } from "./coder-tiers.js";
45 45
import { RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
46
import {
47
  backspaceComposer,
48
  expandPastedTextRefs,
49
  formatPastedTextRef,
50
  getPastedTextRefNumLines,
51
  type PastedTextContent,
52
  shouldCollapsePaste,
53
} from "./coder-paste.js";
46 54
import type { SkillSelection } from "./coder-skills.js";
47 55
48 56
const ALT_SCREEN_ON = "\x1b[?1049h";

@@ -407,6 +415,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

407 415
  let pendingEscape = "";
408 416
  /** A paste whose end has not arrived yet. */
409 417
  let pendingPaste = "";
418
  const pastedContents = new Map<number, PastedTextContent>();
419
  let nextPasteId = 1;
410 420
  let escapeTimer: NodeJS.Timeout | undefined;
411 421
412 422
  const write = (text: string) => {

@@ -1055,8 +1065,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1055 1065
    };
1056 1066
1057 1067
    const submit = (mode: "steer" | "queue" = "steer") => {
1058
      const prompt = composer;
1068
      const prompt = expandPastedTextRefs(composer, pastedContents);
1059 1069
      composer = "";
1070
      pastedContents.clear();
1060 1071
      anchor = undefined;
1061 1072
      // Only when a turn actually begins. Resetting on every submission made
1062 1073
      // `/export` mid-turn put the elapsed clock back to zero, which reads as

@@ -1180,7 +1191,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1180 1191
      // reader looking at a settled reply with no way to tell whether the key
1181 1192
      // did anything, which reads as the key not working.
1182 1193
      if (session.interrupt()) session.notice("Interrupted.");
1183
      else composer = "";
1194
      else {
1195
        composer = "";
1196
        pastedContents.clear();
1197
      }
1184 1198
      render();
1185 1199
    };
1186 1200

@@ -1226,7 +1240,15 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1226 1240
            pendingPaste = text.slice(index);
1227 1241
            break;
1228 1242
          }
1229
          composer += text.slice(from, to);
1243
          const pastedText = text.slice(from, to);
1244
          if (shouldCollapsePaste(pastedText)) {
1245
            const pasteId = nextPasteId++;
1246
            pastedContents.set(pasteId, { id: pasteId, content: pastedText });
1247
            const numLines = getPastedTextRefNumLines(pastedText);
1248
            composer += formatPastedTextRef(pasteId, numLines);
1249
          } else {
1250
            composer += pastedText;
1251
          }
1230 1252
          index = to + PASTE_END.length;
1231 1253
          dirty = true;
1232 1254
          continue;

@@ -1506,7 +1528,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1506 1528
        }
1507 1529
1508 1530
        if (char === "\x7f" || char === "\b") {
1509
          composer = composer.slice(0, -1);
1531
          composer = backspaceComposer(composer);
1510 1532
          dirty = true;
1511 1533
          index += 1;
1512 1534
          continue;

@@ -1514,6 +1536,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1514 1536
1515 1537
        if (char === "\x15") {
1516 1538
          composer = "";
1539
          pastedContents.clear();
1517 1540
          dirty = true;
1518 1541
          index += 1;
1519 1542
          continue;
packages/openagents-cli/test/coder-paste.test.ts modified +59 -6

@@ -1,5 +1,14 @@

1 1
import { EventEmitter } from "node:events";
2 2
import { describe, expect, it } from "vitest";
3
import {
4
  backspaceComposer,
5
  expandPastedTextRefs,
6
  formatPastedTextRef,
7
  getPastedTextRefNumLines,
8
  PASTE_MAX_LINES,
9
  PASTE_TEXT_THRESHOLD,
10
  shouldCollapsePaste,
11
} from "../src/coder-paste.js";
3 12
import { CoderSession, type ReplySource } from "../src/coder-session.js";
4 13
import { runCoderUi } from "../src/coder-ui.js";
5 14

@@ -28,8 +37,53 @@ class FakeIn extends EventEmitter {

28 37
  }
29 38
}
30 39
31
describe("pasting", () => {
32
  it("keeps a multi-line paste as one message", async () => {
40
describe("coder-paste utilities", () => {
41
  it("counts additional lines accurately", () => {
42
    expect(getPastedTextRefNumLines("one line")).toBe(0);
43
    expect(getPastedTextRefNumLines("line 1\nline 2")).toBe(1);
44
    expect(getPastedTextRefNumLines("line 1\r\nline 2\r\nline 3")).toBe(2);
45
  });
46
47
  it("formats reference placeholders", () => {
48
    expect(formatPastedTextRef(1, 0)).toBe("[Pasted text #1]");
49
    expect(formatPastedTextRef(2, 1)).toBe("[Pasted text #2 +1 line]");
50
    expect(formatPastedTextRef(3, 15)).toBe("[Pasted text #3 +15 lines]");
51
  });
52
53
  it("identifies when paste should collapse", () => {
54
    expect(shouldCollapsePaste("short text")).toBe(false);
55
    expect(shouldCollapsePaste("line 1\nline 2\nline 3")).toBe(true);
56
    expect(shouldCollapsePaste("a".repeat(PASTE_TEXT_THRESHOLD + 1))).toBe(true);
57
  });
58
59
  it("expands single and multiple pasted text references", () => {
60
    const pasted = new Map([
61
      [1, { id: 1, content: "full contents of first paste\nwith lines" }],
62
      [2, { id: 2, content: "second paste content" }],
63
    ]);
64
65
    const input = "Please inspect [Pasted text #1 +1 line] and also [Pasted text #2] carefully.";
66
    const expanded = expandPastedTextRefs(input, pasted);
67
68
    expect(expanded).toBe(
69
      "Please inspect full contents of first paste\nwith lines and also second paste content carefully.",
70
    );
71
  });
72
73
  it("deletes placeholder token atomically on backspace", () => {
74
    const composerWithToken = "prefix [Pasted text #1 +5 lines]";
75
    expect(backspaceComposer(composerWithToken)).toBe("prefix ");
76
77
    const composerWithSingleLineToken = "prefix [Pasted text #2]";
78
    expect(backspaceComposer(composerWithSingleLineToken)).toBe("prefix ");
79
80
    const normalComposer = "normal text";
81
    expect(backspaceComposer(normalComposer)).toBe("normal tex");
82
  });
83
});
84
85
describe("pasting in coder UI", () => {
86
  it("keeps a multi-line paste as a compact placeholder in UI and expands on submit", async () => {
33 87
    const sent: string[] = [];
34 88
    const src: ReplySource = {
35 89
      model: "m",

@@ -79,7 +133,7 @@ describe("pasting", () => {

79 133
    await running;
80 134
  });
81 135
82
  it("shows a paste as a blob rather than as its last line", async () => {
136
  it("shows large paste as a placeholder token in the composer", async () => {
83 137
    const src: ReplySource = {
84 138
      model: "m",
85 139
      async *reply() {

@@ -94,10 +148,9 @@ describe("pasting", () => {

94 148
    const ESC = String.fromCharCode(27);
95 149
    stdin.emit("data", `${ESC}[200~first line\nsecond\nthird${ESC}[201~`);
96 150
97
    // The reader needs to know how much is there and that it goes as one
98
    // message, not to read all of it in a one-row composer.
151
    // The reader sees a compact placeholder token
99 152
    const painted = stdout.written.split(new RegExp(`${ESC}\\[[0-9;]*m`)).join("");
100
    expect(painted).toContain("first line [+2 more lines]");
153
    expect(painted).toContain("[Pasted text #1 +2 lines]");
101 154
    expect(painted).not.toContain("second");
102 155
103 156
    // Ctrl+D quits only an empty composer, which is why escape comes first.

This page updates live while a promote is in flight · changelog