Move the coder status line under the delegate rows and add a three-line activity preview

c9fedbec84a5 · AtlantisPleb · · parent 2a2be871da7f

Move the coder status line under the delegate rows and add a three-line activity preview

What the session is doing read as a caption on the wrong thing: the
status line sat under the composer while the work it described sat in
the fleet block above the transcript. The line now sits directly under
the delegate rows, where the reader watching a delegation is already
looking.

Under it there is a preview box framed like the rest of the chrome: one
row for each of the latest things the working children did. The box
scrolls by staying fixed — each new tool use pushes the older rows up
and the oldest falls off the top, so three lines are all it ever holds.
Children that finished drop out of it, because their outcome is
announced on the transcript and a box naming steps nothing is taking
anymore is not a preview.

The transcript funds both blocks, so a frame keeps its height whether
children run or not, and paint now erases rows a shorter frame gave up,
which the fleet's clamp on small terminals could already leave behind.

The preview draws in `coder --dev` as everywhere else: the dev flag
chooses the endpoint, never the interface.

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

Diff

4 files changed, +287 -38

packages/openagents-cli/src/coder-fleet.ts modified +37 -2

@@ -19,7 +19,7 @@

19 19
 * ragged text, and the transcript underneath it disappears.
20 20
 */
21 21
22
import type { CoderTask, CoderTaskStatus } from "./coder-tasks.js";
22
import type { CoderTask, CoderTaskStatus, CoderToolActivity } from "./coder-tasks.js";
23 23
import { isTerminal } from "./coder-tasks.js";
24 24
25 25
/** One rendered child. The caller decides how `status` is coloured. */

@@ -93,8 +93,43 @@ export function taskActivity(task: CoderTask, now: number = Date.now()): string

93 93
  const running = formatDuration(now - task.startedAt);
94 94
  const activity = task.progress.lastActivity;
95 95
  if (activity === undefined) return `Initializing… (${running})`;
96
  return `${activityPhrase(activity)} (${running})`;
97
}
98
99
/**
100
 * What an activity says on its own, with no clock and no status around it.
101
 *
102
 * The preview box wants the doing, not the duration: the duration belongs to
103
 * the fleet row, which already carries it, and repeating it one row down says
104
 * the same thing twice.
105
 */
106
export function activityPhrase(activity: CoderToolActivity): string {
96 107
  const target = activity.target === undefined ? "" : `(${collapse(activity.target)})`;
97
  return `${activity.toolName}${target} (${running})`;
108
  return `${activity.toolName}${target}`;
109
}
110
111
/**
112
 * The newest activities across a fleet, oldest first, at most `count` of them.
113
 *
114
 * Within a child, `recentActivities` is oldest to newest. Across children the
115
 * activities are appended in launch order, which is exact for the one-child
116
 * case a preview is usually watching and stable for a fan-out: no timestamp
117
 * exists on an activity to order by, so launch order is the only honest one.
118
 *
119
 * Terminal children are left out. Their last actions were their outcome's
120
 * business, and that outcome is announced on the transcript; keeping the lines
121
 * here would leave a preview of work nothing is doing anymore.
122
 */
123
export function latestActivities(
124
  tasks: ReadonlyArray<CoderTask>,
125
  count: number,
126
): ReadonlyArray<CoderToolActivity> {
127
  const out: CoderToolActivity[] = [];
128
  for (const task of tasks) {
129
    if (isTerminal(task.status)) continue;
130
    out.push(...task.progress.recentActivities);
131
  }
132
  return out.slice(Math.max(0, out.length - Math.max(0, count)));
98 133
}
99 134
100 135
/**
packages/openagents-cli/src/coder-ui.ts modified +76 -23

@@ -14,8 +14,9 @@

14 14
 *
15 15
 *     ┌──────────────────────────────┐
16 16
 *     │ transcript, scrollable       │
17
 *     ├──────────────────────────────┤
18
 *     │ status  repo · branch · model · budget│
17
 *     │ delegate rows                │
18
 *     │ status line                  │
19
 *     │ activity preview, three rows │
19 20
 *     ├──────────────────────────────┤
20 21
 *     │ composer                     │
21 22
 *     └──────────────────────────────┘

@@ -29,7 +30,7 @@

29 30
 * own job instead.
30 31
 */
