Open a child's transcript from the column, and come back

5509cbec12f8 · AtlantisPleb · · parent dda762e5a9d4

Open a child's transcript from the column, and come back

The column said what each child was doing in one phrase and that was all there
was until it finished. A child works for minutes and reports one paragraph, so
the interesting part — what it read, what it ran, what came back — was written
to a file nobody could open without leaving the session.

Right hands the arrow keys to the column, which grows a selector bar. Up and
down move it, enter fills the screen with that child's transcript, and left
gives the keys back. From a child, left or escape returns to the column it was
opened from rather than to the composer, because stepping through children
should not need a press of right between each one.

The transcript is read from the file the harness is still appending to, so an
open child updates as it works rather than at the end. Two shapes arrive there
and both are handled: the self-hosted harness writes its own small records, and
`opencode` writes its own event stream, which `parseOpencodeEvent` already
reads because the fleet reads it live. A half-written last line is ignored and
parses on the next frame — that is the normal state of a running child, not an
error. The file is re-read when it has grown and reused when it has not, since
a finished child re-parsed on every frame is work that buys nothing.

Three things this could have got wrong:

Typing is always typing. A sentence begun while the column has the keys brings
the composer back rather than being swallowed as shortcuts.

The selector bar shows only while the column actually holds the keys. A
highlight on a list that does not answer them is a lie about where typing goes,
and so is a cursor left blinking in a composer that is not taking any — it
moves to the selected child instead.

The order the selector walks is the order the column draws, from one function,
because two orders select the wrong child.

Right is ignored when there is no column: no children running, or a terminal
too narrow for one. `/help` carries the keys.

697 tests pass, where 684 did. Verified by driving the real interface through
the whole path: right, down, enter into a child's transcript, left back to the
column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

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
  • added packages/openagents-cli/src/coder-child-transcript.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-child-transcript.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

7 files changed, +675 -10

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": 2455,
7
    "filesScanned": 2456,
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:e3bf4b2c23dae0ee9b20eb8a0ce09bae4d32b9b57aa8d94ababaa57716efa9fd",
4
  "sourceDigest": "sha256:627eb95e2969a269c885d76ea8b1cc3983aa98770237dca8373cbd6a73645bb6",
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 (55 tracked test files)"
1879
          "ref": "packages/openagents-cli (56 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/coder-child-transcript.ts added +170

@@ -0,0 +1,170 @@

1
import { readFileSync, statSync } from "node:fs";
2
3
import { parseOpencodeEvent } from "./coder-delegate.js";
4
5
/**
6
 * A child's own transcript, read back for the interface to show.
7
 *
8
 * Every harness writes one as its child runs — the fleet hands each child a
9
 * path and the harness appends to it, so a child that is killed still leaves
10
 * everything it had done behind. This reads that file so the reader can open a
11
 * child and watch it work rather than waiting for the one paragraph it returns
12
 * at the end.
13
 *
14
 * Two shapes arrive here and neither is negotiable. The self-hosted harness
15
 * writes its own small records (`session`, `tool`, `tool_result`, `text`);
16
 * `opencode` writes its own event stream, which `parseOpencodeEvent` already
17
 * knows how to read because the fleet reads it live. Both are normalized to
18
 * the same handful of entries, so the screen renders one thing.
19
 *
20
 * A file is re-read when it has grown and reused when it has not. A child is
21
 * appended to several times a second and the screen repaints at least as
22
 * often; re-parsing a finished child's whole transcript on every frame is work
23
 * that buys nothing.
24
 */
25
26
export type ChildEntry =
27
  | { readonly kind: "started"; readonly model: string; readonly cwd: string }
28
  | { readonly kind: "tool"; readonly name: string; readonly target: string | undefined }
29
  | { readonly kind: "output"; readonly text: string }
30
  | { readonly kind: "text"; readonly text: string }
31
  | { readonly kind: "error"; readonly text: string };
32
33
interface Cached {
34
  readonly size: number;
35
  readonly entries: ReadonlyArray<ChildEntry>;
36
}
37
38
/**
39
 * What has been read, by path, bounded.
40
 *
41
 * A session's children are few, but nothing here prunes on its own and a cache
42
 * that only grows is one that eventually holds every transcript of a long
43
 * session in memory. The oldest entry goes when the cap is reached; re-reading
44
 * it costs one parse.
45
 */
