From b5e538d03725478dde9a114718adb9c1dc5e20b4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 24 Oct 2026 20:32:27 -0700 Subject: [PATCH 1/3] fix: clip a shell command to one line in a step title A step with no label was using the whole shell command as its title. Keep the first line, and stop at 80 characters. --- src/ui/observability/wording.test.ts | 53 ++++++++++++++++++++++++++++ src/ui/observability/wording.ts | 12 ++++++- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/ui/observability/wording.test.ts diff --git a/src/ui/observability/wording.test.ts b/src/ui/observability/wording.test.ts new file mode 100644 index 00000000..723636ed --- /dev/null +++ b/src/ui/observability/wording.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; + +import type { Span } from "./model.js"; +import { stepTitle } from "./wording.js"; + +function toolSpan(input: string, label?: string): Span { + const span: Span = { + id: "span-1", + parentId: null, + kind: "tool", + name: "Bash", + status: "ok", + startMs: 0, + durationMs: 1, + input, + }; + if (label === undefined) return span; + return { ...span, label }; +} + +describe("stepTitle", () => { + test("a label wins over the command", () => { + const input = JSON.stringify({ command: "ls -la" }); + expect(stepTitle(toolSpan(input, "List files"))).toBe("List files"); + }); + + test("a multiline command keeps the first line", () => { + const command = "git status\ngit diff"; + const input = JSON.stringify({ command }); + expect(stepTitle(toolSpan(input))).toBe("git status"); + expect(JSON.parse(input).command).toBe(command); + }); + + test("a command of 80 characters stays whole", () => { + const command = "b".repeat(80); + const input = JSON.stringify({ cmd: command }); + expect(stepTitle(toolSpan(input))).toBe(command); + expect(JSON.parse(input).cmd).toBe(command); + }); + + test("a blank command falls through to the step name", () => { + expect(stepTitle(toolSpan(JSON.stringify({ command: " \nls" })))).toBe( + "Bash", + ); + }); + + test("a longer command is clipped", () => { + const command = ` ${"a".repeat(90)} \nrest`; + const input = JSON.stringify({ command }); + expect(stepTitle(toolSpan(input))).toBe(`${"a".repeat(77)}…`); + expect(JSON.parse(input).command).toBe(command); + }); +}); diff --git a/src/ui/observability/wording.ts b/src/ui/observability/wording.ts index c0db62cb..a407d893 100644 --- a/src/ui/observability/wording.ts +++ b/src/ui/observability/wording.ts @@ -30,13 +30,23 @@ export function compactionLine(compaction: Compaction): string { return `${formatTokens(compaction.tokensBefore)} down to ${formatTokens(compaction.tokensAfter)}`; } +function clipLine(value: string): string { + const first = value.split("\n")[0] ?? value; + const trimmed = first.trim(); + if (trimmed.length <= 80) return trimmed; + return `${trimmed.slice(0, 77)}…`; +} + function whatItDid(span: Span): string | undefined { const hop = span.network?.[0]; if (hop !== undefined) return `${hop.method} ${shortUrl(hop.url)}`; const record = recordOf(span.input); if (record === undefined) return undefined; const command = text(record, "command") ?? text(record, "cmd"); - if (command !== undefined) return command; + if (command !== undefined) { + const clipped = clipLine(command); + if (clipped.length > 0) return clipped; + } const pattern = text(record, "pattern"); const path = text(record, "path") ?? text(record, "file"); if (pattern !== undefined && !span.name.includes(pattern)) { From 78deb3da38b2b36e079c080561ea143a881c7b9c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 26 Oct 2026 23:04:16 -0700 Subject: [PATCH 2/3] fix: clip step titles by code point Count command and reasoning titles in code points so a cut inside an emoji does not leave a lone surrogate. Character limits stay the same. --- src/ui/observability/wording.test.ts | 48 +++++++++++++++++++++ src/ui/observability/wording.ts | 12 ++++-- stories/observability/views/run.stories.tsx | 22 ++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/ui/observability/wording.test.ts b/src/ui/observability/wording.test.ts index 723636ed..04e53190 100644 --- a/src/ui/observability/wording.test.ts +++ b/src/ui/observability/wording.test.ts @@ -50,4 +50,52 @@ describe("stepTitle", () => { expect(stepTitle(toolSpan(input))).toBe(`${"a".repeat(77)}…`); expect(JSON.parse(input).command).toBe(command); }); + + test("a command cut inside an emoji keeps the code point", () => { + const command = `${"a".repeat(76)}\u{1F600}${"b".repeat(20)}`; + const input = JSON.stringify({ command }); + const span = toolSpan(input); + const title = stepTitle(span); + expect(title).toBe(`${"a".repeat(76)}\u{1F600}…`); + expect(title.endsWith("…")).toBe(true); + expect([...title].length).toBeLessThanOrEqual(78); + expect(loneSurrogate(title)).toBe(false); + expect(span.input).toBe(input); + }); + + test("a reasoning cut inside an emoji keeps the code point", () => { + const thinking = `${"c".repeat(84)}\u{1F600}${"d".repeat(20)}\nnext`; + const span: Span = { + id: "span-r", + parentId: null, + kind: "reasoning", + name: "Think", + status: "ok", + startMs: 0, + durationMs: 1, + thinking, + }; + const title = stepTitle(span); + expect(title).toBe(`${"c".repeat(84)}\u{1F600}…`); + expect(title.endsWith("…")).toBe(true); + expect([...title].length).toBeLessThanOrEqual(86); + expect(loneSurrogate(title)).toBe(false); + expect(span.thinking).toBe(thinking); + }); }); + +function loneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + const high = code >= 0xd800 && code <= 0xdbff; + const low = code >= 0xdc00 && code <= 0xdfff; + if (high) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + index += 1; + continue; + } + if (low) return true; + } + return false; +} diff --git a/src/ui/observability/wording.ts b/src/ui/observability/wording.ts index a407d893..4bda0734 100644 --- a/src/ui/observability/wording.ts +++ b/src/ui/observability/wording.ts @@ -21,7 +21,7 @@ export function stepTitle(span: Span): string { if (span.contextChange !== undefined) return span.contextChange.summary; if (span.kind === "reasoning" && span.thinking !== undefined) { const line = span.thinking.split("\n")[0] ?? span.thinking; - return line.length > 88 ? `${line.slice(0, 85)}…` : line; + return clipCodePoints(line, 88, 85); } return span.name; } @@ -32,9 +32,13 @@ export function compactionLine(compaction: Compaction): string { function clipLine(value: string): string { const first = value.split("\n")[0] ?? value; - const trimmed = first.trim(); - if (trimmed.length <= 80) return trimmed; - return `${trimmed.slice(0, 77)}…`; + return clipCodePoints(first.trim(), 80, 77); +} + +function clipCodePoints(value: string, whole: number, keep: number): string { + const points = Array.from(value); + if (points.length <= whole) return value; + return `${points.slice(0, keep).join("")}…`; } function whatItDid(span: Span): string | undefined { diff --git a/stories/observability/views/run.stories.tsx b/stories/observability/views/run.stories.tsx index f6fee056..032e8c12 100644 --- a/stories/observability/views/run.stories.tsx +++ b/stories/observability/views/run.stories.tsx @@ -96,6 +96,28 @@ SignalStory.storyName = "Signal"; export const ToolStory = () => ; ToolStory.storyName = "Tool"; +export const ClippedShellCommand = () => { + const command = [ + "bun test src/parse.test.ts ".repeat(4).trimEnd(), + "echo done", + ].join("\n"); + const shell = { + ...span("tool-shell", review), + input: JSON.stringify({ command }), + }; + return ( +
+
+ +
+
+ +
+
+ ); +}; +ClippedShellCommand.storyName = "Clipped shell command"; + export const ThinkingStory = () => ; ThinkingStory.storyName = "Thinking"; From e12207a3762775f09337782067141eea60f84b58 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 26 Oct 2026 23:26:32 -0700 Subject: [PATCH 3/3] test: show a clipped shell command as a tool --- stories/observability/views/run.stories.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/stories/observability/views/run.stories.tsx b/stories/observability/views/run.stories.tsx index 032e8c12..c715608b 100644 --- a/stories/observability/views/run.stories.tsx +++ b/stories/observability/views/run.stories.tsx @@ -105,16 +105,7 @@ export const ClippedShellCommand = () => { ...span("tool-shell", review), input: JSON.stringify({ command }), }; - return ( -
-
- -
-
- -
-
- ); + return ; }; ClippedShellCommand.storyName = "Clipped shell command";