31 32
32
import { fleetPhrase, fleetRows } from "./coder-fleet.js";
33
import { activityPhrase, fleetPhrase, fleetRows, latestActivities } from "./coder-fleet.js";
33 34
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
34 35
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";
35 36
import { RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";

@@ -86,6 +87,14 @@ const MAGENTA = "\x1b[35m";

86 87
const RED = "\x1b[31m";
87 88
88 89
const STATUS_ROWS = 1;
90
/**
91
 * The status line sits under the delegate rows rather than under the composer.
92
 *
93
 * What the session is doing reads as a caption on the thing it describes, and
94
 * when children are running that thing is the fleet block above the line, not
95
 * the composer below it. The row is still paid for out of the transcript's
96
 * height, so the composer never moves.
97
 */
89 98
const COMPOSER_ROWS = 3;
90 99
/**
91 100
 * One blank row between the transcript and the composer.

@@ -103,6 +112,15 @@ const SPACER_ROWS = 1;

103 112
 * ones that are still working and counts the rest.
104 113
 */
105 114
const FLEET_ROWS_MAX = 8;
115
/**
116
 * Activity lines the preview box holds.
117
 *
118
 * Fixed at three because a preview that grows with the run is a transcript in
119
 * miniature, and the transcript already exists. Newer activity pushes the
120
 * older rows up and out: the box always names the latest things the children
121
 * are doing and never more than three of them.
122
 */
123
const PREVIEW_ROWS = 3;
106 124
/** Width of the role gutter, so every entry's text starts in one column. */
107 125
/**
108 126
 * Width of the marker column.

@@ -503,6 +521,31 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

503 521
      return out;
504 522
    };
505 523
524
    /**
525
     * The preview box: what the working children last did, one line per thing.
526
     *
527
     * Drawn beside the fleet rather than inside it because a fleet row has one
528
     * line for everything a child is, and what a child did in its last three
529
     * steps does not fit in that line. The box scrolls by staying fixed: every
530
     * new activity pushes the rows up, and the oldest falls off the top.
531
     */
532
    const previewLines = (snapshot: CoderSnapshot, width: number): ReadonlyArray<string> => {
533
      const activities = latestActivities(snapshot.tasks, PREVIEW_ROWS);
534
      if (activities.length === 0) return [];
535
536
      // Aligned with the rules and the status line: two columns of gutter,
537
      // then a box whose right edge meets theirs.
538
      const body = Math.max(10, width - 6);
539
      const frame = `${DIM}╭${"─".repeat(body + 2)}╮${RESET}`;
540
      const floor = `${DIM}╰${"─".repeat(body + 2)}╯${RESET}`;
541
      const rows = activities.map((activity) => {
542
        const text = truncate(activityPhrase(activity), body);
543
        const pad = " ".repeat(Math.max(0, body - [...text].length));
544
        return `  ${DIM}│${RESET} ${text}${pad} ${DIM}│${RESET}`;
545
      });
546
      return [`  ${frame}`, ...rows, `  ${floor}`];
547
    };
548
506 549
    /** The newest tool call, which is the one ctrl+o expands. */
507 550
    const focusedTool = (snapshot: CoderSnapshot): string | undefined => {
508 551
      for (let index = snapshot.entries.length - 1; index >= 0; index -= 1) {

@@ -594,9 +637,13 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

594 637
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - SPACER_ROWS);
595 638
596 639
      const fleet = fleetLines(snapshot, width);
597
      // The fleet takes its rows from the transcript, not from the chrome: the
598
      // status line and composer stay where the reader's hands expect them.
599
      const transcriptRows = Math.max(1, transcriptHeight - fleet.length);
640
      const preview = previewLines(snapshot, width);
641
      // The fleet and the preview take their rows from the transcript, not
642
      // from the chrome: the composer stays where the reader's hands expect
643
      // it. The status line moved up beside them; its row is already priced
644
      // into `transcriptHeight`, which is what keeps every frame the same
645
      // height whether children are running or not.
646
      const transcriptRows = Math.max(1, transcriptHeight - fleet.length - preview.length);
600 647
601 648
      const lines = transcriptLines(snapshot, width);
602 649
      lineCount = lines.length;

@@ -612,10 +659,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

612 659
      for (let row = 0; row < transcriptRows; row += 1) rows.push(lines[start + row] ?? "");
613 660
      rows.push(...fleet);
614 661
615
      // Bottom chrome, in the order a reader scans it: where the typing goes,
616
      // what the session is doing, then what the keys do. The composer sits
617
      // between two rules so it reads as its own region rather than as the last
618
      // line of the transcript, and the status line sits under that region.
662
      // Bottom chrome, in the order a reader scans it. The status line sits
663
      // directly under the delegate rows now: what the session is doing is a
664
      // caption on the thing it describes, and the children are that thing
665
      // whenever any exist. The preview box follows it, then the composer in
666
      // its own region between two rules.
619 667
      const rule = `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`;
620 668
      const inner = Math.max(10, width - 4);
621 669

@@ -648,10 +696,12 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

648 696
        where = candidate;
649 697
        break;
650 698
      }
651
      // A blank row, then the composer, then what the session is doing. The
652
      // status line reads as a caption under the thing it describes: the reader
653
      // looks at where they type, and the state of the session is the next
654
      // thing down rather than something to scan back up for.
699
      rows.push(`  ${justify(activity, where, inner)}`);
700
      rows.push(...preview);
701
702
      // A blank row, then the composer. The keys used to live on a second row
703
      // under it; they are in `/help` now, which is where a reader looks for
704
      // them once rather than past them always.
655 705
      rows.push("");
656 706
      rows.push(rule);
657 707
      // The composer shows its tail, never more characters than the row holds.

@@ -676,16 +726,12 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

676 726
          : summarised;
677 727
      rows.push(`  › ${visible}`);
678 728
      rows.push(rule);
679
      // Under the composer's own region, not inside it. Between the rules is
680
      // where typing goes; what the session is doing is a caption on the box,
681
      // and a caption sits outside the thing it describes.
682
      rows.push(`  ${justify(activity, where, inner)}`);
683
684
      // One row of chrome under the composer, and it is the status line. The
685
      // keys used to live on a second row; they are in `/help` now, which is
686
      // where a reader looks for them once rather than past them always.
687 729
688
      paint(rows, transcriptRows + fleet.length + 3, 4 + [...visible].length + 1);
730
      paint(
731
        rows,
732
        transcriptRows + fleet.length + 1 + preview.length + 3,
733
        4 + [...visible].length + 1,
734
      );
689 735
    };
