diff --git a/src/ui/observability/wording.test.ts b/src/ui/observability/wording.test.ts
new file mode 100644
index 00000000..04e53190
--- /dev/null
+++ b/src/ui/observability/wording.test.ts
@@ -0,0 +1,101 @@
+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);
+ });
+
+ 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 c0db62cb..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;
}
@@ -30,13 +30,27 @@ 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;
+ 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 {
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)) {
diff --git a/stories/observability/views/run.stories.tsx b/stories/observability/views/run.stories.tsx
index f6fee056..c715608b 100644
--- a/stories/observability/views/run.stories.tsx
+++ b/stories/observability/views/run.stories.tsx
@@ -96,6 +96,19 @@ 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";