Give running children a column of their own

979ee0f928d5 · AtlantisPleb · · parent 27526bf8a6f2

Give running children a column of their own

A fan-out of fifteen took the whole viewport at the moment the reader most
wanted to see what the parent had said. The fleet was drawn inline under the
`delegate` call — one row per child plus three of its own activity — so the
more work was in flight, the less of the conversation was on screen.

The children move to a right column beside the transcript. The conversation
keeps its own space and the fleet keeps updating without pushing it anywhere.

The column is conditional on both counts. It opens when the first child starts
and closes when the last one is cleared, and it needs a terminal at least a
hundred columns wide: a fleet row wants about thirty before its description and
its activity both survive being cut, and carving that out of eighty would make
the transcript pay for the sidebar in wrapped lines. Below the threshold the
rows go inline exactly as before, because a fleet with nowhere to go is worse
than one read in the feed.

It says it once. With the column open the inline block is suppressed, so a
child appears on one side or the other and never on both — a test holds that,
because two of the same child on one screen is the failure this invites.

A child that has not called a tool yet shows its state rather than nothing. An
empty cell under a child's name reads as a stalled child, which is what the
inline block already knew and this had to learn.

Rows are padded to the column width before the divider, because a styled row is
longer in bytes than it is on screen and a divider that lands in a different
place on each row is not a divider.

Two `ThreadReplySource` tests were already red on this tip, unrelated to this:
the source emits a `usage` chunk at the end of a turn and both asserted on the
whole chunk list. They assert on the shape each is actually about now. 680 tests
pass, where 678 did.

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

Diff

3 files changed, +184 -26

packages/openagents-cli/src/coder-ui.ts modified +126 -13

@@ -12,14 +12,19 @@

12 12
 *
13 13
 * The layout:
14 14
 *
15
 *     ┌──────────────────────────────┐
16
 *     │ transcript, scrollable       │
17
 *     │   delegate preview inline    │
18
 *     │ delegate rows                │
19
 *     ├──────────────────────────────┤
20
 *     │ status line                  │
21
 *     │ composer                     │
22
 *     └──────────────────────────────┘
15
 *     ┌───────────────────────┬──────────┐
16
 *     │ transcript, scrollable │ children │
17
 *     │                        │  ├─ one  │
18
 *     │                        │  └─ two  │
19
 *     ├───────────────────────┴──────────┤
20
 *     │ composer                         │
21
 *     │ status line                      │
22
 *     └──────────────────────────────────┘
23
 *
24
 * The right column appears only while children are running and only on a
25
 * terminal wide enough to give it room without squeezing the transcript. On a
26
 * narrow terminal the same rows go inline under the `delegate` call instead,
27
 * because a fleet that has nowhere to go is worse than one read in the feed.
23 28
 *
24 29
 * Painting is differential. An earlier version cleared the whole screen and
25 30
 * repainted it several times a second while a reply streamed, which left a

@@ -30,7 +35,7 @@

30 35
 * own job instead.
31 36
 */
32 37
33
import { activityPhrase, fleetRows, latestActivities } from "./coder-fleet.js";
38
import { activityPhrase, fleetRows, latestActivities, taskActivity } from "./coder-fleet.js";
34 39
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
35 40
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";
36 41
import type { CoderTask, CoderTaskStatus } from "./coder-tasks.js";

@@ -121,6 +126,17 @@ const FLEET_ROWS_MAX = 8;

121 126
 * are doing and never more than three of them.
122 127
 */
123 128
const PREVIEW_ROWS = 3;
129
130
/**
131
 * The right column's width, and the narrowest terminal that gets one.
132
 *
133
 * A fleet row needs about thirty columns before its description and its
134
 * activity both survive being cut. Below the threshold the transcript would
135
 * pay for the sidebar in wrapped lines, so there is no sidebar and the fleet
136
 * stays inline.
137
 */