690 736
691 737
    /**

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

701 747
        if (painted[index] === next) continue;
702 748
        frame.push(`\x1b[${index + 1};1H`, ERASE_LINE, next);
703 749
      }
750
      // Rows the last frame had that this one does not. The preview box and
751
      // the fleet both come and go with the work, so a frame can be shorter
752
      // than the one before it, and whatever it left below would otherwise
753
      // stay on screen with nothing owning it.
754
      for (let index = rows.length; index < painted.length; index += 1) {
755
        frame.push(`\x1b[${index + 1};1H`, ERASE_LINE);
756
      }
704 757
      painted = [...rows];
705 758
      frame.push(`\x1b[${cursorRow};${cursorColumn}H`, CURSOR_SHOW);
706 759
      write(frame.join(""));
packages/openagents-cli/test/coder-delegate.test.ts modified +73 -1

@@ -8,7 +8,14 @@ import {

8 8
  parseDelegateCommand,
9 9
  parseOpencodeEvent,
10 10
} from "../src/coder-delegate.js";
11
import { fleetPhrase, fleetRows, formatTokens, taskActivity } from "../src/coder-fleet.js";
11
import {
12
  activityPhrase,
13
  fleetPhrase,
14
  fleetRows,
15
  formatTokens,
16
  latestActivities,
17
  taskActivity,
18
} from "../src/coder-fleet.js";
12 19
import { CoderSession, type ReplySource } from "../src/coder-session.js";
13 20
import { CoderTaskRegistry } from "../src/coder-tasks.js";
14 21
import { mkdtempSync } from "node:fs";

@@ -372,4 +379,69 @@ describe("fleet rendering", () => {

372 379
    const rows = fleetRows(registry.list(), 30);
373 380
    expect(rows.every((row) => [...row.text].length <= 30)).toBe(true);
374 381
  });
382
383
  it("says an activity on its own, with no clock around it", () => {
384
    expect(activityPhrase({ toolName: "read", target: "src/a.ts" })).toBe("read(src/a.ts)");
385
    expect(activityPhrase({ toolName: "think", target: undefined })).toBe("think");
386
  });
387
});
388
389
describe("latestActivities", () => {
390
  const startedRegistry = () => {
391
    const registry = new CoderTaskRegistry();
392
    const task = registry.register(
393
      {
394
        id: "d1",
395
        description: "inspect the repo",
396
        prompt: "look around",
397
        agent: "opencode",
398
        model: "fake/model",
399
        cwd: "/tmp",
400
        background: true,
401
      },
402
      0,
403
    );
404
    registry.start(task.id, new AbortController());
405
    return { registry, id: task.id };
406
  };
407
408
  it("returns nothing when no child has done anything", () => {
409
    expect(latestActivities([], 3)).toEqual([]);
410
411
    const { registry, id } = startedRegistry();
412
    expect(latestActivities(registry.list(), 3)).toEqual([]);
413
    registry.complete(id, "done", 10);
414
    expect(latestActivities(registry.list(), 3)).toEqual([]);
415
  });
416
417
  it("keeps the newest few, oldest first, across the fleet", () => {
418
    const { registry, id } = startedRegistry();
419
    for (const [name, target] of [
420
      ["read", "a.ts"],
421
      ["grep", "needle"],
422
      ["bash", "ls"],
423
      ["edit", "b.ts"],
424
    ] as const) {
425
      registry.recordToolUse(id, { toolName: name, target });
426
    }
427
428
    expect(latestActivities(registry.list(), 3)).toEqual([
429
      { toolName: "grep", target: "needle" },
430
      { toolName: "bash", target: "ls" },
431
      { toolName: "edit", target: "b.ts" },
432
    ]);
433
  });
434
435
  it("leaves a finished child's steps out of a live preview", () => {
436
    const first = startedRegistry();
437
    first.registry.recordToolUse(first.id, { toolName: "bash", target: "stale" });
438
    first.registry.complete(first.id, "done", 10);
439
440
    const second = startedRegistry();
441
    second.registry.recordToolUse(second.id, { toolName: "grep", target: "fresh" });
442
443
    expect(latestActivities([...first.registry.list(), ...second.registry.list()], 3)).toEqual([
444
      { toolName: "grep", target: "fresh" },
445
    ]);
446
  });
375 447
});
packages/openagents-cli/test/coder-ui.test.ts modified +101 -12

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";

3 3
4 4
import { CODER_BACKENDS } from "../src/coder-backends.js";
5 5
import { CoderSession, type ReplyChunk, type ReplySource } from "../src/coder-session.js";
6
import { CoderTaskRegistry } from "../src/coder-tasks.js";
6 7
import { RELOAD_EXIT_CODE } from "../src/coder-reload.js";
7 8
import { runCoderUi } from "../src/coder-ui.js";
8 9

@@ -170,14 +171,11 @@ describe("runCoderUi", () => {

170 171
    expect(painted).toContain("\x1b[2m\x1b[3mI should check first.\x1b[0m");
171 172
  });
172 173
173
174
175 174
  it("says nothing about scope when the source keeps its turns to itself", async () => {
176 175
    const { rows } = await drive([{ type: "text", value: "hello" }]);
177 176
    expect(rows.join("\n")).not.toContain("shared with");
178 177
  });
179 178
180
181 179
  describe("switching backend with tab", () => {
182 180
    const driveSwitchable = async (keys: ReadonlyArray<string>) => {
183 181
      const stdin = new FakeIn();

@@ -200,7 +198,6 @@ describe("runCoderUi", () => {

200 198
      return { painted, rows: screen(painted), session };
201 199
    };
202 200
203
204 201
    it("moves to the next backend and says so", async () => {
205 202
      const { session, rows } = await driveSwitchable(["\t"]);
206 203

@@ -261,7 +258,6 @@ describe("runCoderUi", () => {

261 258
    expect(status).toContain("$2.00");
262 259
  });
263 260
264
265 261
  it("keeps a long typed line inside the row, showing its tail", async () => {
266 262
    const stdin = new FakeIn();
267 263
    const stdout = new FakeOut();

@@ -864,14 +860,17 @@ describe("the transcript's marker column", () => {

864 860
});
865 861
866 862
describe("the chrome under the composer", () => {
867
  it("is one row, and it is the status line", async () => {
863
  it("closes the composer with its floor rule, and no longer carries the status line", async () => {
868 864
    const { rows } = await drive([{ type: "text", value: "answer" }]);
869
    const bottom = rows.slice(-2);
870
871
    // The keys used to have a row of their own under the status line. They are
872
    // in `/help` now, which is where a reader looks for them once rather than
873
    // past them always.
874
    expect(bottom.some((row) => row.includes("ready") || row.includes("working"))).toBe(true);
865
    const composerAt = rows.findIndex((row) => row.includes("›"));
866
    const statusAt = rows.findIndex((row) => row.includes("repo · main"));
867
868
    // The status line moved up beside the delegate rows; what is left under
869
    // the composer is its own region and nothing else.
870
    expect(composerAt).toBeGreaterThan(0);
871
    expect(statusAt).toBeGreaterThanOrEqual(0);
872
    expect(statusAt).toBeLessThan(composerAt);
873
    expect(rows.at(-1)).toMatch(/^─+$/);
875 874
    expect(rows.join("\n")).not.toContain("enter to send");
876 875
    expect(rows.join("\n")).not.toContain("ctrl+d to quit");
877 876
  });

@@ -907,3 +906,93 @@ describe("the chrome under the composer", () => {

907 906
    expect(rows.join("\n")).toContain("ready");
908 907
  });
909 908
});
909
910
describe("the status line under the delegate rows", () => {
911
  /** A session that can delegate, driven by writing to its registry by hand. */
