Skip to content

Commit 4ea5e81

Browse files
committed
fix(terminal-core): replace clackNote with own box rendering to prevent path splitting
wrapNoteMessage correctly preserves copy-sensitive tokens (paths, URLs) by allowing them to overflow the wrap width, but the downstream clackNote call would re-wrap them at the box border, splitting file extensions mid-token. Build the note box directly in renderNoteBox() so OpenClaw owns wrapping decisions end-to-end. Lines with copy-sensitive overflow are allowed to extend past the right border instead of being broken. Fixes #94730
1 parent f19cae6 commit 4ea5e81

2 files changed

Lines changed: 227 additions & 5 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Terminal Core tests cover note behavior.
2+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
3+
import { stripAnsi } from "./ansi.js";
4+
import { note, withSuppressedNotes, wrapNoteMessage } from "./note.js";
5+
6+
function spyStdoutWrite() {
7+
const chunks: string[] = [];
8+
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
9+
if (typeof chunk === "string") chunks.push(chunk);
10+
return true;
11+
});
12+
return { spy, chunks };
13+
}
14+
15+
function setColumns(n: number) {
16+
Object.defineProperty(process.stdout, "columns", {
17+
value: n,
18+
configurable: true,
19+
});
20+
}
21+
22+
describe("wrapNoteMessage", () => {
23+
it("preserves a copy-sensitive path that exceeds the wrap width", () => {
24+
const longPath =
25+
"~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock";
26+
const msg = `- Found lock at ${longPath} pid=86519 (alive)`;
27+
const result = wrapNoteMessage(msg, { columns: 80, maxWidth: 68 });
28+
const lines = result.split("\n");
29+
const pathLine = lines.find((l) => l.includes(".jsonl.lock"));
30+
expect(pathLine).toBeDefined();
31+
expect(pathLine!).toContain(longPath);
32+
});
33+
34+
it("preserves a URL that exceeds the wrap width", () => {
35+
const url =
36+
"https://example.com/very/long/path/that/exceeds/normal/line/width/endpoint/v1/users";
37+
const msg = `- See ${url} for details`;
38+
const result = wrapNoteMessage(msg, { columns: 80, maxWidth: 70 });
39+
const lines = result.split("\n");
40+
const urlLine = lines.find((l) => l.includes("https://"));
41+
expect(urlLine).toBeDefined();
42+
expect(urlLine!).toContain(url);
43+
});
44+
45+
it("wraps long non-copy-sensitive words at the wrap width", () => {
46+
const longWord = "supercalifragilisticexpialidocious";
47+
const result = wrapNoteMessage(`- ${longWord}`, {
48+
columns: 80,
49+
maxWidth: 20,
50+
});
51+
const lines = result.split("\n");
52+
expect(lines.length).toBeGreaterThan(1);
53+
});
54+
});
55+
56+
describe("note box rendering", () => {
57+
let writeSpy: ReturnType<typeof vi.spyOn>;
58+
let chunks: string[];
59+
60+
beforeEach(() => {
61+
setColumns(80);
62+
const spied = spyStdoutWrite();
63+
writeSpy = spied.spy;
64+
chunks = spied.chunks;
65+
});
66+
67+
afterEach(() => {
68+
writeSpy.mockRestore();
69+
});
70+
71+
function getOutput(): string {
72+
return stripAnsi(chunks.join(""));
73+
}
74+
75+
it("renders a note box with title and message", () => {
76+
note("hello world", "Title");
77+
const out = getOutput();
78+
expect(out).toContain("◇");
79+
expect(out).toContain("Title");
80+
expect(out).toContain("hello world");
81+
expect(out).toContain("╮");
82+
expect(out).toContain("╰");
83+
expect(out).toContain("╯");
84+
});
85+
86+
it("renders content lines with proper box borders", () => {
87+
note("- item one\n- item two", "List");
88+
const out = getOutput();
89+
expect(out).toContain("│");
90+
expect(out).toContain("- item one");
91+
expect(out).toContain("- item two");
92+
});
93+
94+
it("does not re-wrap a copy-sensitive path inside the box", () => {
95+
const lockPath =
96+
"~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock";
97+
const msg = `- Found 1 session lock file.\n- ${lockPath} pid=86519 (alive) age=2m47s stale=no`;
98+
note(msg, "Session locks");
99+
const out = getOutput();
100+
// The path must appear intact — not split across lines inside the box.
101+
expect(out).toContain(lockPath);
102+
// The extension ".jsonl.lock" must appear as one unbroken unit.
103+
expect(out).toContain(".jsonl.lock");
104+
});
105+
106+
it("allows a copy-sensitive line to overflow the box right border", () => {
107+
const longPath = "/" + "a/".repeat(30) + "very-long-filename.jsonl.lock";
108+
const msg = `- ${longPath}`;
109+
note(msg, "Path overflow");
110+
const out = getOutput();
111+
// The path must appear intact.
112+
expect(out).toContain(longPath);
113+
// The bottom border should still be rendered.
114+
expect(out).toContain("╰");
115+
});
116+
});
117+
118+
describe("note suppression", () => {
119+
let writeSpy: ReturnType<typeof vi.spyOn>;
120+
121+
beforeEach(() => {
122+
setColumns(80);
123+
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
124+
});
125+
126+
afterEach(() => {
127+
writeSpy.mockRestore();
128+
vi.unstubAllEnvs();
129+
});
130+
131+
it("suppresses output when OPENCLAW_SUPPRESS_NOTES is set", () => {
132+
vi.stubEnv("OPENCLAW_SUPPRESS_NOTES", "1");
133+
note("should not appear");
134+
expect(writeSpy).not.toHaveBeenCalled();
135+
});
136+
137+
it("suppresses output inside withSuppressedNotes callback", () => {
138+
withSuppressedNotes(() => {
139+
note("should not appear");
140+
});
141+
expect(writeSpy).not.toHaveBeenCalled();
142+
});
143+
});