46
const CACHE_LIMIT = 32;
47
const cache = new Map<string, Cached>();
48
49
const remember = (path: string, entry: Cached): void => {
50
  cache.delete(path);
51
  cache.set(path, entry);
52
  if (cache.size > CACHE_LIMIT) {
53
    const oldest = cache.keys().next();
54
    if (!oldest.done) cache.delete(oldest.value);
55
  }
56
};
57
58
/**
59
 * The child's transcript at this path, or an empty list.
60
 *
61
 * Never throws. A child that has not written yet, a path that was removed, and
62
 * a line that is half-written because the harness is mid-append are all the
63
 * same thing to a reader watching a running child: less than there will be in
64
 * a moment.
65
 */
66
export const readChildTranscript = (path: string | undefined): ReadonlyArray<ChildEntry> => {
67
  if (path === undefined) return [];
68
69
  let size: number;
70
  try {
71
    size = statSync(path).size;
72
  } catch {
73
    return [];
74
  }
75
76
  const seen = cache.get(path);
77
  if (seen !== undefined && seen.size === size) return seen.entries;
78
79
  let contents: string;
80
  try {
81
    contents = readFileSync(path, "utf8");
82
  } catch {
83
    return seen?.entries ?? [];
84
  }
85
86
  const entries = contents.split("\n").flatMap((line) => {
87
    const entry = parseLine(line);
88
    return entry === undefined ? [] : [entry];
89
  });
90
91
  remember(path, { size, entries });
92
  return entries;
93
};
94
95
const parseLine = (line: string): ChildEntry | undefined => {
96
  const trimmed = line.trim();
97
  if (trimmed.length === 0 || !trimmed.startsWith("{")) return undefined;
98
99
  let record: Record<string, unknown>;
100
  try {
101
    record = JSON.parse(trimmed) as Record<string, unknown>;
102
  } catch {
103
    // A half-written last line, because the harness is appending as this
104
    // reads. It will parse on the next frame.
105
    return undefined;
106
  }
107
108
  const own = fromSelfHarness(record);
109
  if (own !== undefined) return own;
110
111
  // Anything else is a harness with its own event stream, and `opencode`'s is
112
  // the one the fleet already reads live.
113
  const event = parseOpencodeEvent(trimmed);
114
  if (event === undefined) return undefined;
115
116
  switch (event.type) {
117
    case "tool":
118
      return { kind: "tool", name: event.name, target: event.target };
119
    case "text":
120
      return { kind: "text", text: event.value };
121
    case "error":
122
      return { kind: "error", text: event.message };
123
    default:
124
      return undefined;
125
  }
126
};
127
128
const fromSelfHarness = (record: Record<string, unknown>): ChildEntry | undefined => {
129
  const type = record["type"];
130
131
  if (type === "session") {
132
    return {
133
      kind: "started",
134
      model: text(record["model"]) ?? "unknown",
135
      cwd: text(record["cwd"]) ?? "",
136
    };
137
  }
138
139
  if (type === "tool") {
140
    const name = text(record["name"]);
141
    if (name === undefined) return undefined;
142
    return { kind: "tool", name, target: target(record["arguments"]) };
143
  }
144
145
  if (type === "tool_result") {
146
    const output = text(record["output"]);
147
    return output === undefined ? undefined : { kind: "output", text: output };
148
  }
149
150
  if (type === "text") {
151
    const value = text(record["value"]);
152
    return value === undefined ? undefined : { kind: "text", text: value };
153
  }
154
155
  return undefined;
156
};
157
158
/** The one argument worth showing beside a tool's name, if there is one. */
159
const target = (args: unknown): string | undefined => {
160
  if (typeof args !== "object" || args === null) return undefined;
161
  const record = args as Record<string, unknown>;
162
  for (const key of ["command", "path", "file", "pattern", "query"]) {
163
    const value = record[key];
164
    if (typeof value === "string" && value.length > 0) return value;
165
  }
166
  return undefined;
167
};
168
169
const text = (value: unknown): string | undefined =>
170
  typeof value === "string" && value.length > 0 ? value : undefined;
packages/openagents-cli/src/coder-session.ts modified +2

