Skip to content

Commit b404ee3

Browse files
Merge pull request #132 from corbitsdev/cl-11451-clip-shell-commands-in-a-step-title
fix: clip a shell command to one line in a step title
2 parents cd8ac5e + e12207a commit b404ee3

3 files changed

Lines changed: 130 additions & 2 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import type { Span } from "./model.js";
4+
import { stepTitle } from "./wording.js";
5+
6+
function toolSpan(input: string, label?: string): Span {
7+
const span: Span = {
8+
id: "span-1",
9+
parentId: null,
10+
kind: "tool",
11+
name: "Bash",
12+
status: "ok",
13+
startMs: 0,
14+
durationMs: 1,
15+
input,
16+
};
17+
if (label === undefined) return span;
18+
return { ...span, label };
19+
}
20+
21+
describe("stepTitle", () => {
22+
test("a label wins over the command", () => {
23+
const input = JSON.stringify({ command: "ls -la" });
24+
expect(stepTitle(toolSpan(input, "List files"))).toBe("List files");
25+
});
26+
27+
test("a multiline command keeps the first line", () => {
28+
const command = "git status\ngit diff";
29+
const input = JSON.stringify({ command });
30+
expect(stepTitle(toolSpan(input))).toBe("git status");
31+
expect(JSON.parse(input).command).toBe(command);
32+
});
33+
34+
test("a command of 80 characters stays whole", () => {
35+
const command = "b".repeat(80);
36+
const input = JSON.stringify({ cmd: command });
37+
expect(stepTitle(toolSpan(input))).toBe(command);
38+
expect(JSON.parse(input).cmd).toBe(command);
39+
});
40+
41+
test("a blank command falls through to the step name", () => {
42+
expect(stepTitle(toolSpan(JSON.stringify({ command: " \nls" })))).toBe(
43+
"Bash",
44+
);
45+
});
46+
47+
test("a longer command is clipped", () => {
48+
const command = ` ${"a".repeat(90)} \nrest`;
49+
const input = JSON.stringify({ command });
50+
expect(stepTitle(toolSpan(input))).toBe(`${"a".repeat(77)}…`);
51+
expect(JSON.parse(input).command).toBe(command);
52+
});
53+
54+
test("a command cut inside an emoji keeps the code point", () => {
55+
const command = `${"a".repeat(76)}\u{1F600}${"b".repeat(20)}`;
56+
const input = JSON.stringify({ command });
57+
const span = toolSpan(input);
58+
const title = stepTitle(span);
59+
expect(title).toBe(`${"a".repeat(76)}\u{1F600}…`);
60+
expect(title.endsWith("…")).toBe(true);
61+
expect([...title].length).toBeLessThanOrEqual(78);
62+
expect(loneSurrogate(title)).toBe(false);
63+
expect(span.input).toBe(input);
64+
});
65+
66+
test("a reasoning cut inside an emoji keeps the code point", () => {
67+
const thinking = `${"c".repeat(84)}\u{1F600}${"d".repeat(20)}\nnext`;
68+
const span: Span = {
69+
id: "span-r",
70+
parentId: null,
71+
kind: "reasoning",
72+
name: "Think",
73+
status: "ok",
74+
startMs: 0,
75+
durationMs: 1,
76+
thinking,
77+
};
78+
const title = stepTitle(span);
79+
expect(title).toBe(`${"c".repeat(84)}\u{1F600}…`);
80+
expect(title.endsWith("…")).toBe(true);
81+
expect([...title].length).toBeLessThanOrEqual(86);
82+
expect(loneSurrogate(title)).toBe(false);
83+
expect(span.thinking).toBe(thinking);
84+
});
85+
});
86+
87+
function loneSurrogate(value: string): boolean {
88+
for (let index = 0; index < value.length; index += 1) {
89+
const code = value.charCodeAt(index);
90+
const high = code >= 0xd800 && code <= 0xdbff;
91+
const low = code >= 0xdc00 && code <= 0xdfff;
92+
if (high) {
93+
const next = value.charCodeAt(index + 1);
94+
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
95+
index += 1;
96+
continue;
97+
}
98+
if (low) return true;
99+
}
100+
return false;
101+
}

‎src/ui/observability/wording.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function stepTitle(span: Span): string {
2121
if (span.contextChange !== undefined) return span.contextChange.summary;
2222
if (span.kind === "reasoning" && span.thinking !== undefined) {
2323
const line = span.thinking.split("\n")[0] ?? span.thinking;
24-
return line.length > 88 ? `${line.slice(0, 85)}…` : line;
24+
return clipCodePoints(line, 88, 85);
2525
}
2626
return span.name;
2727
}
@@ -30,13 +30,27 @@ export function compactionLine(compaction: Compaction): string {
3030
return `${formatTokens(compaction.tokensBefore)} down to ${formatTokens(compaction.tokensAfter)}`;
3131
}
3232

33+
function clipLine(value: string): string {
34+
const first = value.split("\n")[0] ?? value;
35+
return clipCodePoints(first.trim(), 80, 77);
36+
}
37+
38+
function clipCodePoints(value: string, whole: number, keep: number): string {
39+
const points = Array.from(value);
40+
if (points.length <= whole) return value;
41+
return `${points.slice(0, keep).join("")}…`;
42+
}
43+
3344
function whatItDid(span: Span): string | undefined {
3445
const hop = span.network?.[0];
3546
if (hop !== undefined) return `${hop.method} ${shortUrl(hop.url)}`;
3647
const record = recordOf(span.input);
3748
if (record === undefined) return undefined;
3849
const command = text(record, "command") ?? text(record, "cmd");
39-
if (command !== undefined) return command;
50+
if (command !== undefined) {
51+
const clipped = clipLine(command);
52+
if (clipped.length > 0) return clipped;
53+
}
4054
const pattern = text(record, "pattern");
4155
const path = text(record, "path") ?? text(record, "file");
4256
if (pattern !== undefined && !span.name.includes(pattern)) {

‎stories/observability/views/run.stories.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,19 @@ SignalStory.storyName = "Signal";
9696
export const ToolStory = () => <Tool span={span("tool-shell", review)} />;
9797
ToolStory.storyName = "Tool";
9898

99+
export const ClippedShellCommand = () => {
100+
const command = [
101+
"bun test src/parse.test.ts ".repeat(4).trimEnd(),
102+
"echo done",
103+
].join("\n");
104+
const shell = {
105+
...span("tool-shell", review),
106+
input: JSON.stringify({ command }),
107+
};
108+
return <Tool span={shell} />;
109+
};
110+
ClippedShellCommand.storyName = "Clipped shell command";
111+
99112
export const ThinkingStory = () => <Thinking span={span("e3", editor)} />;
100113
ThinkingStory.storyName = "Thinking";
101114

0 commit comments

Comments
 (0)