diff --git a/CHANGELOG.md b/CHANGELOG.md index 05936c8d076..2070b038ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI tables: preserve muted/color styling on wrapped continuation lines after multiline cells, keeping `openclaw plugins list` descriptions readable. - iOS: restore first-use Contacts, Calendar, and Reminders permission prompts and add Privacy & Access status/actions in Settings. Thanks @BunsDev. - Canvas: return not found for malformed percent-encoded Canvas/A2UI asset paths and keep decoded parent traversal blocked before path normalization. - Agents: allow dot-dot-prefixed filenames such as `..note.txt` through sandbox FS bridge, remote sandbox reads, and apply_patch summaries without mistaking the name for parent traversal. diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index dd28b82043b..a596c05a148 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -124,6 +124,40 @@ describe("renderTable", () => { expect(lines[0]).not.toContain("│ \x1b[2m Use when"); }); + it("keeps ANSI styling when a multiline cell wraps after an unstyled line", () => { + const muted = "\x1b[38;2;120;120;120m"; + const resetForeground = "\x1b[39m"; + const out = renderTable({ + width: 62, + columns: [ + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Source", header: "Source", minWidth: 24, flex: true }, + { key: "Version", header: "Version", minWidth: 8 }, + ], + rows: [ + { + Status: "disabled", + Source: + "stock:codex/index.js\n" + + `${muted}Codex app-server harness and Codex-managed GPT model catalog.${resetForeground}`, + Version: "2026.5.12-beta.6", + }, + ], + }); + + const descLines = out + .split("\n") + .filter((line) => line.includes("Codex") || line.includes("catalog.")); + expect(descLines.length).toBeGreaterThan(1); + for (const line of descLines) { + expect(line).toContain(muted); + const resetIndex = line.lastIndexOf(resetForeground); + const lastSep = Math.max(line.lastIndexOf("│"), line.lastIndexOf("|")); + expect(resetIndex).toBeGreaterThan(-1); + expect(lastSep).toBeGreaterThan(resetIndex); + } + }); + it("respects explicit newlines in cell values", () => { const out = renderTable({ width: 48, diff --git a/src/terminal/table.ts b/src/terminal/table.ts index 7c55ba7f2dd..bd0aa1ab46e 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -70,9 +70,10 @@ function wrapLine(text: string, width: number): string[] { } // ANSI-aware wrapping: never split inside ANSI SGR/OSC-8 sequences. - // We don't attempt to re-open styling per line; terminals keep SGR state - // across newlines, so as long as we don't corrupt escape sequences we're safe. + // Table cells are padded and bordered per physical line, so wrapped lines + // must not leak styling into padding while the next continuation keeps it. const ESC = "\u001b"; + const SGR_RESET = `${ESC}[0m`; type Token = { kind: "ansi" | "char"; value: string }; const tokens: Token[] = []; @@ -170,9 +171,140 @@ function wrapLine(text: string, width: number): string[] { const bufVisibleWidth = (slice: Token[]) => slice.reduce((acc, t) => acc + (t.kind === "char" ? visibleWidth(t.value) : 0), 0); + const parseSgrParams = (value: string): number[] | null => { + if (!value.startsWith(`${ESC}[`) || !value.endsWith("m")) { + return null; + } + const raw = value.slice(2, -1); + if (!raw) { + return [0]; + } + const params = raw.split(";").map((part) => (part === "" ? 0 : Number(part))); + return params.every((param) => Number.isInteger(param)) ? params : null; + }; + + const activeSgrAfter = (tokens: Token[]) => { + type SgrCategory = + | "background" + | "blink" + | "conceal" + | "foreground" + | "intensity" + | "inverse" + | "italic" + | "strike" + | "underline"; + const active: Array<{ value: string; categories: Set }> = []; + const resetCategoriesFor = (params: number[]) => { + const categories = new Set(); + for (const param of params) { + if (param === 22) { + categories.add("intensity"); + } else if (param === 23) { + categories.add("italic"); + } else if (param === 24) { + categories.add("underline"); + } else if (param === 25) { + categories.add("blink"); + } else if (param === 27) { + categories.add("inverse"); + } else if (param === 28) { + categories.add("conceal"); + } else if (param === 29) { + categories.add("strike"); + } else if (param === 39) { + categories.add("foreground"); + } else if (param === 49) { + categories.add("background"); + } + } + return categories; + }; + const activeCategoriesFor = (params: number[]) => { + const categories = new Set(); + for (let i = 0; i < params.length; i += 1) { + const param = params[i] ?? 0; + if (param === 1 || param === 2) { + categories.add("intensity"); + } else if (param === 3) { + categories.add("italic"); + } else if (param === 4) { + categories.add("underline"); + } else if (param === 5 || param === 6) { + categories.add("blink"); + } else if (param === 7) { + categories.add("inverse"); + } else if (param === 8) { + categories.add("conceal"); + } else if (param === 9) { + categories.add("strike"); + } else if ((param >= 30 && param <= 37) || (param >= 90 && param <= 97)) { + categories.add("foreground"); + } else if (param === 38) { + categories.add("foreground"); + if (params[i + 1] === 2) { + i += 4; + } else if (params[i + 1] === 5) { + i += 2; + } + } else if ((param >= 40 && param <= 47) || (param >= 100 && param <= 107)) { + categories.add("background"); + } else if (param === 48) { + categories.add("background"); + if (params[i + 1] === 2) { + i += 4; + } else if (params[i + 1] === 5) { + i += 2; + } + } + } + return categories; + }; + const intersects = (left: Set, right: Set) => { + for (const value of left) { + if (right.has(value)) { + return true; + } + } + return false; + }; + for (const token of tokens) { + if (token.kind !== "ansi") { + continue; + } + const params = parseSgrParams(token.value); + if (!params) { + continue; + } + if (params.includes(0)) { + active.length = 0; + } + const resetCategories = resetCategoriesFor(params); + if (resetCategories.size > 0) { + for (let i = active.length - 1; i >= 0; i -= 1) { + const entry = active[i]; + if (entry && intersects(entry.categories, resetCategories)) { + active.splice(i, 1); + } + } + } + const activeCategories = activeCategoriesFor(params); + if (activeCategories.size > 0) { + for (let i = active.length - 1; i >= 0; i -= 1) { + const entry = active[i]; + if (entry && intersects(entry.categories, activeCategories)) { + active.splice(i, 1); + } + } + active.push({ value: token.value, categories: activeCategories }); + } + } + return active.map((entry) => entry.value).join(""); + }; + const pushLine = (value: string) => { const cleaned = value.replace(/\s+$/, ""); - if (cleaned.trim().length === 0) { + if (visibleWidth(cleaned) === 0) { return; } lines.push(cleaned); @@ -197,8 +329,12 @@ function wrapLine(text: string, width: number): string[] { return; } if (breakAt == null || breakAt <= 0) { - pushLine(bufToString()); + const activeSgr = activeSgrAfter(buf); + pushLine(activeSgr ? `${bufToString()}${SGR_RESET}` : bufToString()); buf.length = 0; + if (activeSgr) { + buf.push({ kind: "ansi", value: activeSgr }); + } bufVisible = 0; lastBreakIndex = null; return; @@ -206,8 +342,12 @@ function wrapLine(text: string, width: number): string[] { const left = buf.slice(0, breakAt); const rest = buf.slice(breakAt); - pushLine(bufToString(left)); + const activeSgr = activeSgrAfter(left); + pushLine(activeSgr ? `${bufToString(left)}${SGR_RESET}` : bufToString(left)); trimLeadingSpaces(rest); + if (activeSgr) { + rest.unshift({ kind: "ansi", value: activeSgr }); + } buf.length = 0; buf.push(...rest);