@@ -765,6 +765,8 @@ export class CoderSession {

765 765
          "  shift+tab                   change how hard it thinks",
766 766
          "  ctrl+o                      expand a tool call",
767 767
          "  ctrl+x                      stop the children",
768
          "  →                           move into the children column",
769
          "  ↑↓ · enter · ←              in the column: select · open · leave",
768 770
          "  pgup / pgdn                 scroll the transcript",
769 771
          "  ctrl+c                      stop · ctrl+d  quit",
770 772
        ].join("\n"),
packages/openagents-cli/src/coder-ui.ts modified +268 -7

@@ -35,6 +35,7 @@

35 35
 * own job instead.
36 36
 */
37 37
38
import { readChildTranscript } from "./coder-child-transcript.js";
38 39
import { activityPhrase, fleetRows, latestActivities, taskActivity } from "./coder-fleet.js";
39 40
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
40 41
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";

@@ -83,6 +84,8 @@ const ALT_SCROLL_OFF = "\x1b[?1007l";

83 84
84 85
const DIM = "\x1b[2m";
85 86
const BOLD = "\x1b[1m";
87
/** Reverse video, for the sidebar's selector bar. */
88
const REVERSE = "\x1b[7m";
86 89
const ITALIC = "\x1b[3m";
87 90
const RESET = "\x1b[0m";
88 91
const CYAN = "\x1b[36m";

@@ -137,6 +140,9 @@ const PREVIEW_ROWS = 3;

137 140
 */
138 141
const SIDEBAR_WIDTH = 34;
139 142
const SIDEBAR_MINIMUM_TERMINAL = 100;
143
144
/** How many rows of one tool result the child screen shows. */
145
const CHILD_OUTPUT_ROWS = 12;
140 146
/** Width of the role gutter, so every entry's text starts in one column. */
141 147
/**
142 148
 * Width of the marker column.

@@ -338,7 +344,25 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

338 344
   * screen over it rather than a turn in it, because switching a skill off is
339 345
   * a change to what the next turn carries, not something to say to the model.
340 346
   */
341
  let screen: "chat" | "skills" = "chat";
347
  let screen: "chat" | "skills" | "child" = "chat";
348
349
  /**
350
   * Which half of the chat screen has the arrow keys.
351
   *
352
   * The composer, until the reader presses right. The sidebar is a list and
353
   * the composer is a field, and the two want the same four keys, so one of
354
   * them holds them at a time and the other shows that it does not.
355
   */
356
  let focus: "composer" | "sidebar" = "composer";
357
  /** The selected child, as an index into the sidebar's own order. */
358
  let sidebarRow = 0;
359
  /** The child whose transcript fills the screen, while one does. */
360
  let childId: string | undefined;
361
  /** Scroll position within that transcript, or the end when undefined. */
362
  let childAnchor: number | undefined;
363
  /** How many rows the child screen can show, for paging it. */
364
  let childViewport = 1;
365
  let childLines = 0;
342 366
  /** The row `/skills` acts on. */
343 367
  let skillRow = 0;
344 368
  /**

@@ -623,6 +647,16 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

623 647
     * viewport at the moment the reader most wanted to see what the parent
624 648
     * had said.
625 649
     */
650
    /**
651
     * The order the column shows children in, and the order the selector moves
652
     * through. One function, because a selector that walks a different order
653
     * than the one on screen selects the wrong child.
654
     */
655
    const sidebarOrder = (tasks: ReadonlyArray<CoderTask>): ReadonlyArray<CoderTask> => [
656
      ...tasks.filter((task) => task.status === "running" || task.status === "pending"),
657
      ...tasks.filter((task) => task.status !== "running" && task.status !== "pending"),
658
    ];