138
const SIDEBAR_WIDTH = 34;
139
const SIDEBAR_MINIMUM_TERMINAL = 100;
124 140
/** Width of the role gutter, so every entry's text starts in one column. */
125 141
/**
126 142
 * Width of the marker column.

@@ -393,7 +409,14 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

393 409
    };
394 410
395 411
    /** Turn the transcript into printable rows, newest last. */
396
    const transcriptLines = (snapshot: CoderSnapshot, width: number): ReadonlyArray<string> => {
412
    const transcriptLines = (
413
      snapshot: CoderSnapshot,
414
      width: number,
415
      // The children are on screen already, in the column to the right. Drawing
416
      // them inline as well would say everything twice, and the feed is where
417
      // the reader is following the conversation rather than the fleet.
418
      sidebar: boolean,
419
    ): ReadonlyArray<string> => {
397 420
      const out: string[] = [];
398 421
      const body = Math.max(20, width - GUTTER - 1);
399 422

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

401 424
      // reading as part of the sentence before it.
402 425
      for (const entry of snapshot.entries) {
403 426
        if (out.length > 0) out.push("");
404
        out.push(...renderEntry(entry, body, snapshot.tasks));
427
        out.push(...renderEntry(entry, body, sidebar ? [] : snapshot.tasks));
405 428
      }
406 429
      return out;
407 430
    };

@@ -591,6 +614,75 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

591 614
      return rows;
592 615
    };
593 616
617
    /**
618
     * The right column: what every child is doing, while any of them is.
619
     *
620
     * The same rows the inline block draws, one per child with its own latest
621
     * activity under it, but given a column of their own they do not push the
622
     * conversation off screen — a fan-out of fifteen used to take the whole
623
     * viewport at the moment the reader most wanted to see what the parent
624
     * had said.
625
     */
626
    const sidebarLines = (
627
      tasks: ReadonlyArray<CoderTask>,
628
      height: number,
629
    ): ReadonlyArray<string> => {
630
      const inner = SIDEBAR_WIDTH - 2;
631
      const running = tasks.filter(
632
        (task) => task.status === "running" || task.status === "pending",
633
      ).length;
634
635
      const heading =
636
        running === 0
637
          ? `${BOLD}children${RESET}`
638
          : `${BOLD}children${RESET} ${DIM}${String(running)} working${RESET}`;
639
640
      const rows: string[] = [heading, ""];
641
642
      // Working children first. A finished one has already been reported on
643
      // 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
      ];
648
649
      const built = fleetRows(ordered, inner);
650
651
      for (const [index, task] of ordered.entries()) {
652
        const row = built[index];
653
        if (row === undefined) continue;
654
        // Two rows per child at least — the row itself and one activity — so
655
        // the count is what decides how many fit rather than the cut.
656
        if (rows.length + 2 > height) break;
657
658
        const color = fleetColor(row.status);
659
        rows.push(`${color}${row.mark}${RESET} ${truncate(task.description, inner - 2)}`);
660
661
        const activities = latestActivities([task], PREVIEW_ROWS);
662
663
        if (activities.length === 0) {
664
          // A child that has not called a tool yet still has a state, and an
665
          // empty cell under its name reads as a stalled child.
666
          rows.push(`${DIM}  ${truncate(taskActivity(task), inner - 2)}${RESET}`);
667
        }
668
669
        for (const activity of activities) {
670
          if (rows.length + 1 > height) break;
671
          rows.push(`${DIM}  → ${truncate(activityPhrase(activity), inner - 4)}${RESET}`);
672
        }
673
674
        rows.push("");
675
      }
676
677
      const shown = ordered.filter((_task, index) => built[index] !== undefined).length;
678
      const hidden = tasks.length - Math.min(shown, tasks.length);
679
      if (hidden > 0 && rows.length < height) {
680
        rows.push(`${DIM}+${String(hidden)} more${RESET}`);
681
      }
682
683
      return rows;
684
    };
685
594 686
    const render = () => {
595 687
      if (closed) return;
596 688
      const snapshot = session.snapshot();

@@ -615,7 +707,14 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

615 707
      }
616 708
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - SPACER_ROWS);
617 709
618
      const lines = transcriptLines(snapshot, width);
