fix(cli): preserve multiline table colors

This commit is contained in:
Vincent Koc
2026-05-14 12:04:39 +08:00
parent 284dcc51b8
commit 1fc92ddfb1
3 changed files with 180 additions and 5 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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<SgrCategory> }> = [];
const resetCategoriesFor = (params: number[]) => {
const categories = new Set<SgrCategory>();
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<SgrCategory>();
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<SgrCategory>, right: Set<SgrCategory>) => {
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);