Align coder messages and tool calls with Claude Code UI conventions

0f35076aee11 · AtlantisPleb · · parent 19098b54939e

Align coder messages and tool calls with Claude Code UI conventions

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

Diff

4 files changed, +51 -42

packages/openagents-cli/src/coder-tool-summary.ts modified +22 -16

@@ -1,26 +1,25 @@

1 1
/**
2 2
 * A tool call in one line, for the row that names it.
3 3
 *
4
 * A collapsed call took three rows: the tool's name, its arguments as raw
5
 * JSON, and its result. The middle row is the one nobody reads as JSON —
6
 * `{"args":["issue","view","212","-R","OpenAgentsInc/openagents.com"]}` is a
7
 * command line wearing a costume, and eight of them in a row is a screen of
8
 * punctuation with the answers pushed off the bottom.
9
 *
10
 * So the call joins the row that names it, in the shape a person would have
11
 * typed: `openagents issue view 212 -R OpenAgentsInc/openagents.com`. The raw
12
 * arguments are still there under `ctrl+o`, which is what expanding a call is
13
 * for.
14
 *
15
 * Nothing here invents a summary it cannot make. A tool whose arguments have no
16
 * obvious subject falls back to the JSON, clipped by the caller, which is what
17
 * the row showed before.
4
 * Formats tool calls and userFacingNames closely mirroring Claude Code's
5
 * UI conventions (e.g. `Bash(command)`, `Read(file_path)`, `Edit(file_path)`, `delegate(description)`).
18 6
 */
