Move the delegate activity preview inline under the tool call

d0c812f1dec1 · AtlantisPleb · · parent c9fedbec84a5

Move the delegate activity preview inline under the tool call

The three-line activity preview and the agent status line now live
inside the running delegate tool-call entry, directly under the
delegate line, instead of above the composer. The bottom status line
keeps the main agent and workspace facts only.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.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-ui.test.ts

Diff

2 files changed, +103 -88

packages/openagents-cli/src/coder-ui.ts modified +62 -78

@@ -10,14 +10,14 @@

10 10
 * Node, so the interface cannot require the Bun runtime. See `docs/2026-08-23-openagents-
11 11
 * coder-cli-spec.md` section 3 and its open questions.
12 12
 *
13
 * The layout follows that document:
13
 * The layout:
14 14
 *
15 15
 *     ┌──────────────────────────────┐
16 16
 *     │ transcript, scrollable       │
17
 *     │   delegate preview inline    │
17 18
 *     │ delegate rows                │
18
 *     │ status line                  │
19
 *     │ activity preview, three rows │
20 19
 *     ├──────────────────────────────┤
20
 *     │ status line                  │
21 21
 *     │ composer                     │
22 22
 *     └──────────────────────────────┘
23 23
 *

@@ -33,9 +33,9 @@

33 33
import { activityPhrase, fleetPhrase, fleetRows, latestActivities } from "./coder-fleet.js";
34 34
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
35 35
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";
36
import type { CoderTask, CoderTaskStatus } from "./coder-tasks.js";
36 37
import { RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
37 38
import type { SkillSelection } from "./coder-skills.js";
38
import type { CoderTaskStatus } from "./coder-tasks.js";
39 39
40 40
const ALT_SCREEN_ON = "\x1b[?1049h";
41 41
const ALT_SCREEN_OFF = "\x1b[?1049l";

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

401 401
      // reading as part of the sentence before it.
402 402
      for (const entry of snapshot.entries) {
403 403
        if (out.length > 0) out.push("");
404
        out.push(...renderEntry(entry, body));
404
        out.push(...renderEntry(entry, body, snapshot.tasks));
405 405
      }
406 406
      return out;
407 407
    };