packages/terminal-core/src/note.ts

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
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";
6+
import { isRich, theme } from "./theme.js";
77

88
const MIN_NOTE_COLUMNS = 80;
99
const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i;
@@ -199,6 +199,86 @@ function createNoteOutput(columns: number): NodeJS.WriteStream {
199199
return output;
200200
}
201201

202+
/**
203+
* Returns true when a line exceeds maxWidth and at least one of the words on
204+
* that line is a copy-sensitive token (path, URL, file-like name). Such lines
205+
* are allowed to overflow the note box rather than being re-wrapped, because
206+
* breaking them mid-token defeats the reason they are surfaced — the user needs
207+
* to copy-paste them unbroken.
208+
*/
209+
function hasCopySensitiveOverflow(line: string, maxWidth: number): boolean {
210+
if (visibleWidth(line) <= maxWidth) return false;
211+
const words = line.split(/\s+/);
212+
return words.some((w) => isCopySensitiveToken(w));
213+
}
214+
215+
/**
216+
* Render a bordered note box directly, bypassing @clack/prompts' note() so that
217+
* OpenClaw owns the text-wrapping decisions end-to-end. wrapNoteMessage already
218+
* produces correctly-wrapped lines (copy-sensitive tokens are kept intact and
219+
* allowed to overflow the wrap width). clackNote would re-wrap them at the box
220+
* border, splitting paths mid-token. Building the box ourselves eliminates that
221+
* second pass.
222+
*
223+
* Lines that contain copy-sensitive tokens and exceed the content area width are
224+
* allowed to overflow past the right border of the box.
225+
*/
226+
function renderNoteBox(
227+
message: string,
228+
title: string | undefined,
229+
columns: number,
230+
output: NodeJS.WriteStream,
231+
) {
232+
const rich = isRich();
233+
const dim = (s: string) => (rich ? theme.muted(s) : s);
234+
const accent = (s: string) => (rich ? theme.accent(s) : s);
235+
236+
const titleText = title ?? "";
237+
const titleWidth = visibleWidth(titleText);
238+
const contentLines = message.split("\n");
239+
240+
const boxH = dim("─");
241+
const boxV = dim("│");
242+
const cornerTR = dim("╮");
243+
const cornerBL = dim("╰");
244+
const cornerBR = dim("╯");
245+
const step = accent("◇");
246+
247+
const borderLeftWidth = 3; // "│ "
248+
const borderRightWidth = 3; // " │"
249+
const borderOverhead = borderLeftWidth + borderRightWidth; // 6
250+
251+
// Content area width: max of title and non-copy-sensitive lines.
252+
// Copy-sensitive overflow lines are allowed to exceed this.
253+
let contentWidth = Math.max(titleWidth, 40);
254+
for (const line of contentLines) {
255+
const w = visibleWidth(line);
256+
if (w > contentWidth && !hasCopySensitiveOverflow(line, columns - borderOverhead)) {
257+
contentWidth = w;
258+
}
259+
}
260+
contentWidth = Math.min(contentWidth, columns - borderOverhead);
261+
262+
// Title line: ◇ Title ───╮
263+
const titleDashLen = Math.max(1, contentWidth - titleWidth + 1);
264+
output.write(`${step} ${titleText} ${boxH.repeat(titleDashLen)}${cornerTR}\n`);
265+
266+
// Content lines
267+
for (const line of contentLines) {
268+
const lineWidth = visibleWidth(line);
269+
if (lineWidth > contentWidth && hasCopySensitiveOverflow(line, contentWidth)) {
270+
// Copy-sensitive overflow: let the line extend past the box right border.
271+
output.write(`${boxV} ${line}\n`);
272+
} else {
273+
const pad = Math.max(0, contentWidth - lineWidth);
274+
output.write(`${boxV} ${line}${" ".repeat(pad)} ${boxV}\n`);
275+
}
276+
}
277+
278+
// Bottom line: ╰───╯
279+
output.write(`${cornerBL}${boxH.repeat(contentWidth + 4)}${cornerBR}\n`);
280+
}
281+
202282
export function note(message: unknown, title?: string) {
203283
if (
204284
suppressNotesStorage.getStore() === true ||
@@ -207,10 +287,9 @@ export function note(message: unknown, title?: string) {
207287
return;
208288
}
209289
const columns = resolveNoteColumns(process.stdout.columns);
210-
clackNote(wrapNoteMessage(message, { columns }), stylePromptTitle(title), {
211-
output: createNoteOutput(columns),
212-
format: (line) => line,
213-
});
290+
const wrapped = wrapNoteMessage(message, { columns });
291+
const output = createNoteOutput(columns);
292+
renderNoteBox(wrapped, stylePromptTitle(title), columns, output);
214293
}
215294

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

0 commit comments

Comments
 (0)