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";