408 408
409
    const renderEntry = (entry: CoderEntry, width: number): ReadonlyArray<string> => {
409
    const renderEntry = (entry: CoderEntry, width: number, tasks: ReadonlyArray<CoderTask>): ReadonlyArray<string> => {
410 410
      const color =
411 411
        entry.role === "you"
412 412
          ? CYAN

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

424 424
      const glyph = entry.settled ? "●" : pulse ? "●" : "○";
425 425
      const head = `  ${color}${glyph}${RESET} `;
426 426
      const continuation = " ".repeat(GUTTER);
427
      const rows = entryRows(entry, width);
427
      const rows = entryRows(entry, width, tasks);
428 428
      const caret = "";
429 429
430 430
      return rows.map((row, index) => {

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

433 433
      });
434 434
    };
435 435
436
    const entryRows = (entry: CoderEntry, width: number): ReadonlyArray<string> => {
436
    const entryRows = (entry: CoderEntry, width: number, tasks: ReadonlyArray<CoderTask>): ReadonlyArray<string> => {
437 437
      if (entry.role === "tool" && entry.tool !== undefined) {
438
        return toolRows(entry.tool, width, expanded.has(entry.tool.callId));
438
        return toolRows(entry.tool, width, expanded.has(entry.tool.callId), tasks);
439 439
      }
440 440
      if (entry.text.length === 0 && !entry.settled) return ["…"];
441 441
      // Reasoning is Markdown too, rendered inside dim italic. It was plain

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

450 450
      return wrapStyled(entry.text, width, entry.role === "notice" ? DIM : "");
451 451
    };
452 452
453
    const toolRows = (tool: CoderToolCall, width: number, open: boolean): ReadonlyArray<string> => {
453
    const toolRows = (tool: CoderToolCall, width: number, open: boolean, tasks: ReadonlyArray<CoderTask>): ReadonlyArray<string> => {
454 454
      const mark =
455 455
        tool.status === "running"
456 456
          ? `${YELLOW}◐${RESET}`

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

459 459
            : `${GREEN}✓${RESET}`;
460 460
      const rows = [`${mark} ${BOLD}${tool.name}${RESET}`];
461 461
462
      if (!open) {
462
      if (tool.name === "delegate" && tool.status === "running") {
463
        const phrase = fleetPhrase(tasks) ?? "starting children…";
464
        rows.push(`${DIM}→ ${phrase}${RESET}`);
465
        const activities = latestActivities(tasks, PREVIEW_ROWS);
466
        if (activities.length > 0) {
467
          const boxWidth = Math.max(10, width - 4);
468
          const frame = `${DIM}╭${"─".repeat(boxWidth + 2)}╮${RESET}`;
469
          const floor = `${DIM}╰${"─".repeat(boxWidth + 2)}╯${RESET}`;
470
          const lines = activities.map((activity) => {
471
            const text = truncate(activityPhrase(activity), boxWidth);
472
            const pad = " ".repeat(Math.max(0, boxWidth - [...text].length));
473
            return `${DIM}│${RESET} ${text}${pad} ${DIM}│${RESET}`;
474
          });
475
          rows.push(frame, ...lines, floor);
476
        }
477
      }
478
479
      if (open) {
480
        for (const line of tool.arguments.split("\n")) {
481
          rows.push(`${DIM}${truncate(line, width)}${RESET}`);
482
        }
483
        if (tool.error !== undefined) {
484
          rows.push(...wrapStyled(tool.error, width, RED));
485
        } else if (tool.output !== undefined) {
486
          // The arrow separates the call from its result, which otherwise read
487
          // as one JSON document split over a blank line.
488
          const lines = tool.output.split("\n");
489
          for (const [index, line] of lines.entries()) {
490
            const marker = index === 0 ? `${DIM}→${RESET} ` : "  ";
491
            rows.push(`${marker}${DIM}${truncate(line, Math.max(4, width - 2))}${RESET}`);
492
          }
493
        }
494
        return rows;
495
      }
496
497
      if (tool.name !== "delegate" || tool.status !== "running") {
463 498
        const args = clip(tool.arguments, Math.max(8, width - 4));
464 499
        if (args.length > 0) rows.push(`${DIM}${args}${RESET}`);
465 500
        const outcome =

@@ -467,26 +502,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

467 502
            ? `${RED}${clip(tool.error, Math.max(8, width - 4))}${RESET}`
468 503
            : tool.output !== undefined
469 504
              ? `${DIM}→ ${clip(tool.output, Math.max(8, width - 6))}${RESET}`
470
              : tool.status === "running"
505
              : tool.status === "running" && tool.name !== "delegate"
471 506
                ? `${DIM}→ running…${RESET}`
472 507
                : "";
473 508
        if (outcome.length > 0) rows.push(outcome);
474
        return rows;
475
      }
476
477
      for (const line of tool.arguments.split("\n")) {
478
        rows.push(`${DIM}${truncate(line, width)}${RESET}`);
479
      }
480
      if (tool.error !== undefined) {
481
        rows.push(...wrapStyled(tool.error, width, RED));
482
      } else if (tool.output !== undefined) {
483
        // The arrow separates the call from its result, which otherwise read
484
        // as one JSON document split over a blank line.
485
        const lines = tool.output.split("\n");
486
        for (const [index, line] of lines.entries()) {
487
          const marker = index === 0 ? `${DIM}→${RESET} ` : "  ";
488
          rows.push(`${marker}${DIM}${truncate(line, Math.max(4, width - 2))}${RESET}`);
489
        }
490 509
      }
491 510
      return rows;
492 511
    };

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

521 540
      return out;
522 541
    };
523 542
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
549 543
    /** The newest tool call, which is the one ctrl+o expands. */
550 544
    const focusedTool = (snapshot: CoderSnapshot): string | undefined => {
551 545
      for (let index = snapshot.entries.length - 1; index >= 0; index -= 1) {

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

637 631
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - SPACER_ROWS);
638 632
639 633
      const fleet = fleetLines(snapshot, width);
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);
634
      // The fleet takes its rows from the transcript, not from the chrome:
635
      // the composer stays where the reader's hands expect it. The status
636
      // line is priced into `transcriptHeight`, which keeps every frame the
637
      // same height whether children are running or not.
638
      const transcriptRows = Math.max(1, transcriptHeight - fleet.length);
647 639
648 640
      const lines = transcriptLines(snapshot, width);
649 641
      lineCount = lines.length;

@@ -659,15 +651,13 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

659 651
      for (let row = 0; row < transcriptRows; row += 1) rows.push(lines[start + row] ?? "");
660 652
      rows.push(...fleet);
661 653
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.
654
      // Bottom chrome, in the order a reader scans it. The status line is the
655
      // main agent's state; the delegate preview lives inline under the
656
      // delegate tool call. The composer follows in its own region between two
657
      // rules.
667 658
      const rule = `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`;
668 659
      const inner = Math.max(10, width - 4);
669 660
670
      const phrase = fleetPhrase(snapshot.tasks);
671 661
      // The elapsed time and nothing else. This said `streaming` until the
672 662
      // reply source became the inference proxy, which builds the whole body
673 663
      // and sends it once: a turn that shows one block after four silent

@@ -675,13 +665,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

675 665
      const chatActivity = snapshot.running
676 666
        ? `${YELLOW}●${RESET} working… ${DIM}(${elapsed(runningSince, Date.now())})${RESET}`
677 667
        : `${DIM}○ ready${RESET}`;
678
      // The fleet is named on the status line even though the block above lists
679
      // it, because the block is what gives way first on a short terminal and
680
      // the count is the part the reader is waiting on.
681 668
      const scrolled = anchor === undefined ? "" : `${DIM} · scrolled ↑${String(above)}${RESET}`;
682
      const activity =
683
        (phrase === undefined ? chatActivity : `${chatActivity} ${DIM}· ${phrase}${RESET}`) +
684
        scrolled;
669
      const activity = chatActivity + scrolled;
685 670
      // Dropped from the left as the terminal narrows, because that is the
686 671
      // order of what a reader cannot recover elsewhere: they can see which
687 672
      // checkout they are in, they can ask git for the branch, and nothing on

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

697 682
        break;
698 683
      }
699 684
      rows.push(`  ${justify(activity, where, inner)}`);
700
      rows.push(...preview);
701 685
702 686
      // A blank row, then the composer. The keys used to live on a second row
703 687
      // under it; they are in `/help` now, which is where a reader looks for

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

729 713
730 714
      paint(
731 715
        rows,
732
        transcriptRows + fleet.length + 1 + preview.length + 3,
716
        transcriptRows + fleet.length + 4,
733 717
        4 + [...visible].length + 1,
734 718
      );
735 719
    };

@@ -747,10 +731,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

747 731
        if (painted[index] === next) continue;
748 732
        frame.push(`\x1b[${index + 1};1H`, ERASE_LINE, next);
749 733
      }
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.
734
      // Rows the last frame had that this one does not. The fleet comes and
