feat(coder): display formatted working directory and branch with icon in bottom left status bar

6f8b64e1f100 · AtlantisPleb · · parent e46dc4cbdbdb

feat(coder): display formatted working directory and branch with icon in bottom left status bar

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, +62 -29

packages/openagents-cli/src/coder-ui.ts modified +57 -24

@@ -1,3 +1,4 @@

1
import { homedir } from "node:os";
1 2
/**
2 3
 * The full-screen interface for `openagents coder`.
3 4
 *

@@ -952,34 +953,66 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

952 953
      const rule = `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`;
953 954
      const inner = Math.max(10, width - 4);
954 955
955
      // The elapsed time and nothing else. This said `streaming` until the
956
      // reply source became the inference proxy, which builds the whole body
957
      // and sends it once: a turn that shows one block after four silent
958
      // seconds was never streaming, and the status line must not say it was.
959
      const chatActivity = snapshot.running
960
        ? `${YELLOW}●${RESET} working… ${DIM}(${elapsed(runningSince, Date.now())})${RESET}`
961
        : `${DIM}○ ready${RESET}`;
962
      const scrolled = anchor === undefined ? "" : `${DIM} · scrolled ↑${String(above)}${RESET}`;
963
      const activity = chatActivity + scrolled;
964
      // Dropped from the left as the terminal narrows, because that is the
965
      // order of what a reader cannot recover elsewhere: they can see which
966
      // checkout they are in, they can ask git for the branch, and nothing on
967
      // screen but this says which model answers or what the thread has left.
968
      const facts = [snapshot.repository, snapshot.branch, snapshot.model];
969
      if (snapshot.reasoning !== undefined) facts.push(`thinking ${snapshot.reasoning}`);
970
      if (snapshot.budget !== undefined) facts.push(snapshot.budget);
956
      // Status line left side:
957
      // - Directory (replacing home directory with ~)
958
      // - Branch (with branch icon if present and not "no branch")
959
      // - Active state / scrolled indicator (no "ready" indicator when idle)
960
      const home = homedir();
961
      const formattedRepo =
962
        snapshot.repository === home
963
          ? "~"
964
          : snapshot.repository.startsWith(`${home}/`)
965
            ? `~${snapshot.repository.slice(home.length)}`
966
            : snapshot.repository;
967
      const formattedBranch =
968
        snapshot.branch === "no branch" || snapshot.branch.length === 0
969
          ? snapshot.branch
970
          : `⎇ ${snapshot.branch}`;
971
972
      const leftParts: string[] = [];
973
      if (formattedRepo.length > 0) leftParts.push(formattedRepo);
974
      if (formattedBranch.length > 0) leftParts.push(formattedBranch);
975
      if (snapshot.running) {
976
        leftParts.push(`${YELLOW}●${RESET} ${DIM}working… (${elapsed(runningSince, Date.now())})${RESET}`);
977
      }
978
      if (anchor !== undefined) {
979
        leftParts.push(`${DIM}scrolled ↑${String(above)}${RESET}`);
980
      }
981
982
      // Status line right side:
983
      // Model, reasoning, budget, goal.
984
      const rightParts = [snapshot.model];
985
      if (snapshot.reasoning !== undefined) rightParts.push(`thinking ${snapshot.reasoning}`);
986
      if (snapshot.budget !== undefined) rightParts.push(snapshot.budget);
971 987
      if (snapshot.goal !== undefined && snapshot.goal.status === "active") {
972 988
        const goalSnippet = snapshot.goal.objective.length > 25
973 989
          ? snapshot.goal.objective.slice(0, 22) + "…"
974 990
          : snapshot.goal.objective;
975
        facts.push(`goal: "${goalSnippet}"`);
991
        rightParts.push(`goal: "${goalSnippet}"`);
976 992
      }
977
      let where = "";
978
      for (let from = 0; from < facts.length; from += 1) {
979
        const candidate = `${DIM}${facts.slice(from).join(" · ")}${RESET}`;
980
        if (visibleWidth(activity) + visibleWidth(candidate) + 2 > inner) continue;
981
        where = candidate;
982
        break;
993
994
      let chosenLeft = "";
995
      let chosenRight = "";
996
      for (let leftFrom = 0; leftFrom <= leftParts.length; leftFrom += 1) {
997
        const leftCandidate =
998
          leftParts.slice(leftFrom).length > 0
999
            ? `${DIM}${leftParts.slice(leftFrom).join(" · ")}${RESET}`
1000
            : "";
1001
        let matched = false;
1002
        for (let rightFrom = 0; rightFrom < rightParts.length; rightFrom += 1) {
1003
          const rightCandidate = `${DIM}${rightParts.slice(rightFrom).join(" · ")}${RESET}`;
1004
          const used =
1005
            visibleWidth(leftCandidate) +
1006
            visibleWidth(rightCandidate) +
1007
            (leftCandidate.length > 0 && rightCandidate.length > 0 ? 2 : 0);
1008
          if (used <= inner) {
1009
            chosenLeft = leftCandidate;
1010
            chosenRight = rightCandidate;
1011
            matched = true;
1012
            break;
1013
          }
1014
        }
1015
        if (matched) break;
983 1016
      }
984 1017
      // A blank row, then the composer. The keys used to live on a second row
985 1018
      // under it; they are in `/help` now, which is where a reader looks for

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

1012 1045
      // The status line sits under the composer. Main agent state, workspace,
1013 1046
      // model, and budget stay here so the transcript and any running work are
1014 1047
      // read first and the bottom chrome is the last thing the eye reaches.
1015
      rows.push(`  ${justify(activity, where, inner)}`);
1048
      rows.push(`  ${justify(chosenLeft, chosenRight, inner)}`);
1016 1049
1017 1050
      if (focus === "sidebar") {
1018 1051
        // On the selected child, because a cursor left blinking in a composer
packages/openagents-cli/test/coder-ui.test.ts modified +5 -5

@@ -245,7 +245,7 @@ describe("runCoderUi", () => {

245 245
    Object.defineProperty(metered, "budget", { get: () => "255 calls · 1.0M tok · $2.00" });
246 246
247 247
    const { rows } = await drive([{ type: "text", value: "hi" }], "go", metered);
248
    const status = rows.find((row) => row.includes("repo · main"));
248
    const status = rows.find((row) => row.includes("repo · ⎇ main"));
249 249
250 250
    expect(status).toContain("scripted · 255 calls · 1.0M tok · $2.00");
251 251
  });

@@ -894,7 +894,7 @@ describe("the chrome under the composer", () => {

894 894
    // The keys used to have a row of their own under the status line. They are
895 895
    // in `/help` now, which is where a reader looks for them once rather than
896 896
    // past them always.
897
    expect(bottom.some((row) => row.includes("ready") || row.includes("working"))).toBe(true);
897
    expect(bottom.some((row) => row.includes("repo · ⎇ main") || row.includes("working"))).toBe(true);
898 898
    expect(rows.join("\n")).not.toContain("enter to send");
899 899
    expect(rows.join("\n")).not.toContain("ctrl+d to quit");
900 900
  });

@@ -927,7 +927,7 @@ describe("the chrome under the composer", () => {

927 927
928 928
    // Losing the second row lost the scroll indicator with it, and a still
929 929
    // transcript with nothing saying why reads as a stopped session.
930
    expect(rows.join("\n")).toContain("ready");
930
    expect(rows.join("\n")).toContain("repo · ⎇ main");
931 931
  });
932 932
});
933 933

@@ -1020,8 +1020,8 @@ describe("where a running child is shown", () => {

1020 1020
    expect(rows[at + 1] ?? "").toContain("Initializing");
1021 1021
1022 1022
    // The session status lives at the bottom and does not repeat the fleet.
1023
    const bottom = rows.filter((row) => row.includes("repo · main")).at(-1) ?? "";
1024
    expect(bottom).toContain("repo · main");
1023
    const bottom = rows.filter((row) => row.includes("repo · ⎇ main")).at(-1) ?? "";
1024
    expect(bottom).toContain("repo · ⎇ main");
1025 1025
    expect(bottom).not.toContain("1 agent");
1026 1026
  });
1027 1027

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