Take a paste as one message, and quieten the transcript

022ebbd9330a · AtlantisPleb · · parent 43159068b9d9

Take a paste as one message, and quieten the transcript

A twelve-line paste became twelve messages. Nothing marked where it began or
ended, so every newline in it was an enter, and each line steered the turn the
line before it had started — the transcript in the report shows `you`, `note:
Steering…`, `you`, `note: Steering…` down the screen.

The terminal can say. Bracketed paste is on now, so a paste arrives wrapped and
its newlines are text; a paste split across two reads is held until its end
arrives, because half a paste submitted as a message is the bug this exists to
stop. The composer shows the blob as a blob — `first line [+2 more lines]` —
since what a reader needs there is how much is waiting and that it goes as one
message, not to read it in a one-row field.

Then four things asked for in the interface:

The role column is gone. It named every entry down the left — `you`, `think`,
`coder`, `note`, `tool` — which is a word of chrome per turn saying what the
colour already said. In its place is the one thing colour cannot say: a solid
dot for an entry that is finished, a pulsing one for a reply still arriving.
The trailing caret goes with it, having become a second answer to the same
question. The ticker moves twice a second now, because a dot that blinks once a
second reads as a dot that is broken.

`scrolled` was the one yellow word in a dim row, which reads as a warning about
something being wrong rather than a note about where you are. It matches the row
now, and the `pgup/pgdn to scroll` hint is gone.

507 tests pass.

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

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-paste.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

5 files changed, +244 -22

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2444,
7
    "filesScanned": 2445,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:a813bcd9bb356638c4366fc0cf61cd2444992879b1371490642b0b562ef1bb01",
4
  "sourceDigest": "sha256:093efa8ee710da1f837de6a42b2dbe0b02438975c432ec7df9f71da5ba6c4cdc",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (44 tracked test files)"
1879
          "ref": "packages/openagents-cli (45 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/coder-ui.ts modified +90 -19

@@ -55,9 +55,23 @@ const ERASE_LINE = "\x1b[K";

55 55
 * already ambiguous change shape, and a terminal that does not implement this
56 56
 * ignores it, which leaves enter doing the default and costs nothing.
57 57
 */
58
const PASTE_START = "\x1b[200~";
59
const PASTE_END = "\x1b[201~";
60
58 61
const KEYS_DISAMBIGUATE_ON = "\x1b[>1u";
59 62
const KEYS_DISAMBIGUATE_OFF = "\x1b[<u";
60 63
64
/**
65
 * Ask the terminal to bracket pasted text.
66
 *
67
 * Without it a paste is indistinguishable from typing, so every newline in it
68
 * is an enter: a twelve-line paste became twelve messages, each steering the
69
 * turn the one before it started. With it the terminal wraps the blob in
70
 * `\x1b[200~` and `\x1b[201~`, and the newlines inside are text.
71
 */
72
const BRACKETED_PASTE_ON = "\x1b[?2004h";
73
const BRACKETED_PASTE_OFF = "\x1b[?2004l";
74
61 75
const ALT_SCROLL_ON = "\x1b[?1007h";
62 76
const ALT_SCROLL_OFF = "\x1b[?1007l";
63 77

@@ -90,7 +104,16 @@ const SPACER_ROWS = 1;

90 104
 */
91 105
const FLEET_ROWS_MAX = 8;
92 106
/** Width of the role gutter, so every entry's text starts in one column. */
93
const GUTTER = 9;
107
/**
108
 * Width of the marker column.
109
 *
110
 * The transcript used to name every entry down the left — `you`, `think`,
111
 * `coder`, `note`, `tool` — which is five words of chrome per turn saying what
112
 * the styling already said. What is worth a column is the one thing styling
113
 * cannot say: whether a reply is finished. So the column is a dot, and it is
114
 * two glyphs wide plus a space.
115
 */
116
const GUTTER = 4;
94 117
/**
95 118
 * How long a lone escape byte waits for the rest of a sequence.
96 119
 *

@@ -309,6 +332,13 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

309 332
  let exitCode = 0;
310 333
  let closed = false;
311 334
  let runningSince = Date.now();
335
  /**
336
   * Which half of a second the pulse is in.
337
   *
338
   * Advanced by the same ticker that moves the elapsed clock, so an unfinished
339
   * reply blinks without a timer of its own.
340
   */
341
  let pulse = true;
312 342
  /** Redraws the status line once a second so the elapsed time advances. */
313 343
  let ticker: NodeJS.Timeout | undefined;
314 344
  /** Rows as last painted, so only what changed is written. */

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

318 348
  let viewport = 1;
319 349
  /** Bytes held back because they may be the start of an escape sequence. */
320 350
  let pendingEscape = "";
351
  /** A paste whose end has not arrived yet. */
352
  let pendingPaste = "";