912
  const driveDelegated = async (
913
    record: (registry: CoderTaskRegistry) => void,
914
  ): Promise<ReadonlyArray<string>> => {
915
    const stdin = new FakeIn();
916
    const stdout = new FakeOut();
917
    const registry = new CoderTaskRegistry();
918
    const session = new CoderSession(source([{ type: "text", value: "hi" }]), "repo", "main", {
919
      registry,
920
      fleet: {
921
        submit: (): Promise<never> => new Promise(() => {}),
922
      },
923
      label: "fake",
924
    });
925
    const running = runCoderUi(session, {
926
      stdin: stdin as unknown as NodeJS.ReadStream,
927
      stdout: stdout as unknown as NodeJS.WriteStream,
928
    });
929
930
    record(registry);
931
    const painted = stdout.written;
932
    stdin.emit("data", "\x04");
933
    await running;
934
    return screen(painted);
935
  };
936
937
  const registerChild = (registry: CoderTaskRegistry): string => {
938
    const task = registry.register({
939
      id: "d1",
940
      description: "inspect the repo",
941
      prompt: "look around",
942
      agent: "opencode",
943
      model: "fake/model",
944
      cwd: "/tmp",
945
      background: true,
946
    });
947
    registry.start(task.id, new AbortController());
948
    return task.id;
949
  };