659
626 660
    const sidebarLines = (
627 661
      tasks: ReadonlyArray<CoderTask>,
628 662
      height: number,

@@ -637,14 +671,14 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

637 671
          ? `${BOLD}children${RESET}`
638 672
          : `${BOLD}children${RESET} ${DIM}${String(running)} working${RESET}`;
639 673
640
      const rows: string[] = [heading, ""];
674
      const rows: string[] = [
675
        heading,
676
        focus === "sidebar" ? `${DIM}↑↓ select · enter opens · ← back${RESET}` : "",
677
      ];
641 678
642 679
      // Working children first. A finished one has already been reported on
643 680
      // the transcript, so it is the one to drop when the column runs out.
644
      const ordered = [
645
        ...tasks.filter((task) => task.status === "running" || task.status === "pending"),
646
        ...tasks.filter((task) => task.status !== "running" && task.status !== "pending"),
647
      ];
681
      const ordered = sidebarOrder(tasks);
648 682
649 683
      const built = fleetRows(ordered, inner);
650 684

@@ -656,7 +690,16 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

656 690
        if (rows.length + 2 > height) break;
657 691
658 692
        const color = fleetColor(row.status);
659
        rows.push(`${color}${row.mark}${RESET} ${truncate(task.description, inner - 2)}`);
693
        // The selector bar. Only while the column holds the keys, because a
694
        // highlight on a list that does not answer them is a lie about where
695
        // typing goes.
696
        const selected = focus === "sidebar" && index === sidebarRow;
697
        const name = truncate(task.description, inner - 2);
698
        rows.push(
699
          selected
700
            ? `${REVERSE}${color}${row.mark}${RESET}${REVERSE} ${name}${RESET}`
701
            : `${color}${row.mark}${RESET} ${name}`,
702
        );
660 703
661 704
        const activities = latestActivities([task], PREVIEW_ROWS);
662 705

@@ -683,6 +726,79 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

683 726
      return rows;
684 727
    };
685 728
729
    /**
730
     * One child's transcript, filling the screen.
731
     *
732
     * A child reports one paragraph when it finishes, and until then the fleet
733
     * row says only what tool it is on. This is the rest of it: every event the
734
     * harness wrote, read from the file it is still appending to, so a reader
735
     * can watch a child work instead of waiting for its summary.
736
     */
737
    const childScreenLines = (task: CoderTask, width: number): ReadonlyArray<string> => {
738
      const rows: string[] = [];
739
      const body = Math.max(20, width - 4);
740
741
      rows.push(
742
        `${BOLD}${task.description}${RESET} ${DIM}${task.agent} · ${task.model} · ${task.status}${RESET}`,
743
        "",
744
      );
745
746
      const entries = readChildTranscript(task.transcriptPath);
747
748
      if (entries.length === 0) {
749
        rows.push(
750
          task.transcriptPath === undefined
751
            ? `${DIM}This child has not started writing yet.${RESET}`
752
            : `${DIM}Nothing written yet.${RESET}`,
753
        );
754
      }
755
756
      for (const entry of entries) {
757
        switch (entry.kind) {
758
          case "started":
759
            rows.push(`${DIM}started in ${entry.cwd} on ${entry.model}${RESET}`, "");
760
            break;
761
762
          case "tool":
763
            rows.push(
764
              `${YELLOW}▸${RESET} ${BOLD}${entry.name}${RESET}` +
765
                (entry.target === undefined ? "" : ` ${DIM}${truncate(entry.target, body - 6)}${RESET}`),
766
            );
767
            break;
768
769
          case "output":
770
            // Bounded per result. A child that cats a large file must not push
771
            // everything it did before that off the top of the screen.
772
            for (const line of entry.text.split("\n").slice(0, CHILD_OUTPUT_ROWS)) {
773
              rows.push(`  ${DIM}${truncate(line, body - 2)}${RESET}`);
774
            }
775
            if (entry.text.split("\n").length > CHILD_OUTPUT_ROWS) {
776
              rows.push(`  ${DIM}…${RESET}`);
777
            }
778
            break;
779
780
          case "text":
781
            rows.push("", ...wrapStyled(entry.text, body, ""), "");
782
            break;
783
784
          case "error":
785
            rows.push(...wrapStyled(entry.text, body, RED));
786
            break;
787
        }
788
      }
789
790
      // The answer, once there is one. It is what the parent was given, and a
791
      // reader who opened the child came for exactly this.
792
      if (task.result !== undefined && task.result.length > 0) {
793
        rows.push("", `${GREEN}result${RESET}`, ...wrapStyled(task.result, body, ""));
794
      }
795
      if (task.error !== undefined) {
796
        rows.push("", `${RED}failed${RESET}`, ...wrapStyled(task.error, body, RED));
797
      }
798
799
      return rows.map((row) => `  ${row}`);
800
    };
801
686 802
    const render = () => {
687 803
      if (closed) return;
688 804
      const snapshot = session.snapshot();

@@ -705,6 +821,41 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

705 821
        paint(rows, rows.length, 1);
706 822
        return;
707 823
      }
824
      // One child, filling the screen. Left or escape returns to the chat, and
825
      // the chat is untouched underneath: this is a screen over it, not a
