Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/ui/observability/wording.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 16 additions & 2 deletions src/ui/observability/wording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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)) {
Expand Down
13 changes: 13 additions & 0 deletions stories/observability/views/run.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ SignalStory.storyName = "Signal";
export const ToolStory = () => <Tool span={span("tool-shell", review)} />;
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 <Tool span={shell} />;
};
ClippedShellCommand.storyName = "Clipped shell command";

export const ThinkingStory = () => <Thinking span={span("e3", editor)} />;
ThinkingStory.storyName = "Thinking";

Expand Down
Loading