950
951
  it("sits directly under the delegate row while a child runs", async () => {
952
    const rows = await driveDelegated((registry) => {
953
      registerChild(registry);
954
    });
955
956
    const delegateRow = rows.findIndex((row) => row.includes("inspect the repo"));
957
    expect(delegateRow).toBeGreaterThan(0);
958
    const status = rows[delegateRow + 1] ?? "";
959
    expect(status).toContain("1 agent");
960
    expect(status).toContain("repo · main");
961
  });
962
963
  it("previews the child's latest activity one line per thing, three lines at most", async () => {
964
    const rows = await driveDelegated((registry) => {
965
      const id = registerChild(registry);
966
      // Five activities against a box that holds three: the two oldest have
967
      // to fall off the top, which is what keeps the box a preview.
968
      registry.recordToolUse(id, { toolName: "read", target: "src/a.ts" });
969
      registry.recordToolUse(id, { toolName: "grep", target: "needle" });
970
      registry.recordToolUse(id, { toolName: "bash", target: "pnpm test" });
971
      registry.recordToolUse(id, { toolName: "edit", target: "src/b.ts" });
972
      registry.recordToolUse(id, { toolName: "shell", target: "mix test" });
973
    });
974
975
    const boxed = rows.filter((row) => /[╭╰]/.test(row) || row.includes(" │ "));
976
    expect(boxed).toHaveLength(5); // top border, three activity rows, floor.
977
    const text = boxed.join("\n");
978
    expect(text).toContain("shell(mix test)");
979
    expect(text).toContain("edit(src/b.ts)");
980
    expect(text).toContain("bash(pnpm test)");
981
    // Pushed out by the newer work.
982
    expect(text).not.toContain("read(src/a.ts)");
983
    expect(text).not.toContain("grep(needle)");
984
985
    // Newest last, so reading down is reading forward in time.
986
    const at = (phrase: string) => boxed.findIndex((row) => row.includes(phrase));
987
    expect(at("bash(pnpm test)")).toBeLessThan(at("edit(src/b.ts)"));
988
    expect(at("edit(src/b.ts)")).toBeLessThan(at("shell(mix test)"));
989
  });
990
991
  it("draws no preview until the child has done something", async () => {
992
    const rows = await driveDelegated((registry) => {
993
      registerChild(registry);
994
    });
995
996
    expect(rows.join("\n")).not.toContain("╭");
997
  });
998
});

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