826
      // place the session went.
827
      if (screen === "child") {
828
        const task = snapshot.tasks.find((candidate) => candidate.id === childId);
829
830
        if (task === undefined) {
831
          // The child was cleared while its transcript was open.
832
          screen = "chat";
833
        } else {
834
          const room = Math.max(1, height - 2);
835
          const lines = childScreenLines(task, width);
836
          childLines = lines.length;
837
          childViewport = room;
838
839
          const last = Math.max(0, lines.length - room);
840
          const from = childAnchor === undefined ? last : Math.min(childAnchor, last);
841
842
          const rows: string[] = [];
843
          for (let row = 0; row < room; row += 1) rows.push(lines[from + row] ?? "");
844
845
          rows.push(
846
            `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`,
847
            hints(
848
              [{ text: "↑↓ scroll" }, { text: "← back" }, { text: "esc back" }],
849
              `${DIM}${task.description}${RESET}`,
850
              width,
851
            ),
852
          );
853
854
          paint(rows, rows.length, 1);
855
          return;
856
        }
857
      }
858
708 859
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - SPACER_ROWS);
709 860
710 861
      // The column appears when there is something to put in it and room to

@@ -804,6 +955,15 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

804 955
      // read first and the bottom chrome is the last thing the eye reaches.
805 956
      rows.push(`  ${justify(activity, where, inner)}`);
806 957
958
      if (focus === "sidebar") {
959
        // On the selected child, because a cursor left blinking in a composer
960
        // that is not taking keys says the wrong thing about where typing goes.
961
        const heading = 2;
962
        const selectedRow = Math.min(transcriptHeight, heading + sidebarRow * 3 + 1);
963
        paint(rows, selectedRow, transcriptWidth + 2);
964
        return;
965
      }
966
807 967
      paint(
808 968
        rows,
809 969
        transcriptHeight + 3,

@@ -1006,6 +1166,55 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1006 1166
          continue;
1007 1167
        }
1008 1168
1169
        // The child screen takes the keyboard while it is up, for the same
1170
        // reason the skills screen does: a stray letter must not fall through
1171
        // into a composer the reader cannot see.
1172
        if (screen === "child") {
1173
          if (char === "\x1b") {
1174
            const sequence = matchEscapeSequence(text, index);
1175
            if (sequence === undefined) {
1176
              pendingEscape = text.slice(index);
1177
              break;
1178
            }
1179
            index += sequence.length;
1180
1181
            const page = Math.max(1, childViewport - 1);
1182
            const scroll = (by: number) => {
1183
              const last = Math.max(0, childLines - childViewport);
1184
              const from = childAnchor ?? last;
1185
              const next = Math.min(last, Math.max(0, from + by));
1186
              childAnchor = next >= last ? undefined : next;
1187
            };
1188
1189
            if (sequence === "\x1b" || sequence === "\x1b[D" || sequence === "\x1bOD") {
1190
              // Back to the chat, and back into the column it was opened from,
1191
              // so a reader stepping through children does not have to press
1192
              // right again between each one.
1193
              screen = "chat";
1194
              focus = "sidebar";
1195
              childAnchor = undefined;
1196
              painted = [];
1197
            } else if (sequence === "\x1b[A" || sequence === "\x1bOA") scroll(-1);
1198
            else if (sequence === "\x1b[B" || sequence === "\x1bOB") scroll(1);
1199
            else if (sequence === "\x1b[5~") scroll(-page);
1200
            else if (sequence === "\x1b[6~") scroll(page);
1201
            else continue;
1202
1203
            render();
1204
            continue;
1205
          }
1206
1207
          index += 1;
1208
          if (char === "\x03" || char === "\x04") {
1209
            screen = "chat";
1210
            focus = "composer";
1211
            childAnchor = undefined;
1212
            painted = [];
1213
            render();
1214
          }
1215
          continue;
1216
        }
1217
1009 1218
        // The skills screen takes the keyboard while it is up. Only the keys it
1010 1219
        // names do anything: a stray letter must not fall through into the
1011 1220
        // composer of a screen the reader cannot see.

@@ -1103,6 +1312,35 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1103 1312
            continue;
1104 1313
          }
1105 1314
1315
          // The column, when there is one. Right hands it the arrow keys;
1316
          // left hands them back. Both are no-ops when no children are