321 353
  let escapeTimer: NodeJS.Timeout | undefined;
322 354
323 355
  const write = (text: string) => {

@@ -347,7 +379,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

347 379
      stdout.off("resize", onResize);
348 380
      if (stdin.isTTY) stdin.setRawMode(false);
349 381
      stdin.pause();
350
      write(CURSOR_SHOW + KEYS_DISAMBIGUATE_OFF + ALT_SCROLL_OFF + ALT_SCREEN_OFF);
382
      write(
383
        CURSOR_SHOW + BRACKETED_PASTE_OFF + KEYS_DISAMBIGUATE_OFF + ALT_SCROLL_OFF + ALT_SCREEN_OFF,
384
      );
351 385
      resolve(exitCode);
352 386
    };
353 387

@@ -366,21 +400,25 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

366 400
    };
367 401
368 402
    const renderEntry = (entry: CoderEntry, width: number): ReadonlyArray<string> => {
369
      const [label, color] =
403
      const color =
370 404
        entry.role === "you"
371
          ? ["you", CYAN]
405
          ? CYAN
372 406
          : entry.role === "assistant"
373
            ? ["coder", GREEN]
407
            ? GREEN
374 408
            : entry.role === "tool"
375
              ? ["tool", MAGENTA]
409
              ? MAGENTA
376 410
              : entry.role === "reasoning"
377
                ? ["think", DIM]
378
                : ["note", YELLOW];
379
380
      const head = `  ${color}${BOLD}${label}${RESET}${" ".repeat(GUTTER - 2 - label.length)}`;
411
                ? DIM
412
                : YELLOW;
413
414
      // A reply still arriving pulses; a finished one is solid. The dot is the
415
      // only thing in this column because it is the only thing the colour and
416
      // the styling do not already say.
417
      const glyph = entry.settled ? "●" : pulse ? "●" : "○";
418
      const head = `  ${color}${glyph}${RESET} `;
381 419
      const continuation = " ".repeat(GUTTER);
382 420
      const rows = entryRows(entry, width);
383
      const caret = entry.settled ? "" : `${DIM}▌${RESET}`;
421
      const caret = "";
384 422
385 423
      return rows.map((row, index) => {
386 424
        const tail = index === rows.length - 1 ? caret : "";

@@ -629,11 +667,21 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

629 667
      // pushes the rule and the hints down a line and leaves the text they used
630 668
      // to occupy on screen with nothing to erase it.
631 669
      const composerRoom = Math.max(4, width - 4);
632
      const typed = [...composer];
670
      // A pasted blob is shown as what it is rather than as its last row. The
671
      // composer holds every line; the reader needs to know how many there are
672
      // and that they will go as one message, not to read them here.
673
      const pasted = composer.split("\n");
674
      const summarised =
675
        pasted.length > 1
676
          ? `${pasted[0] ?? ""} ${DIM}[+${String(pasted.length - 1)} more ${
677
              pasted.length === 2 ? "line" : "lines"
678
            }]${RESET}`
679
          : composer;
680
      const typed = [...summarised];
633 681
      const visible =
634
        typed.length > composerRoom
682
        visibleWidth(summarised) > composerRoom
635 683
          ? `…${typed.slice(typed.length - composerRoom + 1).join("")}`
636
          : composer;
684
          : summarised;
637 685
      rows.push(`  › ${visible}`);
638 686
      rows.push(rule);
639 687
      // Under the composer's own region, not inside it. Between the rules is

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

669 717
      if (session.canCycleReasoning && !snapshot.running) {
670 718
        keys.push({ text: "shift+tab to change thinking" });
671 719
      }
672
      if (lines.length > transcriptRows) keys.push({ text: "pgup/pgdn to scroll" });
720
673 721
      if (focusedTool(snapshot) !== undefined) keys.push({ text: "ctrl+o to expand" });
674 722
675 723
      // `this run` is not decoration. The count is this process's, and a

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

679 727
      const replies = `${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"} this run`;
680 728
      const counter =
681 729
        anchor !== undefined
682
          ? `${YELLOW}scrolled${RESET}${DIM} · ↑${above} · ↓${below}${RESET}`
730
          ? `${DIM}scrolled · ↑${above} · ↓${below}${RESET}`
683 731
          : above > 0
684 732
            ? `${DIM}↑${above} above · ${replies}${RESET}`
685 733
            : `${DIM}${replies}${RESET}`;

@@ -732,10 +780,14 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

732 780
733 781
      // The elapsed time has to advance between chunks, not only when one
734 782
      // arrives, or a slow reply looks stalled.
783
      // Twice a second, because the same tick drives the pulse on an unfinished
784
      // reply as well as the elapsed clock, and a dot that blinks once a second
785
      // reads as a dot that is broken.
735 786
      ticker ??= setInterval(() => {
736 787
        session.pruneTasks();
788
        pulse = !pulse;
737 789
        if (session.running || session.snapshot().tasks.length > 0) render();
738
      }, 1000);
790
      }, 500);
739 791
740 792
      // A delegate line is not a turn: it returns as soon as the children are
741 793
      // submitted and each one reports later, so nothing here waits on it and

@@ -845,7 +897,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

845 897
        clearTimeout(escapeTimer);
846 898
        escapeTimer = undefined;
847 899
      }