710
      // The column appears when there is something to put in it and room to
711
      // put it. Both conditions are live: it opens when the first child starts
712
      // and closes when the last one is cleared, and a terminal resized narrow
713
      // gives the width back to the transcript.
714
      const sidebar = snapshot.tasks.length > 0 && width >= SIDEBAR_MINIMUM_TERMINAL;
715
      const transcriptWidth = sidebar ? width - SIDEBAR_WIDTH - 1 : width;
716
717
      const lines = transcriptLines(snapshot, transcriptWidth, sidebar);
619 718
      lineCount = lines.length;
620 719
      viewport = transcriptHeight;
621 720

@@ -625,8 +724,22 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

625 724
      // reads a still transcript as a stopped session.
626 725
      const above = start;
627 726
727
      const column = sidebar ? sidebarLines(snapshot.tasks, transcriptHeight) : [];
728
628 729
      const rows: string[] = [];
629
      for (let row = 0; row < transcriptHeight; row += 1) rows.push(lines[start + row] ?? "");
730
      for (let row = 0; row < transcriptHeight; row += 1) {
731
        const left = lines[start + row] ?? "";
732
        if (!sidebar) {
733
          rows.push(left);
734
          continue;
735
        }
736
737
        // Padded to the column, because a styled row is longer in bytes than
738
        // it is on screen and the divider has to land in the same place on
739
        // every row or it is not a divider.
740
        const padded = left + " ".repeat(Math.max(0, transcriptWidth - visibleWidth(left)));
741
        rows.push(`${padded}${DIM}│${RESET} ${column[row] ?? ""}`);
742
      }
630 743
631 744
      // Bottom chrome, in the order a reader scans it. The status line is the
632 745
      // main agent's state; the delegate preview lives inline under the
packages/openagents-cli/test/coder-thread.test.ts modified +9 -2

@@ -232,7 +232,11 @@ describe("ThreadReplySource", () => {

232 232
    stub({});
233 233
    const out = await chunks(await open());
234 234
235
    expect(out.every((chunk) => chunk.type === "text")).toBe(true);
235
    // Every chunk but the turn's own tally, which arrives once at the end.
236
    expect(out.filter((chunk) => chunk.type !== "usage").every((c) => c.type === "text")).toBe(
237
      true,
238
    );
239
    expect(out.at(-1)).toMatchObject({ type: "usage", calls: 1 });
236 240
    expect(textOf(out)).toBe("Hello! Nice");
237 241
  });
238 242