735
      // goes with the work, so a frame can be shorter than the one before it,
736
      // and whatever it left below would otherwise stay on screen with nothing
737
      // owning it.
754 738
      for (let index = rows.length; index < painted.length; index += 1) {
755 739
        frame.push(`\x1b[${index + 1};1H`, ERASE_LINE);
756 740
      }
packages/openagents-cli/test/coder-ui.test.ts modified +41 -10

@@ -907,30 +907,58 @@ describe("the chrome under the composer", () => {

907 907
  });
908 908
});
909 909
910
describe("the status line under the delegate rows", () => {
911
  /** A session that can delegate, driven by writing to its registry by hand. */
910
describe("the delegate preview inline under the tool call", () => {
911
  /** A session with a running delegate tool call, for testing the inline preview. */
912 912
  const driveDelegated = async (
913 913
    record: (registry: CoderTaskRegistry) => void,
914 914
  ): Promise<ReadonlyArray<string>> => {
915 915
    const stdin = new FakeIn();
916 916
    const stdout = new FakeOut();
917 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(() => {}),
918
    const session = new CoderSession(
919
      {
920
        model: "scripted",
921
        async *reply(_prompt: string, signal: AbortSignal) {
922
          yield {
923
            type: "tool_call",
924
            callId: "c1",
925
            name: "delegate",
926
            arguments: JSON.stringify({
927
              prompt: "look around",
928
              count: 1,
929
              description: "inspect the repo",
930
            }),
931
          };
932
          await new Promise<void>((resolve) => {
933
            if (signal.aborted) return resolve();
934
            signal.addEventListener("abort", () => resolve(), { once: true });
935
          });
936
        },
922 937
      },
923
      label: "fake",
924
    });
938
      "repo",
939
      "main",
940
      {
941
        registry,
942
        fleet: {
943
          submit: (): Promise<never> => new Promise(() => {}),
944
        },
945
        label: "fake",
946
      },
947
    );
925 948
    const running = runCoderUi(session, {
926 949
      stdin: stdin as unknown as NodeJS.ReadStream,
927 950
      stdout: stdout as unknown as NodeJS.WriteStream,
928 951
    });
929 952
953
    const turn = session.submit("go");
954
    await new Promise((resolve) => setTimeout(resolve, 0));
930 955
    record(registry);
956
    await new Promise((resolve) => setTimeout(resolve, 0));
931 957
    const painted = stdout.written;
932 958
    stdin.emit("data", "\x04");
933 959
    await running;
960
    session.interrupt();
961
    await turn;
934 962
    return screen(painted);
935 963
  };
936 964

@@ -953,11 +981,14 @@ describe("the status line under the delegate rows", () => {

953 981
      registerChild(registry);
954 982
    });
955 983
956
    const delegateRow = rows.findIndex((row) => row.includes("inspect the repo"));
984
    const delegateRow = rows.findIndex((row) => row.includes("delegate"));
957 985
    expect(delegateRow).toBeGreaterThan(0);
958 986
    const status = rows[delegateRow + 1] ?? "";
959 987
    expect(status).toContain("1 agent");
960
    expect(status).toContain("repo · main");
988
    // The session status lives at the bottom and does not repeat the fleet.
989
    const bottom = rows.filter((row) => row.includes("repo · main")).at(-1) ?? "";
990
    expect(bottom).toContain("repo · main");
991
    expect(bottom).not.toContain("1 agent");
961 992
  });
962 993
963 994
  it("previews the child's latest activity one line per thing, three lines at most", async () => {

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