1317
          // running, so the keys never disappear into a column that is not on
1318
          // screen.
1319
          const children = sidebarOrder(session.snapshot().tasks);
1320
          const columnOpen =
1321
            children.length > 0 && (stdout.columns ?? 80) >= SIDEBAR_MINIMUM_TERMINAL;
1322
1323
          if (focus === "composer" && columnOpen && (sequence === "\x1b[C" || sequence === "\x1bOC")) {
1324
            focus = "sidebar";
1325
            sidebarRow = Math.min(sidebarRow, children.length - 1);
1326
            render();
1327
            continue;
1328
          }
1329
1330
          if (focus === "sidebar") {
1331
            if (sequence === "\x1b[D" || sequence === "\x1bOD" || sequence === "\x1b") {
1332
              focus = "composer";
1333
            } else if (sequence === "\x1b[A" || sequence === "\x1bOA") {
1334
              sidebarRow = Math.max(0, sidebarRow - 1);
1335
            } else if (sequence === "\x1b[B" || sequence === "\x1bOB") {
1336
              sidebarRow = Math.min(Math.max(0, children.length - 1), sidebarRow + 1);
1337
            } else {
1338
              continue;
1339
            }
1340
            render();
1341
            continue;
1342
          }
1343
1106 1344
          const page = Math.max(1, viewport - 1);
1107 1345
          if (sequence === "\x1b[5~") scrollBy(-page);
1108 1346
          else if (sequence === "\x1b[6~") scrollBy(page);

@@ -1115,10 +1353,33 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1115 1353
          continue;
1116 1354
        }
1117 1355
1356
        // Typing is always typing. A reader who starts a sentence while the
1357
        // column has the keys means to type it, not to lose it, so the
1358
        // character brings the composer back rather than being swallowed.
1359
        if (focus === "sidebar" && char >= " " && char !== "\x7f") {
1360
          focus = "composer";
1361
        }
