mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-11 23:10:29 +00:00
fix(terminal): stabilize skills table width across Terminal.app and iTerm (#42849)
* Terminal: measure grapheme display width * Tests: cover grapheme terminal width * Terminal: wrap table cells by grapheme width * Tests: cover emoji table alignment * Terminal: refine table wrapping and width handling * Terminal: stop shrinking CLI tables by one column * Skills: use Terminal-safe emoji in list output * Changelog: note terminal skills table fixes * Skills: normalize emoji presentation across outputs * Terminal: consume unsupported escape bytes in tables
This commit is contained in:
@@ -168,6 +168,7 @@ Docs: https://docs.openclaw.ai
|
||||
- ACP/cancel scoping: scope `chat.abort` and shared-session ACP event routing by `runId` so one session cannot cancel or consume another session's run when they share the same gateway session key. (#41331) Thanks @pejmanjohn.
|
||||
- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant.
|
||||
- macOS/browser proxy: serialize non-GET browser proxy request bodies through `AnyCodable.foundationValue` so nested JSON bodies no longer crash the macOS app with `Invalid type in JSON write (__SwiftValue)`. (#43069) Thanks @Effet.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "🎛️",
|
||||
"emoji": "🛌",
|
||||
"requires": { "bins": ["eightctl"] },
|
||||
"install":
|
||||
[
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "♊️",
|
||||
"emoji": "✨",
|
||||
"requires": { "bins": ["gemini"] },
|
||||
"install":
|
||||
[
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "🖼️",
|
||||
"emoji": "🎨",
|
||||
"requires": { "bins": ["python3"], "env": ["OPENAI_API_KEY"] },
|
||||
"primaryEnv": "OPENAI_API_KEY",
|
||||
"install":
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "☁️",
|
||||
"emoji": "🌐",
|
||||
"requires": { "bins": ["curl"], "env": ["OPENAI_API_KEY"] },
|
||||
"primaryEnv": "OPENAI_API_KEY",
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "🎙️",
|
||||
"emoji": "🎤",
|
||||
"requires": { "bins": ["whisper"] },
|
||||
"install":
|
||||
[
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "🗣️",
|
||||
"emoji": "🔊",
|
||||
"requires": { "bins": ["sag"], "env": ["ELEVENLABS_API_KEY"] },
|
||||
"primaryEnv": "ELEVENLABS_API_KEY",
|
||||
"install":
|
||||
|
||||
@@ -5,7 +5,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "🗣️",
|
||||
"emoji": "🔉",
|
||||
"os": ["darwin", "linux", "win32"],
|
||||
"requires": { "env": ["SHERPA_ONNX_RUNTIME_DIR", "SHERPA_ONNX_MODEL_DIR"] },
|
||||
"install":
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "🎞️",
|
||||
"emoji": "🎬",
|
||||
"requires": { "bins": ["ffmpeg"] },
|
||||
"install":
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "𝕏",
|
||||
"emoji": "🐦",
|
||||
"requires": { "bins": ["xurl"] },
|
||||
"install":
|
||||
[
|
||||
|
||||
@@ -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})`)}`,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> =
|
||||
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;
|
||||
|
||||
@@ -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})`)}`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -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 ?? "📦";
|
||||
const emoji = normalizeSkillEmoji(skill.emoji);
|
||||
return `${emoji} ${theme.command(skill.name)}`;
|
||||
}
|
||||
|
||||
@@ -95,7 +99,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 +113,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 },
|
||||
];
|
||||
@@ -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})`)}`);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<vo
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const tableWidth = getTerminalTableWidth();
|
||||
const installLabel =
|
||||
update.installKind === "git"
|
||||
? `git (${update.root ?? "unknown"})`
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
|
||||
import { formatGatewaySummary, formatOutboundDeliverySummary } from "../infra/outbound/format.js";
|
||||
import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js";
|
||||
import { formatTargetDisplay } from "../infra/outbound/target-resolver.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { shortenText } from "./text-format.js";
|
||||
|
||||
@@ -257,7 +257,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
|
||||
const muted = (text: string) => (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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -99,6 +131,45 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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,22 @@ 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;
|
||||
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 });
|
||||
}
|
||||
i = nextEsc;
|
||||
}
|
||||
|
||||
const firstCharIndex = tokens.findIndex((t) => t.kind === "char");
|
||||
@@ -139,7 +148,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+$/, "");
|
||||
@@ -149,6 +158,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;
|
||||
@@ -164,10 +187,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);
|
||||
@@ -195,12 +215,16 @@ 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);
|
||||
}
|
||||
if (bufVisible === 0 && isSpaceChar(ch)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buf.push(token);
|
||||
bufVisible += 1;
|
||||
bufVisible += charWidth;
|
||||
if (isBreakChar(ch)) {
|
||||
lastBreakIndex = buf.length;
|
||||
}
|
||||
@@ -231,6 +255,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<string, string> = {};
|
||||
|
||||
Reference in New Issue
Block a user