Skip to content

Commit 5c7b03b

Browse files
committed
fix(terminal-core): render note box directly to preserve copy-sensitive tokens from Clack re-wrap
- Replace clackNote delegate with direct box rendering so that copy-sensitive tokens (paths, URLs, file-like tokens) preserved by wrapNoteMessage are not re-wrapped by Clack's hard wrapAnsi - Copy-sensitive oversized lines overflow the right border instead of being split mid-token, keeping paths copy-pasteable - Remove unused @clack/prompts note import from note.ts Fixes #94730
1 parent ca6d52e commit 5c7b03b

2 files changed

Lines changed: 143 additions & 5 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Tests for copy-sensitive token preservation in the note rendering pipeline
3+
* (fix #94730: Clack's second-wrap hard-wrapping no longer splits paths).
4+
*
5+
* Before the fix, `note()` delegated to `clackNote(...)` which re-wrapped the
6+
* message with hard wrapAnsi, splitting copy-sensitive tokens (paths, URLs,
7+
* file-like tokens) mid-token at the box boundary. After the fix, `note()`
8+
* renders the box itself, preserving the copy-safe wrapping already performed
9+
* by `wrapNoteMessage`.
10+
*/
11+
import { describe, expect, it } from "vitest";
12+
import { resolveNoteColumns, withSuppressedNotes, note, wrapNoteMessage } from "./note.js";
13+
14+
describe("wrapNoteMessage copy-sensitive token preservation (#94730)", () => {
15+
it("keeps a long session lock path on a single line at 80 columns", () => {
16+
const path = "~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock";
17+
const wrapped = wrapNoteMessage(path, { columns: 80 });
18+
const lines = wrapped.split("\n");
19+
20+
// The path must appear on one intact line with full extension
21+
expect(lines.length).toBe(1);
22+
expect(lines[0]).toContain(".jsonl.lock");
23+
// Bug was splitting .jsonl.lock across lines โ€” verify no mid-path newline
24+
expect(lines[0]).not.toMatch(/\S+\n\S+/);
25+
});
26+
27+
it("keeps URLs intact", () => {
28+
const url =
29+
"https://github.com/openclaw/openclaw/blob/main/packages/terminal-core/src/note.ts#L210";
30+
const wrapped = wrapNoteMessage(url, { columns: 80 });
31+
const lines = wrapped.split("\n");
32+
33+
expect(lines.length).toBe(1);
34+
expect(lines[0]).toContain("https://");
35+
expect(lines[0]).toContain("note.ts");
36+
});
37+
38+
it("keeps copy-sensitive tokens with underscores intact", () => {
39+
const path = "/etc/ssh/administrators_authorized_keys";
40+
const wrapped = wrapNoteMessage(path, { columns: 80 });
41+
const lines = wrapped.split("\n");
42+
43+
expect(lines.length).toBe(1);
44+
expect(lines[0]).toContain("administrators_authorized_keys");
45+
});
46+
47+
it("keeps Windows drive paths intact", () => {
48+
const path = "C:\\Users\\Name\\AppData\\Local\\openclaw\\data";
49+
const wrapped = wrapNoteMessage(path, { columns: 80 });
50+
const lines = wrapped.split("\n");
51+
52+
expect(lines.length).toBe(1);
53+
expect(lines[0]).toContain("C:\\Users");
54+
});
55+
56+
it("keeps absolute Unix paths intact", () => {
57+
const path =
58+
"/home/user/workspace/openclaw-worktrees/fix/issue-94730/packages/terminal-core/src/note.ts";
59+
const wrapped = wrapNoteMessage(path, { columns: 80 });
60+
const lines = wrapped.split("\n");
61+
62+
expect(lines.length).toBe(1);
63+
expect(lines[0]).toContain("note.ts");
64+
});
65+
66+
it("does not split a session lock line with path + metadata at 80 columns", () => {
67+
const input =
68+
"- Found 1 session lock file.\n" +
69+
"- ~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock pid=86519 (alive) age=2m47s stale=no";
70+
const wrapped = wrapNoteMessage(input, { columns: 80 });
71+
const lines = wrapped.split("\n");
72+
73+
// The path line must contain the full .jsonl.lock extension
74+
const pathLine = lines.find((l) => l.includes("9c2acae5"));
75+
expect(pathLine).toBeTruthy();
76+
expect(pathLine!).toContain(".jsonl.lock");
77+
expect(pathLine!).not.toContain(".js\n");
78+
});
79+
});
80+
81+
describe("resolveNoteColumns", () => {
82+
it("returns the given columns when >= MIN_NOTE_COLUMNS", () => {
83+
expect(resolveNoteColumns(120)).toBe(120);
84+
expect(resolveNoteColumns(80)).toBe(80);
85+
});
86+
87+
it("falls back to MIN_NOTE_COLUMNS when too small", () => {
88+
expect(resolveNoteColumns(0)).toBe(80);
89+
expect(resolveNoteColumns(40)).toBe(80);
90+
});
91+
92+
it("falls back to MIN_NOTE_COLUMNS for invalid input", () => {
93+
expect(resolveNoteColumns(undefined as unknown as number)).toBe(80);
94+
});
95+
});
96+
97+
describe("withSuppressedNotes", () => {
98+
it("returns the callback result", () => {
99+
const result = withSuppressedNotes(() => 42);
100+
expect(result).toBe(42);
101+
});
102+
});

โ€Žpackages/terminal-core/src/note.tsโ€Ž

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// Terminal Core module implements note behavior.
22
import { AsyncLocalStorage } from "node:async_hooks";
3-
import { note as clackNote } from "@clack/prompts";
43
import { visibleWidth } from "./ansi.js";
54
import { stylePromptTitle } from "./prompt-style.js";
65
import { normalizeLowercaseStringOrEmpty } from "./string.js";
@@ -207,10 +206,47 @@ export function note(message: unknown, title?: string) {
207206
return;
208207
}
209208
const columns = resolveNoteColumns(process.stdout.columns);
210-
clackNote(wrapNoteMessage(message, { columns }), stylePromptTitle(title), {
211-
output: createNoteOutput(columns),
212-
format: (line) => line,
213-
});
209+
const wrapped = wrapNoteMessage(message, { columns });
210+
211+
// #94730: Draw the bordered box ourselves instead of delegating to
212+
// clackNote, which re-wraps the message with hard wrapAnsi and can split
213+
// copy-sensitive tokens (paths, URLs) mid-token at the box boundary.
214+
// wrapNoteMessage already preserves copy-sensitive tokens via
215+
// isCopySensitiveToken; rendering the box directly keeps that guarantee.
216+
const lines = wrapped.split("\n");
217+
const titleText = stylePromptTitle(title);
218+
const innerWidth = columns - 4;
219+
const out = createNoteOutput(columns);
220+
221+
// Top border: โ—‡ Title โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
222+
if (titleText) {
223+
const titleLen = visibleWidth(titleText);
224+
const titlePad = titleLen > 0 ? " " : "";
225+
const barLen = Math.max(0, innerWidth - 2 - titleLen - titlePad.length);
226+
out.write(`โ—‡ ${titleText}${titlePad}${"โ”€".repeat(barLen)}โ•ฎ\n`);
227+
} else {
228+
out.write(`โ—‡ ${"โ”€".repeat(innerWidth - 2)}โ•ฎ\n`);
229+
}
230+
231+
// Vertical sides
232+
out.write(`โ”‚${" ".repeat(innerWidth)}โ”‚\n`);
233+
234+
// Content lines โ€” rendered as-is, no re-wrapping
235+
for (const line of lines) {
236+
const lineWidth = visibleWidth(line);
237+
if (lineWidth <= innerWidth) {
238+
out.write(`โ”‚ ${line}${" ".repeat(innerWidth - 2 - lineWidth)}โ”‚\n`);
239+
} else {
240+
// Copy-sensitive oversized line: let it overflow the right border
241+
// so the user can still triple-click and copy the whole path/URL.
242+
out.write(`โ”‚ ${line} โ”‚\n`);
243+
}
244+
}
245+
246+
out.write(`โ”‚${" ".repeat(innerWidth)}โ”‚\n`);
247+
248+
// Bottom border โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
249+
out.write(`โ”œ${"โ”€".repeat(innerWidth)}โ•ฏ\n`);
214250
}
215251

216252
export function withSuppressedNotes<T>(callback: () => T): T {

0 commit comments

Comments
ย (0)