1362
1118 1363
        if (char === "\r" || char === "\n") {
1119 1364
          index += 1;
1120 1365
          // Swallow a CRLF pair so a paste does not submit twice.
1121 1366
          if (char === "\r" && text[index] === "\n") index += 1;
1367
1368
          // Enter belongs to whatever holds the arrow keys. In the column it
1369
          // opens the selected child rather than sending the composer, which
1370
          // would send a line the reader was not looking at.
1371
          if (focus === "sidebar") {
1372
            const selected = sidebarOrder(session.snapshot().tasks)[sidebarRow];
1373
            if (selected !== undefined) {
1374
              childId = selected.id;
1375
              childAnchor = undefined;
1376
              screen = "child";
1377
              painted = [];
1378
              render();
1379
            }
1380
            continue;
1381
          }
1382
1122 1383
          // Always. A turn already running is not a reason to drop what was
1123 1384
          // typed: an interface command runs at once, and anything else is
1124 1385
          // queued by the session and sent when the turn ends. Ignoring the key
packages/openagents-cli/test/coder-child-transcript.test.ts added +96

@@ -0,0 +1,96 @@

1
import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { describe, expect, it } from "vitest";
5
6
import { readChildTranscript } from "../src/coder-child-transcript.js";
7
8
const transcript = (lines: ReadonlyArray<unknown>) => {
9
  const path = join(mkdtempSync(join(tmpdir(), "oa-child-")), "child.jsonl");
10
  writeFileSync(path, lines.map((line) => JSON.stringify(line)).join("\n") + "\n");
11
  return path;
12
};
13
14
describe("reading a child this process ran", () => {
15
  it("reads its own records back", () => {
16
    const path = transcript([
17
      { type: "session", sessionId: "s1", model: "ox-alpha", cwd: "/repo" },
18
      { type: "tool", callId: "t1", name: "shell", arguments: { command: "git log" } },
19
      { type: "tool_result", callId: "t1", output: "abc123 a commit" },
20
      { type: "text", value: "The last commit is abc123." },
21
    ]);
22
23
    expect(readChildTranscript(path)).toEqual([
24
      { kind: "started", model: "ox-alpha", cwd: "/repo" },
25
      { kind: "tool", name: "shell", target: "git log" },
26
      { kind: "output", text: "abc123 a commit" },
27
      { kind: "text", text: "The last commit is abc123." },
28
    ]);
29
  });
30
31
  it("takes the one argument worth showing beside a tool", () => {
32
    const path = transcript([
33
      { type: "tool", name: "read", arguments: { path: "lib/a.ex", offset: 40 } },
34
      { type: "tool", name: "think", arguments: { thoughts: "hmm" } },
35
    ]);
36
37
    const entries = readChildTranscript(path);
38
    expect(entries[0]).toEqual({ kind: "tool", name: "read", target: "lib/a.ex" });
39
    // Nothing worth showing is shown as nothing, rather than as the first
40
    // field that happened to be a string.
41
    expect(entries[1]).toEqual({ kind: "tool", name: "think", target: undefined });
42
  });
43
});
44
45
describe("reading a child opencode ran", () => {
46
  it("reads opencode's own event stream through the parser the fleet uses", () => {
47
    const path = transcript([
48
      {
49
        type: "tool_use",
50
        part: {
51
          type: "tool",
52
          callID: "call_1",
53
          tool: "bash",
54
          state: { status: "completed", input: { command: "pnpm test" } },
55
        },
56
      },
57
    ]);
58
59
    const entries = readChildTranscript(path);
60
    expect(entries).toHaveLength(1);
61
    expect(entries[0]).toMatchObject({ kind: "tool", name: "bash" });
62
  });
63
});
64
65
describe("reading a child that is still writing", () => {
66
  it("ignores a half-written last line rather than losing the file", () => {
67
    const path = transcript([{ type: "text", value: "so far" }]);
68
    // The harness is mid-append. This is the common case while a child runs,
69
    // not an error state.
70
    appendFileSync(path, `{"type":"tool","name":"sh`);
71
72
    expect(readChildTranscript(path)).toEqual([{ kind: "text", text: "so far" }]);
73
  });
74
75
  it("re-reads once the file has grown, and not before", () => {
76
    const path = transcript([{ type: "text", value: "one" }]);
77
    expect(readChildTranscript(path)).toHaveLength(1);
78
79
    // Same size, so the parse is reused: a finished child re-parsed on every
80
    // frame is work that buys nothing.
81
    expect(readChildTranscript(path)).toHaveLength(1);
82
83
    appendFileSync(path, JSON.stringify({ type: "text", value: "two" }) + "\n");
84
    expect(readChildTranscript(path)).toHaveLength(2);
85
  });
86
});
87
88
describe("reading a child that has written nothing", () => {
89
  it("is empty for a path that does not exist, rather than throwing", () => {
90
    expect(readChildTranscript(join(tmpdir(), "oa-absent", "nothing.jsonl"))).toEqual([]);
91
  });
92
93
  it("is empty for a child the harness has not named a path for", () => {
94
    expect(readChildTranscript(undefined)).toEqual([]);
95
  });
96
});
packages/openagents-cli/test/coder-ui.test.ts modified +136

@@ -1066,3 +1066,139 @@ describe("where a running child is shown", () => {

1066 1066
    expect(rows.join("\n")).not.toContain(" → ");
1067 1067
  });
1068 1068
});
1069
1070
describe("inspecting a child from the column", () => {
1071
  /** Drive the interface, sending keys between paints, and return each frame. */
1072
  const driveKeys = async (
1073
    record: (registry: CoderTaskRegistry) => void,
1074
    steps: ReadonlyArray<ReadonlyArray<string>>,
1075
  ): Promise<ReadonlyArray<ReadonlyArray<string>>> => {
1076
    const stdin = new FakeIn();
1077
    const stdout = new FakeOut();
1078
    stdout.columns = 120;
1079
    const registry = new CoderTaskRegistry();
1080
    const session = new CoderSession(
1081
      {
1082
        model: "scripted",
1083
        async *reply(_prompt: string, signal: AbortSignal) {
1084
          yield { type: "tool_call", callId: "c1", name: "delegate", arguments: "{}" };
1085
          await new Promise<void>((resolve) => {
1086
            if (signal.aborted) return resolve();
1087
            signal.addEventListener("abort", () => resolve(), { once: true });
1088
          });
1089
        },
1090
      },
1091
      "repo",
1092
      "main",
1093
      { registry, fleet: { submit: (): Promise<never> => new Promise(() => {}) }, label: "fake" },
1094
    );
1095
1096
    const running = runCoderUi(session, {
1097
      stdin: stdin as unknown as NodeJS.ReadStream,
1098
      stdout: stdout as unknown as NodeJS.WriteStream,
1099
    });
1100
1101
    const turn = session.submit("go");
1102
    await new Promise((resolve) => setTimeout(resolve, 0));
1103
    record(registry);
1104
    await new Promise((resolve) => setTimeout(resolve, 0));
1105
1106
    const frames: ReadonlyArray<string>[] = [];
1107
    for (const keys of steps) {
1108
      stdout.written = "";
1109
      for (const key of keys) stdin.emit("data", key);
1110
      await new Promise((resolve) => setTimeout(resolve, 0));
1111
      frames.push(screen(stdout.written));
1112
    }
1113
1114
    // Back to a state that can quit, whatever the steps left behind: the
1115
    // child screen and the column both take ctrl+d as "return", and ctrl+d
1116
    // only exits from an empty composer.
1117
    stdin.emit("data", "\x04");
1118
    stdin.emit("data", "\x7f".repeat(40));
1119
    stdin.emit("data", "\x04");
1120
    await running;
1121
    session.interrupt();
1122
    await turn;
1123
    return frames;
1124
  };
1125
1126
  const two = (registry: CoderTaskRegistry) => {
1127
    for (const [id, description] of [
1128
      ["d1", "audit open_router"],
1129
      ["d2", "audit vercel_gateway"],
1130
    ] as const) {
1131
      const task = registry.register({
1132
        id,
1133
        description,
1134
        prompt: "x",
1135
        agent: "openagents",
1136
        model: "ox-alpha",
1137
        cwd: "/tmp",
1138
        background: true,
1139
      });
1140
      registry.start(task.id, new AbortController());
1141
    }
1142
  };
1143
1144
  it("hands the arrow keys to the column on right, and back on left", async () => {
1145
    const [before, inside, back] = await drillKeys();
1146
1147
    // Nothing says the column is live until it is.
1148
    expect(before.join("\n")).not.toContain("enter opens");
1149
    expect(inside.join("\n")).toContain("enter opens");
1150
    expect(back.join("\n")).not.toContain("enter opens");
1151
  });
1152
1153
  it("moves the selector without moving the transcript", async () => {
1154
    const frames = await driveKeys(two, [
1155
      ["\x1b[C"],
1156
      ["\x1b[B"],
1157
    ]);
1158
1159
    // Down in the column selects the second child. It must not scroll the
1160
    // conversation, which is what down does when the composer has the keys.
1161
    const moved = frames[1] ?? [];
1162
    expect(moved.join("\n")).toContain("audit vercel_gateway");
1163
  });
1164
1165
  it("opens the selected child on enter, filling the screen", async () => {
1166
    const frames = await driveKeys(two, [["\x1b[C"], ["\x1b[B"], ["\r"]]);
1167
    const opened = (frames[2] ?? []).join("\n");
1168
1169
    expect(opened).toContain("audit vercel_gateway");
1170
    expect(opened).toContain("↑↓ scroll");
1171
    // The conversation is not underneath it: this is a screen, not a pane.
1172
    expect(opened).not.toContain("│");
1173
  });
1174
1175
  it("returns from a child to the column it was opened from", async () => {
1176
    // Stepping through children should not need a press of right between each.
1177
    const frames = await driveKeys(two, [["\x1b[C"], ["\r"], ["\x1b[D"]]);
1178
    const returned = (frames[2] ?? []).join("\n");
1179
1180
    expect(returned).toContain("enter opens");
1181
    expect(returned).toContain("│");
1182
  });
1183
1184
  it("gives the keys back to the composer when the reader types", async () => {
1185
    // A sentence started while the column has the keys is a sentence, not a
1186
    // set of shortcuts to swallow.
1187
    const frames = await driveKeys(two, [["\x1b[C"], ["h", "i"]]);
1188
    const typed = (frames[1] ?? []).join("\n");
1189
1190
    expect(typed).toContain("› hi");
1191
  });
1192
1193
  it("ignores right when there is no column to move into", async () => {
1194
    const frames = await driveKeys(
1195
      () => undefined,
1196
      [["\x1b[C"]],
1197
    );
1198
1199
    expect((frames[0] ?? []).join("\n")).not.toContain("enter opens");
1200
  });
1201
1202
  const drillKeys = async () =>
1203
    driveKeys(two, [[], ["\x1b[C"], ["\x1b[D"]]);
1204
});

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