848
      let text = pendingEscape + (typeof chunk === "string" ? chunk : chunk.toString("utf8"));
900
      let text =
901
        pendingPaste + pendingEscape + (typeof chunk === "string" ? chunk : chunk.toString("utf8"));
902
      pendingPaste = "";
849 903
      pendingEscape = "";
850 904
      let index = 0;
851 905
      let dirty = false;

@@ -853,6 +907,23 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

853 907
      while (index < text.length) {
854 908
        const char = text[index] ?? "";
855 909
910
        // A paste is taken whole, before anything in here reads one of its
911
        // newlines as an enter. The terminal says where it ends; if the end has
912
        // not arrived yet the rest is held for the next chunk, because half a
913
        // paste submitted as a message is the bug this exists to stop.
914
        if (text.startsWith(PASTE_START, index)) {
915
          const from = index + PASTE_START.length;
916
          const to = text.indexOf(PASTE_END, from);
917
          if (to < 0) {
918
            pendingPaste = text.slice(index);
919
            break;
920
          }
921
          composer += text.slice(from, to);
922
          index = to + PASTE_END.length;
923
          dirty = true;
924
          continue;
925
        }
926
856 927
        // The skills screen takes the keyboard while it is up. Only the keys it
857 928
        // names do anything: a stray letter must not fall through into the
858 929
        // composer of a screen the reader cannot see.

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

1073 1144
1074 1145
    const unsubscribe = session.onChange(render);
1075 1146
1076
    write(ALT_SCREEN_ON + ALT_SCROLL_ON + KEYS_DISAMBIGUATE_ON);
1147
    write(ALT_SCREEN_ON + ALT_SCROLL_ON + KEYS_DISAMBIGUATE_ON + BRACKETED_PASTE_ON);
1077 1148
    if (stdin.isTTY) stdin.setRawMode(true);
1078 1149
    stdin.resume();
1079 1150
    stdin.setEncoding("utf8");
packages/openagents-cli/test/coder-paste.test.ts added +87

@@ -0,0 +1,87 @@

1
import { EventEmitter } from "node:events";
2
import { describe, expect, it } from "vitest";
3
import { CoderSession, type ReplySource } from "../src/coder-session.js";
4
import { runCoderUi } from "../src/coder-ui.js";
5
6
class FakeOut extends EventEmitter {
7
  columns = 100; rows = 24; written = "";
8
  write(t: string) { this.written += t; return true; }
9
}
10
class FakeIn extends EventEmitter {
11
  isTTY = true;
12
  setRawMode(){return this;} resume(){return this;} pause(){return this;} setEncoding(){return this;}
13
}
14
15
describe("pasting", () => {
16
  it("keeps a multi-line paste as one message", async () => {
17
    const sent: string[] = [];
18
    const src: ReplySource = { model: "m", async *reply(p: string) { sent.push(p); yield { type: "text", value: "ok" } as const; } };
19
    const stdin = new FakeIn(); const stdout = new FakeOut();
20
    const session = new CoderSession(src, "repo", "main");
21
    const running = runCoderUi(session, { stdin: stdin as never, stdout: stdout as never });
22
23
    const ESC = String.fromCharCode(27);
24
    stdin.emit("data", `${ESC}[200~line one\nline two\nline three${ESC}[201~`);
25
    expect(sent).toEqual([]);
26
    stdin.emit("data", "\r");
27
    await new Promise((r) => setTimeout(r, 20));
28
29
    expect(sent).toEqual(["line one\nline two\nline three"]);
30
    stdin.emit("data", "\x04"); await running;
31
  });
32
33
  it("holds a paste whose end has not arrived", async () => {
34
    const sent: string[] = [];
35
    const src: ReplySource = { model: "m", async *reply(p: string) { sent.push(p); yield { type: "text", value: "ok" } as const; } };
36
    const stdin = new FakeIn(); const stdout = new FakeOut();
37
    const session = new CoderSession(src, "repo", "main");
38
    const running = runCoderUi(session, { stdin: stdin as never, stdout: stdout as never });
39
40
    const ESC = String.fromCharCode(27);
41
    stdin.emit("data", `${ESC}[200~first\nsec`);
42
    stdin.emit("data", `ond${ESC}[201~`);
43
    stdin.emit("data", "\r");
44
    await new Promise((r) => setTimeout(r, 20));
45
46
    expect(sent).toEqual(["first\nsecond"]);
47
    stdin.emit("data", "\x04"); await running;
48
  });
49
50
  it("shows a paste as a blob rather than as its last line", async () => {
51
    const src: ReplySource = { model: "m", async *reply() { yield { type: "text", value: "ok" } as const; } };
52
    const stdin = new FakeIn(); const stdout = new FakeOut();
53
    const session = new CoderSession(src, "repo", "main");
54
    const running = runCoderUi(session, { stdin: stdin as never, stdout: stdout as never });
55
56
    const ESC = String.fromCharCode(27);
57
    stdin.emit("data", `${ESC}[200~first line\nsecond\nthird${ESC}[201~`);
58
59
    // The reader needs to know how much is there and that it goes as one
60
    // message, not to read all of it in a one-row composer.
61
    const painted = stdout.written.split(new RegExp(`${ESC}\\[[0-9;]*m`)).join("");
62
    expect(painted).toContain("first line [+2 more lines]");
63
    expect(painted).not.toContain("second");
64
65
    // Ctrl+D quits only an empty composer, which is why escape comes first.
66
    stdin.emit("data", ESC);
67
    await new Promise((r) => setTimeout(r, 60));
68
    stdin.emit("data", "\x04"); await running;
69
  });
70
71
  it("asks the terminal to bracket pastes, and stops asking on the way out", async () => {
72
    const src: ReplySource = { model: "m", async *reply() { yield { type: "text", value: "ok" } as const; } };
73
    const stdin = new FakeIn(); const stdout = new FakeOut();
74
    const session = new CoderSession(src, "repo", "main");
75
    const running = runCoderUi(session, { stdin: stdin as never, stdout: stdout as never });
76
77
    const ESC = String.fromCharCode(27);
78
    // Without this a paste is indistinguishable from typing, and every newline
79
    // in it is an enter.
80
    expect(stdout.written).toContain(`${ESC}[?2004h`);
81
82
    stdin.emit("data", "\x04"); await running;
83
84
    expect(stdout.written).toContain(`${ESC}[?2004l`);
85
  });
86
87
});
packages/openagents-cli/test/coder-ui.test.ts modified +64

@@ -901,3 +901,67 @@ describe("quitting with the keyboard protocol on", () => {

901 901
    await running;
902 902
  });
903 903
});
904
905
describe("the transcript's marker column", () => {
906
  const ESCAPE = String.fromCharCode(27);
907
908
  it("marks an entry with a dot rather than naming its role", async () => {
909
    const { rows } = await drive([{ type: "text", value: "an answer" }]);
910
    const painted = rows.join("\n");
911
912
    // Five words of chrome per turn — `you`, `think`, `coder`, `note`, `tool` —
913
    // said what the styling already said.
914
    expect(painted).toContain("● an answer");
915
    expect(painted).not.toMatch(/\bcoder\s+an answer/);
916
    expect(painted).not.toMatch(/^\s*you\s/m);
917
  });
918
919
  it("pulses an unfinished reply and settles it when it ends", async () => {
920
    const stdin = new FakeIn();
921
    const stdout = new FakeOut();
922
    let release = () => {};
923
    const gate = new Promise<void>((resolve) => {
924
      release = resolve;
925
    });
926
    const held: ReplySource = {
927
      model: "scripted",
928
      async *reply() {
929
        yield { type: "text", value: "still going" } as const;
930
        await gate;
931
      },
932
    };
933
    const session = new CoderSession(held, "repo", "main");
934
    const running = runCoderUi(session, {
935
      stdin: stdin as unknown as NodeJS.ReadStream,
936
      stdout: stdout as unknown as NodeJS.WriteStream,
937
    });
938
939
    const turn = session.submit("go");
940
    await new Promise((resolve) => setTimeout(resolve, 0));
941
942
    // A hollow dot is the half of the pulse that says "still arriving".
943
    const during = stdout.written;
944
    expect(during).toContain("still going");
945
946
    release();
947
    await turn;
948
    stdin.emit("data", "\x04");
949
    await running;
950
951
    expect(stdout.written).toContain("●");
952
  });
953
954
  it("keeps the scroll marker in the same voice as the rest of the bar", async () => {
955
    const { painted } = await drive(
956
      Array.from({ length: 60 }, (_unused, at) => ({
957
        type: "text" as const,
958
        value: `line ${String(at)}\n`,
959
      })),
960
    );
961
962
    // It was the one yellow word in a dim row, which read as a warning.
963
    expect(painted).not.toContain(`${ESCAPE}[33mscrolled`);
964
    // And the key it advertised is gone from the bar.
965
    expect(painted).not.toContain("pgup/pgdn to scroll");
966
  });
967
});

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