From ccc7003360cf0bf9e529ce41b7b23cfcce9021a4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 08:50:30 -0400 Subject: [PATCH 01/13] Changelog: add unreleased March 9 entries --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f705ed77a3..adc8b8d6e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky. +- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc. ### Breaking @@ -40,6 +41,8 @@ Docs: https://docs.openclaw.ai - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. +- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. +- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc. ## 2026.3.8 From 7a8316706c8c7d5720b4a964ebb4aae5ff78fbec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 10:49:39 -0400 Subject: [PATCH 02/13] Tests: cover grapheme terminal width --- src/terminal/ansi.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/terminal/ansi.test.ts b/src/terminal/ansi.test.ts index 30ae4c82eb3..3970868d3f8 100644 --- a/src/terminal/ansi.test.ts +++ b/src/terminal/ansi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sanitizeForLog, stripAnsi } from "./ansi.js"; +import { sanitizeForLog, splitGraphemes, stripAnsi, visibleWidth } from "./ansi.js"; describe("terminal ansi helpers", () => { it("strips ANSI and OSC8 sequences", () => { @@ -11,4 +11,16 @@ describe("terminal ansi helpers", () => { const input = "\u001B[31mwarn\u001B[0m\r\nnext\u0000line\u007f"; expect(sanitizeForLog(input)).toBe("warnnextline"); }); + + it("measures wide graphemes by terminal cell width", () => { + expect(visibleWidth("abc")).toBe(3); + expect(visibleWidth("๐Ÿ“ธ skill")).toBe(8); + expect(visibleWidth("่กจ")).toBe(2); + expect(visibleWidth("\u001B[31m๐Ÿ“ธ\u001B[0m")).toBe(2); + }); + + it("keeps emoji zwj sequences as single graphemes", () => { + expect(splitGraphemes("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ")).toEqual(["๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"]); + expect(visibleWidth("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ")).toBe(2); + }); }); From 4efe7a4dcd0b6b428f2bd0d00816ba83cfd4f955 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 10:50:01 -0400 Subject: [PATCH 03/13] Terminal: measure grapheme display width --- src/terminal/ansi.ts | 91 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/src/terminal/ansi.ts b/src/terminal/ansi.ts index d9adaa38633..471611fcc2e 100644 --- a/src/terminal/ansi.ts +++ b/src/terminal/ansi.ts @@ -4,11 +4,29 @@ const OSC8_PATTERN = "\\x1b\\]8;;.*?\\x1b\\\\|\\x1b\\]8;;\\x1b\\\\"; const ANSI_REGEX = new RegExp(ANSI_SGR_PATTERN, "g"); const OSC8_REGEX = new RegExp(OSC8_PATTERN, "g"); +const graphemeSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; export function stripAnsi(input: string): string { return input.replace(OSC8_REGEX, "").replace(ANSI_REGEX, ""); } +export function splitGraphemes(input: string): string[] { + if (!input) { + return []; + } + if (!graphemeSegmenter) { + return Array.from(input); + } + try { + return Array.from(graphemeSegmenter.segment(input), (segment) => segment.segment); + } catch { + return Array.from(input); + } +} + /** * Sanitize a value for safe interpolation into log messages. * Strips ANSI escape sequences, C0 control characters (U+0000โ€“U+001F), @@ -22,6 +40,75 @@ export function sanitizeForLog(v: string): string { return out.replaceAll(String.fromCharCode(0x7f), ""); } -export function visibleWidth(input: string): number { - return Array.from(stripAnsi(input)).length; +function isZeroWidthCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x0300 && codePoint <= 0x036f) || + (codePoint >= 0x1ab0 && codePoint <= 0x1aff) || + (codePoint >= 0x1dc0 && codePoint <= 0x1dff) || + (codePoint >= 0x20d0 && codePoint <= 0x20ff) || + (codePoint >= 0xfe20 && codePoint <= 0xfe2f) || + (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || + codePoint === 0x200d + ); +} + +function isFullWidthCodePoint(codePoint: number): boolean { + if (codePoint < 0x1100) { + return false; + } + return ( + codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0x3247 && codePoint !== 0x303f) || + (codePoint >= 0x3250 && codePoint <= 0x4dbf) || + (codePoint >= 0x4e00 && codePoint <= 0xa4c6) || + (codePoint >= 0xa960 && codePoint <= 0xa97c) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || + (codePoint >= 0xff01 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1aff0 && codePoint <= 0x1aff3) || + (codePoint >= 0x1aff5 && codePoint <= 0x1affb) || + (codePoint >= 0x1affd && codePoint <= 0x1affe) || + (codePoint >= 0x1b000 && codePoint <= 0x1b2ff) || + (codePoint >= 0x1f200 && codePoint <= 0x1f251) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd) + ); +} + +const emojiLikePattern = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u; + +function graphemeWidth(grapheme: string): number { + if (!grapheme) { + return 0; + } + if (emojiLikePattern.test(grapheme)) { + return 2; + } + + let sawPrintable = false; + for (const char of grapheme) { + const codePoint = char.codePointAt(0); + if (codePoint == null) { + continue; + } + if (isZeroWidthCodePoint(codePoint)) { + continue; + } + if (isFullWidthCodePoint(codePoint)) { + return 2; + } + sawPrintable = true; + } + return sawPrintable ? 1 : 0; +} + +export function visibleWidth(input: string): number { + return splitGraphemes(stripAnsi(input)).reduce( + (sum, grapheme) => sum + graphemeWidth(grapheme), + 0, + ); } From 1ec49e33f3e7086041cd12a759043ae342381513 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 10:50:11 -0400 Subject: [PATCH 04/13] Terminal: wrap table cells by grapheme width --- src/terminal/table.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/terminal/table.ts b/src/terminal/table.ts index 34d7b15dd05..6f00b1b2064 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -1,5 +1,5 @@ import { displayString } from "../utils.js"; -import { visibleWidth } from "./ansi.js"; +import { splitGraphemes, visibleWidth } from "./ansi.js"; type Align = "left" | "right" | "center"; @@ -94,13 +94,15 @@ function wrapLine(text: string, width: number): string[] { } } - const cp = text.codePointAt(i); - if (!cp) { - break; + let nextEsc = text.indexOf(ESC, i); + if (nextEsc < 0) { + nextEsc = text.length; } - const ch = String.fromCodePoint(cp); - tokens.push({ kind: "char", value: ch }); - i += ch.length; + const plainChunk = text.slice(i, nextEsc); + for (const grapheme of splitGraphemes(plainChunk)) { + tokens.push({ kind: "char", value: grapheme }); + } + i = nextEsc; } const firstCharIndex = tokens.findIndex((t) => t.kind === "char"); @@ -139,7 +141,7 @@ function wrapLine(text: string, width: number): string[] { const bufToString = (slice?: Token[]) => (slice ?? buf).map((t) => t.value).join(""); const bufVisibleWidth = (slice: Token[]) => - slice.reduce((acc, t) => acc + (t.kind === "char" ? 1 : 0), 0); + slice.reduce((acc, t) => acc + (t.kind === "char" ? visibleWidth(t.value) : 0), 0); const pushLine = (value: string) => { const cleaned = value.replace(/\s+$/, ""); @@ -195,12 +197,13 @@ function wrapLine(text: string, width: number): string[] { } continue; } - if (bufVisible + 1 > width && bufVisible > 0) { + const charWidth = visibleWidth(ch); + if (bufVisible + charWidth > width && bufVisible > 0) { flushAt(lastBreakIndex); } buf.push(token); - bufVisible += 1; + bufVisible += charWidth; if (isBreakChar(ch)) { lastBreakIndex = buf.length; } From a7a5e01c4c20bfebfabd4cbaa27a96b09cb5e8cc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 10:50:21 -0400 Subject: [PATCH 05/13] Tests: cover emoji table alignment --- src/terminal/table.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index bb6f2082fe3..cc7e110ea40 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -99,6 +99,31 @@ describe("renderTable", () => { expect(line1Index).toBeGreaterThan(-1); expect(line2Index).toBe(line1Index + 1); }); + + it("keeps table borders aligned when cells contain wide emoji graphemes", () => { + const width = 72; + const out = renderTable({ + width, + columns: [ + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Skill", header: "Skill", minWidth: 18 }, + { key: "Description", header: "Description", minWidth: 18, flex: true }, + { key: "Source", header: "Source", minWidth: 10 }, + ], + rows: [ + { + Status: "โœ— missing", + Skill: "๐Ÿ“ธ peekaboo", + Description: "Capture screenshots from macOS windows and keep table wrapping stable.", + Source: "openclaw-bundled", + }, + ], + }); + + for (const line of out.trimEnd().split("\n")) { + expect(visibleWidth(line)).toBe(width); + } + }); }); describe("wrapNoteMessage", () => { From f7f75519add7f4ebe4a3a16ca322bf8765b70b27 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 10 Mar 2026 22:30:44 -0400 Subject: [PATCH 06/13] Deps: patch file-type and hono --- package.json | 6 ++++-- pnpm-lock.yaml | 51 ++++++++++++++++++++++++++------------------------ 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index bc625b74e71..18d17b95859 100644 --- a/package.json +++ b/package.json @@ -362,8 +362,9 @@ "discord-api-types": "^0.38.41", "dotenv": "^17.3.1", "express": "^5.2.1", - "file-type": "^21.3.0", + "file-type": "^21.3.1", "grammy": "^1.41.1", + "hono": "4.12.7", "https-proxy-agent": "^7.0.6", "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", @@ -420,7 +421,8 @@ "pnpm": { "minimumReleaseAge": 2880, "overrides": { - "hono": "4.12.5", + "file-type": "21.3.1", + "hono": "4.12.7", "@hono/node-server": "1.19.10", "fast-xml-parser": "5.3.8", "request": "npm:@cypress/request@3.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ae9ea71e0c..0e68b4b0bfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - hono: 4.12.5 + file-type: 21.3.1 + hono: 4.12.7 '@hono/node-server': 1.19.10 fast-xml-parser: 5.3.8 request: npm:@cypress/request@3.0.10 @@ -32,7 +33,7 @@ importers: version: 3.1004.0 '@buape/carbon': specifier: 0.0.0-beta-20260216184201 - version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1) + version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) '@clack/prompts': specifier: ^1.1.0 version: 1.1.0 @@ -115,11 +116,14 @@ importers: specifier: ^5.2.1 version: 5.2.1 file-type: - specifier: ^21.3.0 - version: 21.3.0 + specifier: 21.3.1 + version: 21.3.1 grammy: specifier: ^1.41.1 version: 1.41.1 + hono: + specifier: 4.12.7 + version: 4.12.7 https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 @@ -339,7 +343,7 @@ importers: version: 10.6.1 openclaw: specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/imessage: {} @@ -400,7 +404,7 @@ importers: dependencies: openclaw: specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -1278,7 +1282,7 @@ packages: resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.5 + hono: 4.12.7 '@huggingface/jinja@0.5.5': resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==} @@ -4440,8 +4444,8 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - file-type@21.3.0: - resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + file-type@21.3.1: + resolution: {integrity: sha512-SrzXX46I/zsRDjTb82eucsGg0ODq2NpGDp4HcsFKApPy8P8vACjpJRDoGGMfEzhFC0ry61ajd7f72J3603anBA==} engines: {node: '>=20'} filename-reserved-regex@3.0.0: @@ -4661,8 +4665,8 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} - hono@4.12.5: - resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} engines: {node: '>=16.9.0'} hookable@6.0.1: @@ -7820,14 +7824,14 @@ snapshots: '@borewit/text-codec@0.2.1': {} - '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)': + '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)': dependencies: '@types/node': 25.3.5 discord-api-types: 0.38.37 optionalDependencies: '@cloudflare/workers-types': 4.20260120.0 '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) - '@hono/node-server': 1.19.10(hono@4.12.5) + '@hono/node-server': 1.19.10(hono@4.12.7) '@types/bun': 1.3.9 '@types/ws': 8.18.1 ws: 8.19.0 @@ -8171,9 +8175,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@hono/node-server@1.19.10(hono@4.12.5)': + '@hono/node-server@1.19.10(hono@4.12.7)': dependencies: - hono: 4.12.5 + hono: 4.12.7 optional: true '@huggingface/jinja@0.5.5': {} @@ -8584,7 +8588,7 @@ snapshots: cli-highlight: 2.1.11 diff: 8.0.3 extract-zip: 2.0.1 - file-type: 21.3.0 + file-type: 21.3.1 glob: 13.0.6 hosted-git-info: 9.0.2 ignore: 7.0.5 @@ -8615,7 +8619,7 @@ snapshots: cli-highlight: 2.1.11 diff: 8.0.3 extract-zip: 2.0.1 - file-type: 21.3.0 + file-type: 21.3.1 glob: 13.0.6 hosted-git-info: 9.0.2 ignore: 7.0.5 @@ -11655,7 +11659,7 @@ snapshots: node-domexception: '@nolyfill/domexception@1.0.28' web-streams-polyfill: 3.3.3 - file-type@21.3.0: + file-type@21.3.1: dependencies: '@tokenizer/inflate': 0.4.1 strtok3: 10.3.4 @@ -11942,8 +11946,7 @@ snapshots: highlight.js@10.7.3: {} - hono@4.12.5: - optional: true + hono@4.12.7: {} hookable@6.0.1: {} @@ -12598,7 +12601,7 @@ snapshots: '@tokenizer/token': 0.3.0 content-type: 1.0.5 debug: 4.4.3 - file-type: 21.3.0 + file-type: 21.3.1 media-typer: 1.1.0 strtok3: 10.3.4 token-types: 6.1.2 @@ -12821,11 +12824,11 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)): + openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)): dependencies: '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) '@aws-sdk/client-bedrock': 3.1000.0 - '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1) + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) '@clack/prompts': 1.0.1 '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': 2.0.3(grammy@1.41.0) @@ -12854,7 +12857,7 @@ snapshots: discord-api-types: 0.38.40 dotenv: 17.3.1 express: 5.2.1 - file-type: 21.3.0 + file-type: 21.3.1 gaxios: 7.1.3 google-auth-library: 10.6.1 grammy: 1.41.0 From 0d7db6c6529622bb2efa402e3095b8e63267dfe8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 01:04:18 -0400 Subject: [PATCH 07/13] Update CHANGELOG.md --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adc8b8d6e16..0ceb494bc4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,6 @@ Docs: https://docs.openclaw.ai - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. -- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. -- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc. ## 2026.3.8 From c58fffdab6541cd332a054d4ee5f0728075dc8aa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 01:39:43 -0400 Subject: [PATCH 08/13] Terminal: refine table wrapping and width handling --- src/terminal/table.test.ts | 32 ++++++++++++++++++++++++++++++++ src/terminal/table.ts | 26 ++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index cc7e110ea40..f6efea97609 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -83,6 +83,38 @@ describe("renderTable", () => { } }); + it("trims leading spaces on wrapped ANSI-colored continuation lines", () => { + const out = renderTable({ + width: 113, + columns: [ + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Skill", header: "Skill", minWidth: 18, flex: true }, + { key: "Description", header: "Description", minWidth: 24, flex: true }, + { key: "Source", header: "Source", minWidth: 10 }, + ], + rows: [ + { + Status: "โœ“ ready", + Skill: "๐ŸŒค๏ธ weather", + Description: + `\x1b[2mGet current weather and forecasts via wttr.in or Open-Meteo. ` + + `Use when: user asks about weather, temperature, or forecasts for any location.` + + `\x1b[0m`, + Source: "openclaw-bundled", + }, + ], + }); + + const lines = out + .trimEnd() + .split("\n") + .filter((line) => line.includes("Use when")); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("\u001b[2mUse when"); + expect(lines[0]).not.toContain("โ”‚ Use when"); + expect(lines[0]).not.toContain("โ”‚ \x1b[2m Use when"); + }); + 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 6f00b1b2064..2945e47019c 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -151,6 +151,20 @@ function wrapLine(text: string, width: number): string[] { lines.push(cleaned); }; + const trimLeadingSpaces = (tokens: Token[]) => { + while (true) { + const firstCharIndex = tokens.findIndex((token) => token.kind === "char"); + if (firstCharIndex < 0) { + return; + } + const firstChar = tokens[firstCharIndex]; + if (!firstChar || !isSpaceChar(firstChar.value)) { + return; + } + tokens.splice(firstCharIndex, 1); + } + }; + const flushAt = (breakAt: number | null) => { if (buf.length === 0) { return; @@ -166,10 +180,7 @@ function wrapLine(text: string, width: number): string[] { const left = buf.slice(0, breakAt); const rest = buf.slice(breakAt); pushLine(bufToString(left)); - - while (rest.length > 0 && rest[0]?.kind === "char" && isSpaceChar(rest[0].value)) { - rest.shift(); - } + trimLeadingSpaces(rest); buf.length = 0; buf.push(...rest); @@ -201,6 +212,9 @@ function wrapLine(text: string, width: number): string[] { if (bufVisible + charWidth > width && bufVisible > 0) { flushAt(lastBreakIndex); } + if (bufVisible === 0 && isSpaceChar(ch)) { + continue; + } buf.push(token); bufVisible += charWidth; @@ -234,6 +248,10 @@ function normalizeWidth(n: number | undefined): number | undefined { return Math.floor(n); } +export function getTerminalTableWidth(minWidth = 60, fallbackWidth = 120): number { + return Math.max(minWidth, process.stdout.columns ?? fallbackWidth); +} + export function renderTable(opts: RenderTableOptions): string { const rows = opts.rows.map((row) => { const next: Record = {}; From 209decf25cd7db618248bccb727cf76138db823f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 01:40:01 -0400 Subject: [PATCH 09/13] Terminal: stop shrinking CLI tables by one column --- src/cli/devices-cli.ts | 6 +++--- src/cli/directory-cli.ts | 6 +++--- src/cli/dns-cli.ts | 4 ++-- src/cli/exec-approvals-cli.ts | 4 ++-- src/cli/hooks-cli.ts | 4 ++-- src/cli/nodes-cli/register.camera.ts | 4 ++-- src/cli/nodes-cli/register.pairing.ts | 3 ++- src/cli/nodes-cli/register.status.ts | 8 ++++---- src/cli/pairing-cli.ts | 4 ++-- src/cli/plugins-cli.ts | 4 ++-- src/cli/skills-cli.format.ts | 8 ++++---- src/cli/update-cli/status.ts | 4 ++-- src/commands/message-format.ts | 4 ++-- src/commands/models/list.status-command.ts | 4 ++-- src/commands/status-all/report-lines.ts | 4 ++-- src/commands/status.command.ts | 4 ++-- 16 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 0344bf7967a..143d27b20ff 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -9,7 +9,7 @@ import { } from "../infra/device-pairing.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { withProgress } from "./progress.js"; @@ -224,7 +224,7 @@ export function registerDevicesCli(program: Command) { return; } if (list.pending?.length) { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log( `${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`, ); @@ -251,7 +251,7 @@ export function registerDevicesCli(program: Command) { ); } if (list.paired?.length) { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log( `${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`, ); diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index d11867fbb40..1a9949f224a 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -6,7 +6,7 @@ import { danger } from "../globals.js"; import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatHelpExamples } from "./help-format.js"; @@ -48,7 +48,7 @@ function printDirectoryList(params: { return; } - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log(`${theme.heading(params.title)} ${theme.muted(`(${params.entries.length})`)}`); defaultRuntime.log( renderTable({ @@ -166,7 +166,7 @@ export function registerDirectoryCli(program: Command) { defaultRuntime.log(theme.muted("Not available.")); return; } - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log(theme.heading("Self")); defaultRuntime.log( renderTable({ diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts index de6e6c0dec0..f9781d2f38e 100644 --- a/src/cli/dns-cli.ts +++ b/src/cli/dns-cli.ts @@ -7,7 +7,7 @@ import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet import { getWideAreaZonePath, resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; type RunOpts = { allowFailure?: boolean; inherit?: boolean }; @@ -133,7 +133,7 @@ export function registerDnsCli(program: Command) { } const zonePath = getWideAreaZonePath(wideAreaDomain); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log(theme.heading("DNS setup")); defaultRuntime.log( renderTable({ diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 07fe5a462a6..c243fb7a0aa 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -10,7 +10,7 @@ import { import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { isRich, theme } from "../terminal/theme.js"; import { describeUnknownError } from "./gateway-cli/shared.js"; import { callGatewayFromCli } from "./gateway-rpc.js"; @@ -151,7 +151,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s const rich = isRich(); const heading = (text: string) => (rich ? theme.heading(text) : text); const muted = (text: string) => (rich ? theme.muted(text) : text); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const file = snapshot.file ?? { version: 1 }; const defaults = file.defaults ?? {}; diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 7ea0de030da..85aa0d0e4b9 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -22,7 +22,7 @@ import { resolveArchiveKind } from "../infra/archive.js"; import { buildPluginStatusReport } from "../plugins/status.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; @@ -273,7 +273,7 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions } const eligible = hooks.filter((h) => h.eligible); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = hooks.map((hook) => { const missing = formatHookMissingSummary(hook); return { diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index 3bd7d1203dc..82cde2a35f3 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { shortenHomePath } from "../../utils.js"; import { type CameraFacing, @@ -71,7 +71,7 @@ export function registerNodesCameraCommands(nodes: Command) { } const { heading, muted } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = devices.map((device) => ({ Name: typeof device.name === "string" ? device.name : "Unknown Camera", Position: typeof device.position === "string" ? device.position : muted("unspecified"), diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index b20c989c1c7..fd649fae754 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; +import { getTerminalTableWidth } from "../../terminal/table.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { parsePairingList } from "./format.js"; import { renderPendingPairingRequestsTable } from "./pairing-render.js"; @@ -25,7 +26,7 @@ export function registerNodesPairingCommands(nodes: Command) { return; } const { heading, warn, muted } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const rendered = renderPendingPairingRequestsTable({ pending, diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 4dcb3be8e38..03e00cbbec4 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { shortenHomeInString } from "../../utils.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; @@ -112,7 +112,7 @@ export function registerNodesStatusCommands(nodes: Command) { const obj: Record = typeof result === "object" && result !== null ? result : {}; const { ok, warn, muted } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const nodes = parseNodeList(result); const lastConnectedById = @@ -256,7 +256,7 @@ export function registerNodesStatusCommands(nodes: Command) { const status = `${paired ? ok("paired") : warn("unpaired")} ยท ${ connected ? ok("connected") : muted("disconnected") }`; - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = [ { Field: "ID", Value: nodeId }, displayName ? { Field: "Name", Value: displayName } : null, @@ -307,7 +307,7 @@ export function registerNodesStatusCommands(nodes: Command) { const result = await callGatewayCli("node.pair.list", opts, {}); const { pending, paired } = parsePairingList(result); const { heading, muted, warn } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const hasFilters = connectedOnly || sinceMs !== undefined; const pendingRows = hasFilters ? [] : pending; diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index 6974663bd49..7c8cbc750ea 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -10,7 +10,7 @@ import { } from "../pairing/pairing-store.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatCliCommand } from "./command-format.js"; @@ -88,7 +88,7 @@ export function registerPairingCli(program: Command) { return; } const idLabel = resolvePairingIdLabel(channel); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log( `${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`, ); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 36e198c71a2..e77d7026875 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -19,7 +19,7 @@ import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uni import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; @@ -404,7 +404,7 @@ export function registerPluginsCli(program: Command) { ); if (!opts.verbose) { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const sourceRoots = resolvePluginSourceRoots({ workspaceDir: report.workspaceDir, }); diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index 5f6dcfdcd2a..dc335fb6c21 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -1,5 +1,5 @@ import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; @@ -39,7 +39,7 @@ function formatSkillStatus(skill: SkillStatusEntry): string { } function formatSkillName(skill: SkillStatusEntry): string { - const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const emoji = (skill.emoji ?? "๐Ÿ“ฆ").replaceAll("\uFE0E", "\uFE0F"); return `${emoji} ${theme.command(skill.name)}`; } @@ -95,7 +95,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti } const eligible = skills.filter((s) => s.eligible); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = skills.map((skill) => { const missing = formatSkillMissingSummary(skill); return { @@ -109,7 +109,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti const columns = [ { key: "Status", header: "Status", minWidth: 10 }, - { key: "Skill", header: "Skill", minWidth: 18, flex: true }, + { key: "Skill", header: "Skill", minWidth: 22 }, { key: "Description", header: "Description", minWidth: 24, flex: true }, { key: "Source", header: "Source", minWidth: 10 }, ]; diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index 5cf2bf8af49..8266a1e5f21 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -10,7 +10,7 @@ import { } from "../../infra/update-channels.js"; import { checkUpdateStatus } from "../../infra/update-check.js"; import { defaultRuntime } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { theme } from "../../terminal/theme.js"; import { parseTimeoutMsOrExit, resolveUpdateRoot, type UpdateStatusOptions } from "./shared.js"; @@ -89,7 +89,7 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise (rich ? theme.muted(text) : text); const heading = (text: string) => (rich ? theme.heading(text) : text); - const width = Math.max(60, (process.stdout.columns ?? 120) - 1); + const width = getTerminalTableWidth(); const opts: FormatOpts = { width }; if (result.handledBy === "dry-run") { diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 59614e3f866..156860bb960 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -38,7 +38,7 @@ import { } from "../../infra/provider-usage.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { colorize, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; @@ -631,7 +631,7 @@ export async function modelsStatusCommand( if (probeSummary.results.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); } else { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const sorted = sortProbeResults(probeSummary.results); const statusColor = (status: string) => { if (status === "ok") { diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 152918029b5..751237360b4 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -1,5 +1,5 @@ import type { ProgressReporter } from "../../cli/progress.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { isRich, theme } from "../../terminal/theme.js"; import { groupChannelIssuesByChannel } from "./channel-issues.js"; import { appendStatusAllDiagnosis } from "./diagnosis.js"; @@ -57,7 +57,7 @@ export async function buildStatusAllReportLines(params: { const fail = (text: string) => (rich ? theme.error(text) : text); const muted = (text: string) => (rich ? theme.muted(text) : text); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const overview = renderTable({ width: tableWidth, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 0d412c9715a..7e68424c5a9 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -16,7 +16,7 @@ import { } from "../memory/status-format.js"; import type { RuntimeEnv } from "../runtime.js"; import { runSecurityAudit } from "../security/audit.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; @@ -229,7 +229,7 @@ export async function statusCommand( runtime.log(""); } - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); if (secretDiagnostics.length > 0) { runtime.log(theme.warn("Secret diagnostics:")); From f46913b83497bcb29888575ef2268865c42bd56c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 01:40:15 -0400 Subject: [PATCH 10/13] Skills: use Terminal-safe emoji in list output --- skills/eightctl/SKILL.md | 2 +- skills/gemini/SKILL.md | 2 +- skills/openai-image-gen/SKILL.md | 2 +- skills/openai-whisper-api/SKILL.md | 2 +- skills/openai-whisper/SKILL.md | 2 +- skills/sag/SKILL.md | 2 +- skills/sherpa-onnx-tts/SKILL.md | 2 +- skills/video-frames/SKILL.md | 2 +- skills/weather/SKILL.md | 2 +- skills/xurl/SKILL.md | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/skills/eightctl/SKILL.md b/skills/eightctl/SKILL.md index c3df81f628c..80a5f1f4bbb 100644 --- a/skills/eightctl/SKILL.md +++ b/skills/eightctl/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐ŸŽ›๏ธ", + "emoji": "๐Ÿ›Œ", "requires": { "bins": ["eightctl"] }, "install": [ diff --git a/skills/gemini/SKILL.md b/skills/gemini/SKILL.md index 70850a4c522..f573afd6ba6 100644 --- a/skills/gemini/SKILL.md +++ b/skills/gemini/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "โ™Š๏ธ", + "emoji": "โœจ", "requires": { "bins": ["gemini"] }, "install": [ diff --git a/skills/openai-image-gen/SKILL.md b/skills/openai-image-gen/SKILL.md index 5db45c2c0e5..5b12671b0b0 100644 --- a/skills/openai-image-gen/SKILL.md +++ b/skills/openai-image-gen/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐Ÿ–ผ๏ธ", + "emoji": "๐ŸŽจ", "requires": { "bins": ["python3"], "env": ["OPENAI_API_KEY"] }, "primaryEnv": "OPENAI_API_KEY", "install": diff --git a/skills/openai-whisper-api/SKILL.md b/skills/openai-whisper-api/SKILL.md index 798b679e3ea..c961f132f4c 100644 --- a/skills/openai-whisper-api/SKILL.md +++ b/skills/openai-whisper-api/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "โ˜๏ธ", + "emoji": "๐ŸŒ", "requires": { "bins": ["curl"], "env": ["OPENAI_API_KEY"] }, "primaryEnv": "OPENAI_API_KEY", }, diff --git a/skills/openai-whisper/SKILL.md b/skills/openai-whisper/SKILL.md index 1c9411a3ff6..c22e0d62252 100644 --- a/skills/openai-whisper/SKILL.md +++ b/skills/openai-whisper/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐ŸŽ™๏ธ", + "emoji": "๐ŸŽค", "requires": { "bins": ["whisper"] }, "install": [ diff --git a/skills/sag/SKILL.md b/skills/sag/SKILL.md index a12e8a6d628..f0f7047651c 100644 --- a/skills/sag/SKILL.md +++ b/skills/sag/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐Ÿ—ฃ๏ธ", + "emoji": "๐Ÿ”Š", "requires": { "bins": ["sag"], "env": ["ELEVENLABS_API_KEY"] }, "primaryEnv": "ELEVENLABS_API_KEY", "install": diff --git a/skills/sherpa-onnx-tts/SKILL.md b/skills/sherpa-onnx-tts/SKILL.md index 1628660637b..46f7ead58da 100644 --- a/skills/sherpa-onnx-tts/SKILL.md +++ b/skills/sherpa-onnx-tts/SKILL.md @@ -5,7 +5,7 @@ metadata: { "openclaw": { - "emoji": "๐Ÿ—ฃ๏ธ", + "emoji": "๐Ÿ”‰", "os": ["darwin", "linux", "win32"], "requires": { "env": ["SHERPA_ONNX_RUNTIME_DIR", "SHERPA_ONNX_MODEL_DIR"] }, "install": diff --git a/skills/video-frames/SKILL.md b/skills/video-frames/SKILL.md index 0aca9fbd199..93a550a6fc9 100644 --- a/skills/video-frames/SKILL.md +++ b/skills/video-frames/SKILL.md @@ -6,7 +6,7 @@ metadata: { "openclaw": { - "emoji": "๐ŸŽž๏ธ", + "emoji": "๐ŸŽฌ", "requires": { "bins": ["ffmpeg"] }, "install": [ diff --git a/skills/weather/SKILL.md b/skills/weather/SKILL.md index 3daedf90f25..8d463be0b6a 100644 --- a/skills/weather/SKILL.md +++ b/skills/weather/SKILL.md @@ -2,7 +2,7 @@ name: weather description: "Get current weather and forecasts via wttr.in or Open-Meteo. Use when: user asks about weather, temperature, or forecasts for any location. NOT for: historical weather data, severe weather alerts, or detailed meteorological analysis. No API key needed." homepage: https://wttr.in/:help -metadata: { "openclaw": { "emoji": "๐ŸŒค๏ธ", "requires": { "bins": ["curl"] } } } +metadata: { "openclaw": { "emoji": "โ˜”", "requires": { "bins": ["curl"] } } } --- # Weather Skill diff --git a/skills/xurl/SKILL.md b/skills/xurl/SKILL.md index cf76bf158ad..1d74d6de3ee 100644 --- a/skills/xurl/SKILL.md +++ b/skills/xurl/SKILL.md @@ -5,7 +5,7 @@ metadata: { "openclaw": { - "emoji": "๐•", + "emoji": "๐Ÿฆ", "requires": { "bins": ["xurl"] }, "install": [ From ad7db1cc0619452ef8dabb30e7b156a1f3963ae6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 09:05:20 -0400 Subject: [PATCH 11/13] Changelog: note terminal skills table fixes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index adc8b8d6e16..992cbc7de4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,7 @@ Docs: https://docs.openclaw.ai - MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. - Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. - Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey. +- CLI/skills tables: keep terminal table borders aligned for wide graphemes, use full reported terminal width, and switch a few ambiguous skill icons to Terminal-safe emoji so `openclaw skills` renders more consistently in Terminal.app and iTerm. Thanks @vincentkoc. ## 2026.3.7 From accabda65c75badcda49e04fab9421741ea222eb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 09:11:20 -0400 Subject: [PATCH 12/13] Skills: normalize emoji presentation across outputs --- src/cli/skills-cli.format.ts | 12 ++++++++---- src/cli/skills-cli.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index dc335fb6c21..580f17b2d40 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -38,8 +38,12 @@ function formatSkillStatus(skill: SkillStatusEntry): string { return theme.error("โœ— missing"); } +function normalizeSkillEmoji(emoji?: string): string { + return (emoji ?? "๐Ÿ“ฆ").replaceAll("\uFE0E", "\uFE0F"); +} + function formatSkillName(skill: SkillStatusEntry): string { - const emoji = (skill.emoji ?? "๐Ÿ“ฆ").replaceAll("\uFE0E", "\uFE0F"); + const emoji = normalizeSkillEmoji(skill.emoji); return `${emoji} ${theme.command(skill.name)}`; } @@ -154,7 +158,7 @@ export function formatSkillInfo( } const lines: string[] = []; - const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const emoji = normalizeSkillEmoji(skill.emoji); const status = skill.eligible ? theme.success("โœ“ Ready") : skill.disabled @@ -282,7 +286,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp lines.push(""); lines.push(theme.heading("Ready to use:")); for (const skill of eligible) { - const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const emoji = normalizeSkillEmoji(skill.emoji); lines.push(` ${emoji} ${skill.name}`); } } @@ -291,7 +295,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp lines.push(""); lines.push(theme.heading("Missing requirements:")); for (const skill of missingReqs) { - const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const emoji = normalizeSkillEmoji(skill.emoji); const missing = formatSkillMissingSummary(skill); lines.push(` ${emoji} ${skill.name} ${theme.muted(`(${missing})`)}`); } diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 37323e7f21d..e87f8b2d313 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -148,6 +148,18 @@ describe("skills-cli", () => { expect(output).toContain("Any binaries"); expect(output).toContain("API_KEY"); }); + + it("normalizes text-presentation emoji selectors in info output", () => { + const report = createMockReport([ + createMockSkill({ + name: "info-emoji", + emoji: "๐ŸŽ›\uFE0E", + }), + ]); + + const output = formatSkillInfo(report, "info-emoji", {}); + expect(output).toContain("๐ŸŽ›๏ธ"); + }); }); describe("formatSkillsCheck", () => { @@ -170,6 +182,22 @@ describe("skills-cli", () => { expect(output).toContain("go"); // missing binary expect(output).toContain("npx clawhub"); }); + + it("normalizes text-presentation emoji selectors in check output", () => { + const report = createMockReport([ + createMockSkill({ name: "ready-emoji", emoji: "๐ŸŽ›\uFE0E", eligible: true }), + createMockSkill({ + name: "missing-emoji", + emoji: "๐ŸŽ™\uFE0E", + eligible: false, + missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, + }), + ]); + + const output = formatSkillsCheck(report, {}); + expect(output).toContain("๐ŸŽ›๏ธ ready-emoji"); + expect(output).toContain("๐ŸŽ™๏ธ missing-emoji"); + }); }); describe("JSON output", () => { From 361f3109a50a7029cf26e10ae283cd809cccb8ed Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 11 Mar 2026 09:11:25 -0400 Subject: [PATCH 13/13] Terminal: consume unsupported escape bytes in tables --- src/terminal/table.test.ts | 14 ++++++++++++++ src/terminal/table.ts | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index f6efea97609..9c6d53eaece 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -156,6 +156,20 @@ describe("renderTable", () => { expect(visibleWidth(line)).toBe(width); } }); + + it("consumes unsupported escape sequences without hanging", () => { + const out = renderTable({ + width: 48, + columns: [ + { key: "K", header: "K", minWidth: 6 }, + { key: "V", header: "V", minWidth: 12, flex: true }, + ], + rows: [{ K: "row", V: "before \x1b[2J after" }], + }); + + expect(out).toContain("before"); + expect(out).toContain("after"); + }); }); describe("wrapNoteMessage", () => { diff --git a/src/terminal/table.ts b/src/terminal/table.ts index 2945e47019c..a1fbb9f570b 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -98,6 +98,13 @@ function wrapLine(text: string, width: number): string[] { if (nextEsc < 0) { nextEsc = text.length; } + if (nextEsc === i) { + // Consume unsupported escape bytes as plain characters so wrapping + // cannot stall on unknown ANSI/control sequences. + tokens.push({ kind: "char", value: ESC }); + i += ESC.length; + continue; + } const plainChunk = text.slice(i, nextEsc); for (const grapheme of splitGraphemes(plainChunk)) { tokens.push({ kind: "char", value: grapheme });