Skip to content

Commit dc6382c

Browse files
committed
fix(terminal-core): prevent clack note box from re-breaking copy-sensitive paths
wrapNoteMessage correctly preserves paths/URLs as unbroken tokens, but clack's note() re-wraps them with hard-wrap inside the bordered box. Align maxWidth with clack's content width (columns - 6) and pad wrapped lines to that width so clack's wrap-ansi sees no overflow. Fixes #94730
1 parent 5532310 commit dc6382c

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Terminal Core tests cover note copy-sensitive token preservation.
2+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
3+
import { visibleWidth } from "./ansi.js";
4+
5+
// Mock @clack/prompts before importing note.js
6+
const clackNoteMock = vi.fn();
7+
vi.mock("@clack/prompts", () => ({ note: clackNoteMock }));
8+
vi.mock("./prompt-style.js", () => ({ stylePromptTitle: (t: string | undefined) => t ?? "" }));
9+
10+
describe("note copy-sensitive token preservation", () => {
11+
let originalColumns: number | undefined;
12+
13+
beforeEach(() => {
14+
originalColumns = process.stdout.columns;
15+
clackNoteMock.mockClear();
16+
});
17+
18+
afterEach(() => {
19+
Object.defineProperty(process.stdout, "columns", {
20+
value: originalColumns,
21+
configurable: true,
22+
writable: true,
23+
});
24+
vi.restoreAllMocks();
25+
});
26+
27+
it("does not re-break copy-sensitive paths passed to clackNote", async () => {
28+
// Simulate 80-column terminal
29+
Object.defineProperty(process.stdout, "columns", {
30+
value: 80,
31+
configurable: true,
32+
writable: true,
33+
});
34+
35+
const { note } = await import("./note.js");
36+
const longPath =
37+
"~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock";
38+
const message = `- Found 1 session lock file.\n- ${longPath} pid=86519 (alive) age=2m47s stale=no`;
39+
40+
note(message, "Session locks");
41+
42+
expect(clackNoteMock).toHaveBeenCalledOnce();
43+
const passedMessage = clackNoteMock.mock.calls[0][0] as string;
44+
const lines = passedMessage.split("\n");
45+
46+
// Find the line containing the path
47+
const pathLine = lines.find((l: string) => l.includes("9c2acae5"));
48+
expect(pathLine).toBeDefined();
49+
// The path must NOT be broken — the full token must be on one line
50+
expect(pathLine).toContain(longPath);
51+
// The path should NOT be split across lines (no line ending in ".js" with next starting "onl.lock")
52+
expect(pathLine).not.toMatch(/\.js\s*$/);
53+
});
54+
55+
it("pads lines to contentWidth to prevent clack re-wrapping", async () => {
56+
Object.defineProperty(process.stdout, "columns", {
57+
value: 80,
58+
configurable: true,
59+
writable: true,
60+
});
61+
62+
const { note } = await import("./note.js");
63+
note("- short line", "Test");
64+
65+
expect(clackNoteMock).toHaveBeenCalledOnce();
66+
const passedMessage = clackNoteMock.mock.calls[0][0] as string;
67+
const lines = passedMessage.split("\n");
68+
const contentWidth = 80 - 6; // columns - 6
69+
70+
// Every line should be padded to exactly contentWidth (visible width)
71+
for (const line of lines) {
72+
// Skip empty lines (the first and last padding lines from note())
73+
if (line.trim() === "") continue;
74+
expect(visibleWidth(line)).toBe(contentWidth);
75+
}
76+
});
77+
});

packages/terminal-core/src/note.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,20 @@ export function note(message: unknown, title?: string) {
207207
return;
208208
}
209209
const columns = resolveNoteColumns(process.stdout.columns);
210-
clackNote(wrapNoteMessage(message, { columns }), stylePromptTitle(title), {
210+
// Align maxWidth with clack's content width (columns - 6) so that
211+
// copy-sensitive tokens (paths, URLs) preserved by wrapNoteMessage are not
212+
// re-broken by clack's hard-wrap inside the bordered box.
213+
const contentWidth = columns - 6;
214+
const wrapped = wrapNoteMessage(message, { columns, maxWidth: contentWidth });
215+
// Pad each line to contentWidth so clack's wrap-ansi sees no overflow.
216+
const padded = wrapped
217+
.split("\n")
218+
.map((line) => {
219+
const w = visibleWidth(line);
220+
return w < contentWidth ? line + " ".repeat(contentWidth - w) : line;
221+
})
222+
.join("\n");
223+
clackNote(padded, stylePromptTitle(title), {
211224
output: createNoteOutput(columns),
212225
format: (line) => line,
213226
});

0 commit comments

Comments
 (0)