19 7
20 8
/** Fields worth showing whole, in the order a tool would mean them. */
21
const SUBJECTS = ["command", "path", "file", "pattern", "query", "name"] as const;
9
const SUBJECTS = ["command", "path", "file_path", "file", "pattern", "query", "name", "description"] as const;
22 10
23
export const summarizeToolCall = (args: string): string => {
11
/**
12
 * Format a tool call with CC-aligned semantics:
13
 * If toolName is provided, produces `ToolName(summary)` or `ToolName` if empty.
14
 * If called with 1 argument for backwards compatibility, produces `summary`.
15
 */
16
export function formatToolUseHeader(toolName: string, args: string): string {
17
  const summary = summarizeToolCall(args, toolName);
18
  if (!summary) return toolName;
19
  return `${toolName}(${summary})`;
20
}
21
22
export const summarizeToolCall = (args: string, toolName?: string): string => {
24 23
  const trimmed = args.trim();
25 24
  if (trimmed.length === 0) return "";
26 25

@@ -36,6 +35,13 @@ export const summarizeToolCall = (args: string): string => {

36 35
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return trimmed;
37 36
  const record = parsed as Record<string, unknown>;
38 37
38
  // Special case: openagents / delegate / bash / file tools
39
  if (toolName === "delegate" || record["prompt"] !== undefined) {
40
    if (typeof record["description"] === "string" && record["description"].length > 0) {
41
      return record["description"];
42
    }
43
  }
44
39 45
  // An argument vector, which is the case this exists for: the `openagents`
40 46
  // tool takes the command line as a list, and joining it back is the whole
41 47
  // translation.
packages/openagents-cli/src/coder-ui.ts modified +10 -16

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

617 617
                ? DIM
618 618
                : YELLOW;
619 619
620
      // A reply still arriving pulses; a finished one is solid. The dot is the
621
      // only thing in this column because it is the only thing the colour and
622
      // the styling do not already say.
623
      const glyph = entry.settled ? "●" : pulse ? "●" : "○";
620
      // Follow Claude Code message markers: ⏺ for settled assistant/user turns, pulse ⏺/○ when streaming.
621
      const glyph = entry.settled ? "⏺" : pulse ? "⏺" : "○";
624 622
      const head = `  ${color}${glyph}${RESET} `;
625 623
      const continuation = " ".repeat(GUTTER);
626 624
      const rows = entryRows(entry, width, tasks);

@@ -671,17 +669,13 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

671 669
          : tool.status === "failed"
672 670
            ? `${RED}✗${RESET}`
673 671
            : `${GREEN}✓${RESET}`;
674
      // The call on the row that names it, in the shape a person would have
675
      // typed. It used to sit below as raw JSON, which cost a row per call and
676
      // read as punctuation; `ctrl+o` still shows the arguments whole.
672
      // Format tool header following Claude Code UI conventions: Tool(summary)
677 673
      const summary = clip(
678
        summarizeToolCall(tool.arguments),
679
        Math.max(8, width - tool.name.length - 4),
674
        summarizeToolCall(tool.arguments, tool.name),
675
        Math.max(8, width - tool.name.length - 6),
680 676
      );
681
      const rows = [
682
        `${mark} ${BOLD}${tool.name}${RESET}` +
683
          (summary.length === 0 ? "" : ` ${DIM}${summary}${RESET}`),
684
      ];
677
      const headerText = summary.length === 0 ? tool.name : `${tool.name}(${summary})`;
678
      const rows = [`${mark} ${BOLD}${headerText}${RESET}`];
685 679
686 680
      if (tool.name === "delegate" && tool.status === "running") {
687 681
        // Working children first when there are more than fit: a finished child

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

727 721
          // as one JSON document split over a blank line.
728 722
          const lines = tool.output.split("\n");
729 723
          for (const [index, line] of lines.entries()) {
730
            const marker = index === 0 ? `${DIM}→${RESET} ` : "  ";
724
            const marker = index === 0 ? `${DIM}⎿ ${RESET}` : "  ";
731 725
            rows.push(`${marker}${DIM}${truncate(line, Math.max(4, width - 2))}${RESET}`);
732 726
          }
733 727
        }

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

739 733
          tool.error !== undefined
740 734
            ? `${RED}${clip(tool.error, Math.max(8, width - 4))}${RESET}`
741 735
            : tool.output !== undefined
742
              ? `${DIM}→ ${clip(tool.output, Math.max(8, width - 6))}${RESET}`
736
              ? `${DIM}⎿ ${clip(tool.output, Math.max(8, width - 6))}${RESET}`
743 737
              : tool.status === "running" && tool.name !== "delegate"
744
                ? `${DIM}→ running…${RESET}`
738
                ? `${DIM}⎿ running…${RESET}`
745 739
                : "";
746 740
        if (outcome.length > 0) rows.push(outcome);
747 741
      }
packages/openagents-cli/test/coder-tool-summary.test.ts modified +13 -4

@@ -1,6 +1,6 @@

1 1
import { describe, expect, it } from "vitest";
2 2
3
import { summarizeToolCall } from "../src/coder-tool-summary.js";
3
import { formatToolUseHeader, summarizeToolCall } from "../src/coder-tool-summary.js";
4 4
5 5
describe("a tool call in one line", () => {
6 6
  it("turns an argument vector back into the command line it was", () => {

@@ -11,6 +11,17 @@ describe("a tool call in one line", () => {

11 11
    ).toBe("issue view 212 -R OpenAgentsInc/openagents.com");
12 12
  });
13 13
14
  it("formats tool headers in Claude Code style", () => {
15
    expect(
16
      formatToolUseHeader(
17
        "openagents",
18
        `{"args":["issue","view","212","-R","OpenAgentsInc/openagents.com"]}`,
19
      ),
20
    ).toBe("openagents(issue view 212 -R OpenAgentsInc/openagents.com)");
21
    expect(formatToolUseHeader("shell", `{"command":"git status"}`)).toBe("shell(git status)");
22
    expect(formatToolUseHeader("read", `{"file_path":"src/main.ts"}`)).toBe("read(src/main.ts)");
23
  });
24
14 25
  it("quotes an argument with a space, so it still reads as one", () => {
15 26
    expect(summarizeToolCall(`{"args":["issue","create","--title","two words"]}`)).toBe(
16 27
      `issue create --title "two words"`,

@@ -34,10 +45,8 @@ describe("a tool call in one line", () => {

34 45
  });
35 46
36 47
  it("drops the punctuation when nothing is named", () => {
37
    // `delegate` has no single subject, and its fields are still what a reader
38
    // wants: how many children, and what they were asked for.
39 48
    expect(summarizeToolCall(`{"count":3,"description":"audit the providers"}`)).toBe(
40
      `count=3 description="audit the providers"`,
49
      "audit the providers",
41 50
    );
42 51
  });
43 52
packages/openagents-cli/test/coder-ui.test.ts modified +6 -6

@@ -161,9 +161,9 @@ describe("runCoderUi", () => {

161 161
    stdin.emit("data", "\x04");
162 162
    await running;
163 163
164
    const bullets = rows.filter((row) => row.trimStart().startsWith("\u25cf"));
164
    const bullets = rows.filter((row) => row.trimStart().startsWith("⏺"));
165 165
    expect(bullets.length).toBeGreaterThan(0);
166
    expect(bullets.every((row) => row.replace("\u25cf", "").trim().length > 0)).toBe(true);
166
    expect(bullets.every((row) => row.replace("⏺", "").trim().length > 0)).toBe(true);
167 167
  });
168 168
169 169
  it("shows the tool, its call, and its outcome, on two rows", async () => {

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

176 176
    // which cost a row per call and read as punctuation rather than as the
177 177
    // command it is.
178 178
    const named = rows.find((row) => row.includes("repo_grep")) ?? "";
179
    expect(named).toContain("repo_grep x");
179
    expect(named).toContain("repo_grep(x)");
180 180
    expect(named).not.toContain('{"pattern"');
181 181
182 182
    expect(rows.join("\n")).toContain('{"matches":[]}');

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

194 194
    ]);
195 195
196 196
    const named = rows.find((row) => row.includes("openagents")) ?? "";
197
    expect(named).toContain("openagents issue view 212 -R OpenAgentsInc/openagents.com");
197
    expect(named).toContain("openagents(issue view 212 -R OpenAgentsInc/openagents.com)");
198 198
  });
199 199
200 200
  it("renders assistant Markdown rather than its source", async () => {

@@ -858,7 +858,7 @@ describe("the transcript's marker column", () => {

858 858
859 859
    // Five words of chrome per turn — `you`, `think`, `coder`, `note`, `tool` —
860 860
    // said what the styling already said.
861
    expect(painted).toContain("● an answer");
861
    expect(painted).toContain("⏺ an answer");
862 862
    expect(painted).not.toMatch(/\bcoder\s+an answer/);
863 863
    expect(painted).not.toMatch(/^\s*you\s/m);
864 864
  });

@@ -895,7 +895,7 @@ describe("the transcript's marker column", () => {

895 895
    stdin.emit("data", "\x04");
896 896
    await running;
897 897
898
    expect(stdout.written).toContain("●");
898
    expect(stdout.written).toContain("⏺");
899 899
  });
900 900
901 901
  it("keeps the scroll marker in the same voice as the rest of the bar", async () => {

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