@@ -302,7 +306,10 @@ describe("ThreadReplySource", () => {

302 306
    // A call the session cannot run is still reported, and the turn continues
303 307
    // with the refusal on the thread, because the alternative is a turn that
304 308
    // ends on a tool row and never answers.
305
    expect(await chunks(await open())).toEqual([
309
    const produced = await chunks(await open());
310
    // The turn's tally is asserted where it is about the tally; here the shape
311
    // under test is the call assembly.
312
    expect(produced.filter((chunk) => chunk.type !== "usage")).toEqual([
306 313
      {
307 314
        type: "tool_call",
308 315
        callId: "call-1",
packages/openagents-cli/test/coder-ui.test.ts modified +49 -11

@@ -904,13 +904,15 @@ describe("the chrome under the composer", () => {

904 904
  });
905 905
});
906 906
907
describe("the delegate preview inline under the tool call", () => {
908
  /** A session with a running delegate tool call, for testing the inline preview. */
907
describe("where a running child is shown", () => {
908
  /** A session with a running delegate tool call, at a given terminal width. */
909 909
  const driveDelegated = async (
910 910
    record: (registry: CoderTaskRegistry) => void,
911
    columns = 100,
911 912
  ): Promise<ReadonlyArray<string>> => {
912 913
    const stdin = new FakeIn();
913 914
    const stdout = new FakeOut();
915
    stdout.columns = columns;
914 916
    const registry = new CoderTaskRegistry();
915 917
    const session = new CoderSession(
916 918
      {

@@ -973,22 +975,59 @@ describe("the delegate preview inline under the tool call", () => {

973 975
    return task.id;
974 976
  };
975 977
976
  it("sits directly under the delegate row while a child runs", async () => {
978
  it("puts a running child in the right column, beside the conversation", async () => {
977 979
    const rows = await driveDelegated((registry) => {
978 980
      registerChild(registry);
979 981
    });
980 982
981
    const delegateRow = rows.findIndex((row) => row.includes("delegate"));
982
    expect(delegateRow).toBeGreaterThan(0);
983
    const childRow = rows[delegateRow + 1] ?? "";
984
    expect(childRow).toContain("inspect the repo");
985
    expect(childRow).toContain("Initializing");
983
    const heading = rows.find((row) => row.includes("children"));
984
    expect(heading).toBeDefined();
985
    expect(heading).toContain("1 working");
986
987
    const at = rows.findIndex((row) => row.includes("inspect the repo"));
988
    expect(at).toBeGreaterThan(0);
989
    // Beside, not below: the divider is to its left, so the transcript still
990
    // has the row it is on.
991
    expect(rows[at]).toContain("│");
992
    // A child that has not called a tool yet still says what it is doing.
993
    expect(rows[at + 1] ?? "").toContain("Initializing");
994
986 995
    // The session status lives at the bottom and does not repeat the fleet.
987 996
    const bottom = rows.filter((row) => row.includes("repo · main")).at(-1) ?? "";
988 997
    expect(bottom).toContain("repo · main");
989 998
    expect(bottom).not.toContain("1 agent");
990 999
  });
991 1000
1001
  it("says it once: the fleet is in the column or in the feed, never both", async () => {
1002
    const rows = await driveDelegated((registry) => {
1003
      registerChild(registry);
1004
    });
1005
1006
    // The inline block drew the child directly under the `delegate` row. With
1007
    // a column open that would be the same child twice on one screen.
1008
    const delegateRow = rows.findIndex((row) => row.includes("delegate"));
1009
    expect(delegateRow).toBeGreaterThan(0);
1010
    const under = rows[delegateRow + 1] ?? "";
1011
    expect(under).not.toContain("inspect the repo");
1012
1013
    const mentions = rows.filter((row) => row.includes("inspect the repo"));
1014
    expect(mentions).toHaveLength(1);
1015
  });
1016
1017
  it("falls back to the feed on a terminal too narrow for a column", async () => {
1018
    // A fleet with nowhere to go is worse than one read in the feed, and a
1019
    // column carved out of eighty would leave the transcript wrapping.
1020
    const rows = await driveDelegated((registry) => {
1021
      registerChild(registry);
1022
    }, 80);
1023
1024
    expect(rows.some((row) => row.includes("children"))).toBe(false);
1025
1026
    const delegateRow = rows.findIndex((row) => row.includes("delegate"));
1027
    expect(delegateRow).toBeGreaterThan(0);
1028
    expect(rows[delegateRow + 1] ?? "").toContain("inspect the repo");
1029
  });
1030
992 1031
  it("previews the child's latest activity one line per thing, three lines at most", async () => {
993 1032
    const rows = await driveDelegated((registry) => {
994 1033
      const id = registerChild(registry);

@@ -1001,9 +1040,8 @@ describe("the delegate preview inline under the tool call", () => {

1001 1040
      registry.recordToolUse(id, { toolName: "shell", target: "mix test" });
1002 1041
    });
1003 1042
1004
    const delegateRow = rows.findIndex((row) => row.includes("delegate"));
1005
    expect(delegateRow).toBeGreaterThan(0);
1006
    const childRow = delegateRow + 1;
1043
    const childRow = rows.findIndex((row) => row.includes("inspect the repo"));
1044
    expect(childRow).toBeGreaterThan(0);
1007 1045
    const previews = rows.slice(childRow + 1, childRow + 4);
1008 1046
    expect(previews).toHaveLength(3);
1009 1047
    const text = previews.join("\n");

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