Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
// Terminal Core tests cover ansi behavior.
import { describe, expect, it } from "vitest";
import {
sanitizeForLog,
splitGraphemes,
stripAnsi,
stripAnsiSequences,
truncateToVisibleWidth,
visibleWidth,
} from "./ansi.js";
describe("terminal ansi helpers", () => {
it("strips ANSI and OSC8 sequences", () => {
expect(stripAnsi("\u001B[31mred\u001B[0m")).toBe("red");
expect(stripAnsi("\u001B[2K\u001B[1Ared")).toBe("red");
expect(stripAnsi("\u001B]8;;https://openclaw.ai\u001B\\link\u001B]8;;\u001B\\")).toBe("link");
expect(stripAnsi("\u001B]8;;https://openclaw.ai\u0007link\u001B]8;;\u0007")).toBe("link");
expect(stripAnsi("copy\u001B]52;c;YWJj\u0007safe")).toBe("copysafe");
});
it("strips the agent output escape grammar without changing text policy", () => {
expect(stripAnsiSequences("\u001B[38:5:196mred\u001B[0m")).toBe("red");
expect(stripAnsiSequences("\u009B31mred\u009B0m")).toBe("red");
expect(stripAnsiSequences("\u001B]8;;https://openclaw.ai\u009Clink\u001B]8;;\u0007")).toBe(
"link",
);
expect(stripAnsiSequences("line\n\t🙂\u001B]unterminated")).toBe("line\n\t🙂nterminated");
expect(() => stripAnsiSequences(null as never)).toThrow("Expected a `string`, got `object`");
});
it("sanitizes control characters for log-safe interpolation", () => {
const input =
"\u001B[31mwarn\u001B[0m" +
"\r\n" +
"next" +
String.fromCharCode(0) +
"line" +
String.fromCharCode(127) +
String.fromCharCode(0x9b) +
"done";
expect(sanitizeForLog(input)).toBe("warnnextlinedone");
});
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);
});
it("truncates to a visible-width budget without splitting wide graphemes", () => {
expect(truncateToVisibleWidth("abc", 2)).toBe("ab");
expect(truncateToVisibleWidth("abc", 5)).toBe("abc");
expect(truncateToVisibleWidth("anything", 0)).toBe("");
// A wide grapheme that cannot fit the remaining budget is dropped whole,
// never emitted half-width, so the result never exceeds the budget.
expect(truncateToVisibleWidth("表文", 2)).toBe("表");
expect(truncateToVisibleWidth("表", 1)).toBe("");
expect(visibleWidth(truncateToVisibleWidth("📸📸", 1))).toBeLessThanOrEqual(1);
});
it("preserves ANSI sequences when truncating styled text", () => {
// Trailing reset is retained even when its grapheme is dropped, so the cell
// does not bleed styling into surrounding padding.
expect(truncateToVisibleWidth("ab", 1)).toBe("a");
expect(truncateToVisibleWidth("表文", 1)).toBe("");
expect(visibleWidth(truncateToVisibleWidth("表文", 1))).toBe(0);
});
it("reuses the ANSI scanner across truncation calls", () => {
expect(truncateToVisibleWidth("\u001B[31mabc\u001B[0m", 2)).toBe("\u001B[31mab\u001B[0m");
expect(truncateToVisibleWidth("plain", 3)).toBe("pla");
expect(
truncateToVisibleWidth("\u001B]8;;https://openclaw.ai\u001B\\link\u001B]8;;\u001B\\", 2),
).toBe("\u001B]8;;https://openclaw.ai\u001B\\li\u001B]8;;\u001B\\");
expect(truncateToVisibleWidth("\u001B[32mxy\u001B[0m", 1)).toBe("\u001B[32mx\u001B[0m");
});
});

View File

@@ -0,0 +1,212 @@
// Full CSI: ESC [ <params> <final byte> covers cursor movement, erase, and SGR.
const ANSI_CSI_PATTERN = "\\x1b\\[[\\x20-\\x3f]*[\\x40-\\x7e]";
// OSC: ESC ] <payload> ST. Covers OSC-8 hyperlinks and clipboard/title escapes.
// ST can be either ESC \ or BEL.
const ANSI_OSC_PATTERN = "\\x1b\\][^\\x07\\x1b]*(?:\\x1b\\\\|\\x07)";
const ANSI_CSI_REGEX = new RegExp(ANSI_CSI_PATTERN, "g");
const ANSI_OSC_REGEX = new RegExp(ANSI_OSC_PATTERN, "g");
const ANSI_SEQUENCE_REGEX = new RegExp(`${ANSI_OSC_PATTERN}|${ANSI_CSI_PATTERN}`, "g");
/*
* The following compatibility grammar is derived from ansi-regex and strip-ansi.
*
* MIT License
*
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const ANSI_STRING_TERMINATOR_PATTERN = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
const ANSI_OSC_SEQUENCE_PATTERN = `(?:\\u001B\\][\\s\\S]*?${ANSI_STRING_TERMINATOR_PATTERN})`;
const ANSI_CONTROL_SEQUENCE_PATTERN =
"[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
const ANSI_COMPAT_SEQUENCE_REGEX = new RegExp(
`${ANSI_OSC_SEQUENCE_PATTERN}|${ANSI_CONTROL_SEQUENCE_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(ANSI_OSC_REGEX, "").replace(ANSI_CSI_REGEX, "");
}
export function stripAnsiSequences(input: string): string {
if (typeof input !== "string") {
throw new TypeError(`Expected a \`string\`, got \`${typeof input}\``);
}
if (!input.includes("\u001B") && !input.includes("\u009B")) {
return input;
}
return input.replace(ANSI_COMPAT_SEQUENCE_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/C1 control characters, and DEL to
* prevent log forging / terminal escape injection (CWE-117).
*/
export function sanitizeForLog(v: string): string {
// Pattern built at runtime so the source file stays free of literal control
// characters AND the linter cannot statically detect them (no-control-regex).
const c0Start = String.fromCharCode(0x00);
const c0End = String.fromCharCode(0x1f);
const del = String.fromCharCode(0x7f);
const c1Start = String.fromCharCode(0x80);
const c1End = String.fromCharCode(0x9f);
const controlCharsRegex = new RegExp(`[${c0Start}-${c0End}${del}${c1Start}-${c1End}]`, "g");
return stripAnsi(v).replace(controlCharsRegex, "");
}
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,
);
}
/**
* Truncate to at most `maxWidth` visible columns, dropping whole grapheme
* clusters that would overflow while preserving ANSI sequences verbatim
* (they have zero visible width). A single wide grapheme that cannot fit the
* remaining budget is dropped rather than emitted partially, so the result is
* always `visibleWidth(result) <= maxWidth`. Callers that need a fixed width
* pad the (possibly short) remainder themselves.
*/
export function truncateToVisibleWidth(input: string, maxWidth: number): string {
if (maxWidth <= 0) {
return "";
}
if (visibleWidth(input) <= maxWidth) {
return input;
}
ANSI_SEQUENCE_REGEX.lastIndex = 0;
let out = "";
let used = 0;
let pos = 0;
// Once the visible budget is spent we stop emitting graphemes but keep
// copying ANSI sequences, so trailing resets/link-closes still land and the
// truncated cell does not bleed styling into the padding or border.
let budgetSpent = false;
const appendVisible = (segment: string): void => {
if (budgetSpent) {
return;
}
for (const grapheme of splitGraphemes(segment)) {
const width = graphemeWidth(grapheme);
if (used + width > maxWidth) {
budgetSpent = true;
return;
}
out += grapheme;
used += width;
}
};
let match: RegExpExecArray | null;
while ((match = ANSI_SEQUENCE_REGEX.exec(input)) !== null) {
appendVisible(input.slice(pos, match.index));
out += match[0];
pos = match.index + match[0].length;
}
appendVisible(input.slice(pos));
return out;
}

View File

@@ -0,0 +1,89 @@
// Terminal Core tests cover decorative emoji behavior.
import { describe, expect, it } from "vitest";
import {
decorativeEmoji,
decorativePrefix,
stripDecorativeEmojiForTerminal,
supportsDecorativeEmoji,
} from "./decorative-emoji.js";
describe("decorative emoji terminal helpers", () => {
it("disables decorative emoji without a TTY", () => {
expect(supportsDecorativeEmoji({ env: { TERM: "xterm-256color" }, isTty: false })).toBe(false);
});
it("disables decorative emoji for dumb or non-UTF-8 terminals", () => {
expect(
supportsDecorativeEmoji({ env: { TERM: "dumb", LANG: "en_US.UTF-8" }, isTty: true }),
).toBe(false);
expect(
supportsDecorativeEmoji({
env: { TERM: "xterm-256color", LANG: "C" },
isTty: true,
platform: "darwin",
}),
).toBe(false);
});
it("keeps emoji for known-good terminal programs", () => {
expect(
supportsDecorativeEmoji({
env: { TERM_PROGRAM: "WezTerm", LANG: "en_US.UTF-8" },
isTty: true,
platform: "linux",
}),
).toBe(true);
});
it("keeps emoji on macOS and drops it on generic Linux terminals", () => {
expect(
supportsDecorativeEmoji({
env: { TERM: "xterm-256color", LANG: "en_US.UTF-8" },
isTty: true,
platform: "darwin",
}),
).toBe(true);
expect(
supportsDecorativeEmoji({
env: { TERM: "xterm-256color", LANG: "en_US.UTF-8" },
isTty: true,
platform: "linux",
}),
).toBe(false);
});
it("formats decorative emoji prefixes conservatively", () => {
const badTerminal = {
env: { TERM: "xterm-256color", LANG: "en_US.UTF-8" },
isTty: true,
platform: "linux" as const,
};
const goodTerminal = {
env: { TERM_PROGRAM: "ghostty", LANG: "en_US.UTF-8" },
isTty: true,
};
expect(decorativeEmoji("🦞", badTerminal)).toBe("");
expect(decorativePrefix("🦞", "OpenClaw", badTerminal)).toBe("OpenClaw");
expect(decorativePrefix("🦞", "OpenClaw", goodTerminal)).toBe("🦞 OpenClaw");
});
it("strips decorative emoji from curated terminal text only when unsupported", () => {
const badTerminal = {
env: { TERM: "xterm-256color", LANG: "en_US.UTF-8" },
isTty: true,
platform: "linux" as const,
};
const goodTerminal = {
env: { TERM_PROGRAM: "iTerm.app", LANG: "en_US.UTF-8" },
isTty: true,
};
expect(stripDecorativeEmojiForTerminal("The lobster in your shell. 🦞", badTerminal)).toBe(
"The lobster in your shell.",
);
expect(stripDecorativeEmojiForTerminal("The lobster in your shell. 🦞", goodTerminal)).toBe(
"The lobster in your shell. 🦞",
);
});
});

View File

@@ -0,0 +1,95 @@
// Terminal Core module implements decorative emoji behavior.
import { splitGraphemes } from "./ansi.js";
// Decorative emoji helpers that degrade cleanly on terminals without reliable emoji support.
/** Environment and terminal facts used to decide decorative emoji support. */
export type DecorativeEmojiOptions = {
env?: NodeJS.ProcessEnv;
isTty?: boolean;
platform?: NodeJS.Platform;
stream?: { isTTY?: boolean };
};
const EMOJI_GRAPHEME_PATTERN = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u;
/** Detect terminals with known emoji rendering support. */
function isKnownEmojiTerminal(env: NodeJS.ProcessEnv): boolean {
const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase();
const term = (env.TERM ?? "").toLowerCase();
return (
termProgram.includes("iterm") ||
termProgram.includes("apple_terminal") ||
termProgram.includes("ghostty") ||
termProgram.includes("wezterm") ||
termProgram.includes("vscode") ||
term.includes("ghostty") ||
term.includes("wezterm") ||
Boolean(env.WT_SESSION)
);
}
/** Return true when locale variables indicate UTF-8 output support. */
function hasUtf8Locale(env: NodeJS.ProcessEnv): boolean {
const locale = [env.LC_ALL, env.LC_CTYPE, env.LANG].find(
(value) => typeof value === "string" && value.trim().length > 0,
);
if (!locale) {
return true;
}
return /utf-?8/i.test(locale);
}
/** Return true when decorative emoji should be emitted for the target terminal. */
export function supportsDecorativeEmoji(options: DecorativeEmojiOptions = {}): boolean {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const isTty = options.isTty ?? options.stream?.isTTY ?? process.stdout.isTTY;
if (!isTty) {
return false;
}
if ((env.TERM ?? "").toLowerCase() === "dumb") {
return false;
}
if (!hasUtf8Locale(env)) {
return false;
}
if (isKnownEmojiTerminal(env)) {
return true;
}
if (platform === "darwin") {
return true;
}
return false;
}
/** Return the emoji only when decorative emoji output is supported. */
export function decorativeEmoji(emoji: string, options: DecorativeEmojiOptions = {}): string {
return supportsDecorativeEmoji(options) ? emoji : "";
}
/** Prefix text with a decorative emoji when supported. */
export function decorativePrefix(
emoji: string,
text: string,
options: DecorativeEmojiOptions = {},
): string {
const prefix = decorativeEmoji(emoji, options);
return prefix ? `${prefix} ${text}` : text;
}
/** Strip decorative emoji for terminals that should not receive them. */
export function stripDecorativeEmojiForTerminal(
text: string,
options: DecorativeEmojiOptions = {},
): string {
if (supportsDecorativeEmoji(options)) {
return text;
}
return splitGraphemes(text)
.filter((grapheme) => !EMOJI_GRAPHEME_PATTERN.test(grapheme))
.join("")
.replace(/\s{2,}/g, " ")
.trim();
}

View File

@@ -0,0 +1,47 @@
// Terminal Core tests cover display-safe path shortening.
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { displayString } from "./display-string.js";
function stubHome(home: string, openclawHome = ""): void {
vi.stubEnv("HOME", home);
vi.stubEnv("USERPROFILE", "");
vi.stubEnv("OPENCLAW_HOME", openclawHome);
}
describe("displayString", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("shortens whole-value homes and child paths without clipping sibling prefixes", () => {
const home = path.resolve("test-home", "alice");
stubHome(home);
expect(displayString(home)).toBe("~");
expect(displayString(`${home}/project`)).toBe("~/project");
expect(displayString(`${home}\\project`)).toBe("~\\project");
expect(displayString(`Workspace: ${home}/project`)).toBe("Workspace: ~/project");
expect(displayString(`${home}/one ${home}/two`)).toBe("~/one ~/two");
expect(displayString(`Home: ${home},`)).toBe("Home: ~,");
expect(displayString(`(${home})`)).toBe("(~)");
expect(displayString(`${home}.`)).toBe("~.");
expect(displayString(`${home}2/project`)).toBe(`${home}2/project`);
expect(displayString(`${home},backup`)).toBe(`${home},backup`);
expect(displayString(`${home} backup/project`)).toBe(`${home} backup/project`);
expect(displayString(`${home}../project`)).toBe(`${home}../project`);
expect(displayString(`prefix${home}/project`)).toBe(`prefix${home}/project`);
expect(displayString(`/tmp${home}/project`)).toBe(`/tmp${home}/project`);
});
it("uses OPENCLAW_HOME as the display prefix", () => {
const home = path.resolve("test-home", "alice");
const openclawHome = path.resolve("test-openclaw-home");
stubHome(home, openclawHome);
expect(displayString(openclawHome)).toBe("$OPENCLAW_HOME");
expect(displayString(`${openclawHome}/state`)).toBe("$OPENCLAW_HOME/state");
expect(displayString(`${openclawHome}2/state`)).toBe(`${openclawHome}2/state`);
});
});

View File

@@ -0,0 +1,118 @@
// Terminal Core module implements display string behavior.
import os from "node:os";
import path from "node:path";
// Display-safe string helpers for shortening user home paths.
/** Normalize env/home values and reject shell placeholder strings. */
function normalize(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed && trimmed !== "undefined" && trimmed !== "null" ? trimmed : undefined;
}
/** Run a home resolver defensively because some runtimes throw for missing passwd data. */
function normalizeSafe(fn: () => string | undefined): string | undefined {
try {
return normalize(fn());
} catch {
return undefined;
}
}
/** Resolve Termux home from its Android prefix layout. */
function resolveTermuxHome(env: NodeJS.ProcessEnv): string | undefined {
const prefix = normalize(env.PREFIX);
if (!prefix || !normalize(env.ANDROID_DATA)) {
return undefined;
}
if (!/(?:^|\/)com\.termux\/files\/usr\/?$/u.test(prefix.replace(/\\/gu, "/"))) {
return undefined;
}
return path.resolve(prefix, "..", "home");
}
/** Resolve the underlying OS home before applying OpenClaw overrides. */
function resolveRawOsHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined {
return (
normalize(env.HOME) ??
normalize(env.USERPROFILE) ??
resolveTermuxHome(env) ??
normalizeSafe(homedir)
);
}
/** Resolve raw home with OPENCLAW_HOME tilde expansion. */
function resolveRawHomeDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string | undefined {
const explicitHome = normalize(env.OPENCLAW_HOME);
if (explicitHome) {
const fallbackHome = resolveRawOsHomeDir(env, homedir);
return fallbackHome ? explicitHome.replace(/^~(?=$|[\\/])/, fallbackHome) : explicitHome;
}
return resolveRawOsHomeDir(env, homedir);
}
/** Resolve the effective absolute home directory for display replacement. */
function resolveEffectiveHomeDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string | undefined {
const raw = resolveRawHomeDir(env, homedir);
return raw ? path.resolve(raw) : undefined;
}
/** Resolve the display prefix that should replace the effective home path. */
function resolveHomeDisplayPrefix(): { home: string; prefix: string } | undefined {
const home = resolveEffectiveHomeDir();
if (!home) {
return undefined;
}
const explicitHome = process.env.OPENCLAW_HOME?.trim();
return explicitHome ? { home, prefix: "$OPENCLAW_HOME" } : { home, prefix: "~" };
}
/** Replace a whole-value home or child path without clipping sibling path prefixes. */
function replaceHomePath(input: string, display: { home: string; prefix: string }): string {
let output = "";
let cursor = 0;
while (cursor < input.length) {
const index = input.indexOf(display.home, cursor);
if (index < 0) {
return `${output}${input.slice(cursor)}`;
}
const before = input[index - 1];
const homeEnd = index + display.home.length;
const after = input[homeEnd];
const startsToken = before === undefined || /[\s("'`:=[{,]/u.test(before);
let punctuationEnd = homeEnd;
while (punctuationEnd < input.length && /[)"'`:,;.\]}]/u.test(input[punctuationEnd])) {
punctuationEnd += 1;
}
const punctuationEndsToken =
punctuationEnd > homeEnd &&
(punctuationEnd === input.length || /\s/u.test(input[punctuationEnd]));
const endsTokenOrContinuesPath =
after === undefined || after === "/" || after === "\\" || punctuationEndsToken;
if (startsToken && endsTokenOrContinuesPath) {
output += `${input.slice(cursor, index)}${display.prefix}`;
} else {
output += input.slice(cursor, index + display.home.length);
}
cursor = index + display.home.length;
}
return output;
}
/** Replace the effective home path with "~" or "$OPENCLAW_HOME" for terminal display. */
export function displayString(input: string): string {
if (!input) {
return input;
}
const display = resolveHomeDisplayPrefix();
return display ? replaceHomePath(input, display) : input;
}

View File

@@ -0,0 +1,48 @@
// Terminal Core module implements health style behavior.
import { normalizeLowercaseStringOrEmpty } from "./string.js";
import { theme } from "./theme.js";
// Styles the status word in health output lines.
/** Highlight known health status prefixes in a "label: detail" line. */
export function styleHealthChannelLine(line: string, rich: boolean): string {
if (!rich) {
return line;
}
const colon = line.indexOf(":");
if (colon === -1) {
return line;
}
const label = line.slice(0, colon + 1);
const detail = line.slice(colon + 1).trimStart();
const normalized = normalizeLowercaseStringOrEmpty(detail);
const applyPrefix = (prefix: string, color: (value: string) => string) =>
`${label} ${color(detail.slice(0, prefix.length))}${detail.slice(prefix.length)}`;
if (normalized.startsWith("failed")) {
return applyPrefix("failed", theme.error);
}
if (normalized.startsWith("ok")) {
return applyPrefix("ok", theme.success);
}
if (normalized.startsWith("linked")) {
return applyPrefix("linked", theme.success);
}
if (normalized.startsWith("configured")) {
return applyPrefix("configured", theme.success);
}
if (normalized.startsWith("not linked")) {
return applyPrefix("not linked", theme.warn);
}
if (normalized.startsWith("not configured")) {
return applyPrefix("not configured", theme.muted);
}
if (normalized.startsWith("unknown")) {
return applyPrefix("unknown", theme.warn);
}
return line;
}

View File

@@ -0,0 +1,19 @@
// Public barrel for shared terminal formatting helpers.
export * from "./ansi.js";
export * from "./decorative-emoji.js";
export * from "./health-style.js";
export * from "./links.js";
export * from "./note.js";
export * from "./osc-progress.js";
export * from "./palette.js";
export * from "./progress-line.js";
export * from "./prompt-select-styled.js";
export * from "./prompt-select-styled-params.js";
export * from "./prompt-style.js";
export * from "./restore.js";
export * from "./safe-text.js";
export * from "./stream-writer.js";
export * from "./table.js";
export * from "./terminal-link.js";
export * from "./theme.js";

View File

@@ -0,0 +1,48 @@
// Terminal Core tests cover links behavior.
import { describe, expect, it } from "vitest";
import { formatDocsLink } from "./links.js";
describe("formatDocsLink", () => {
it("prepends the docs root when given a relative path", () => {
const out = formatDocsLink("/channels/quietchat", "quietchat");
expect(out).toBe("https://docs.openclaw.ai/channels/quietchat");
});
it("preserves an absolute http url", () => {
const out = formatDocsLink("https://example.com/page", "page");
expect(out).toBe("https://example.com/page");
});
it("preserves uppercase absolute HTTPS urls", () => {
const out = formatDocsLink("HTTPS://example.com/page", "page");
expect(out).toBe("HTTPS://example.com/page");
});
it("does not treat http-prefixed relative paths as absolute urls", () => {
const out = formatDocsLink("http-status", "HTTP status");
expect(out).toBe("https://docs.openclaw.ai/http-status");
});
it("treats whitespace-only path like an empty path and falls back to docs root", () => {
const out = formatDocsLink(" ", "root");
expect(out).toBe("https://docs.openclaw.ai");
});
it("falls back to docs root when path is undefined (regression: #67076, #67074)", () => {
const out = formatDocsLink(undefined as unknown as string, "label");
expect(out).toBe("https://docs.openclaw.ai");
});
it("falls back to docs root when path is null", () => {
const out = formatDocsLink(null as unknown as string);
expect(out).toBe("https://docs.openclaw.ai");
});
it("strips terminal controls from non-OSC docs fallback text", () => {
const out = formatDocsLink("https://example.com/a\u0007b", "docs\u001b[31m", {
force: false,
});
expect(out).toBe("https://example.com/ab");
});
});

View File

@@ -0,0 +1,30 @@
// Terminal Core module implements links behavior.
import { formatTerminalLink } from "./terminal-link.js";
function resolveDocsRoot(): string {
return "https://docs.openclaw.ai";
}
const ABSOLUTE_HTTP_URL_RE = /^https?:\/\//i;
export function formatDocsLink(
path: string | undefined | null,
label?: string,
opts?: { fallback?: string; force?: boolean },
): string {
const docsRoot = resolveDocsRoot();
const trimmed = typeof path === "string" ? path.trim() : "";
// When a caller has no docsPath, link to the docs root rather than crashing
// the onboarding/channel-selection flows that pass meta.docsPath through
// here unguarded. The typed contract says docsPath is required, but a
// handful of channel plugins and catalog rows leave it unset at runtime.
const url = trimmed
? ABSOLUTE_HTTP_URL_RE.test(trimmed)
? trimmed
: `${docsRoot}${trimmed.startsWith("/") ? trimmed : `/${trimmed}`}`
: docsRoot;
return formatTerminalLink(label ?? url, url, {
fallback: opts?.fallback ?? url,
force: opts?.force,
});
}

View File

@@ -0,0 +1,239 @@
// Terminal Core module implements note behavior.
import { AsyncLocalStorage } from "node:async_hooks";
import { note as clackNote } from "@clack/prompts";
import { splitGraphemes, visibleWidth } from "./ansi.js";
import { stylePromptTitle } from "./prompt-style.js";
import { normalizeLowercaseStringOrEmpty } from "./string.js";
const MIN_NOTE_COLUMNS = 80;
const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i;
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
const FILE_LIKE_RE = /^[a-zA-Z0-9._-]+$/;
const suppressNotesStorage = new AsyncLocalStorage<boolean>();
function isSuppressedByEnv(value: string | undefined): boolean {
if (!value) {
return false;
}
const normalized = normalizeLowercaseStringOrEmpty(value);
if (!normalized) {
return false;
}
return normalized !== "0" && normalized !== "false" && normalized !== "off";
}
function splitLongWord(word: string, maxLen: number): string[] {
if (maxLen <= 0) {
return [word];
}
// maxLen is a visible-column budget, so accumulate grapheme visible width (CJK/emoji count as 2
// columns) instead of code-point count; otherwise a wide-char run overflows the line by up to 2x.
const parts: string[] = [];
let current = "";
let currentWidth = 0;
for (const grapheme of splitGraphemes(word)) {
const width = visibleWidth(grapheme);
if (current && currentWidth + width > maxLen) {
parts.push(current);
current = "";
currentWidth = 0;
}
current += grapheme;
currentWidth += width;
}
if (current) {
parts.push(current);
}
return parts.length > 0 ? parts : [word];
}
function isCopySensitiveToken(word: string): boolean {
if (!word) {
return false;
}
if (URL_PREFIX_RE.test(word)) {
return true;
}
if (
word.startsWith("/") ||
word.startsWith("~/") ||
word.startsWith("./") ||
word.startsWith("../")
) {
return true;
}
if (WINDOWS_DRIVE_RE.test(word) || word.startsWith("\\\\")) {
return true;
}
if (word.includes("/") || word.includes("\\")) {
return true;
}
// Preserve common file-like tokens (for example administrators_authorized_keys).
return word.includes("_") && FILE_LIKE_RE.test(word);
}
function pushWrappedWordSegments(params: {
word: string;
available: number;
firstPrefix: string;
continuationPrefix: string;
lines: string[];
}) {
const parts = splitLongWord(params.word, params.available);
const first = parts.shift() ?? "";
params.lines.push(params.firstPrefix + first);
for (const part of parts) {
params.lines.push(params.continuationPrefix + part);
}
}
function wrapLine(line: string, maxWidth: number): string[] {
if (line.trim().length === 0) {
return [line];
}
const match = line.match(/^(\s*)([-*\u2022]\s+)?(.*)$/);
const indent = match?.[1] ?? "";
const bullet = match?.[2] ?? "";
const content = match?.[3] ?? "";
const firstPrefix = `${indent}${bullet}`;
const nextPrefix = `${indent}${bullet ? " ".repeat(bullet.length) : ""}`;
const firstWidth = Math.max(10, maxWidth - visibleWidth(firstPrefix));
const nextWidth = Math.max(10, maxWidth - visibleWidth(nextPrefix));
const words = content.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let current = "";
let prefix = firstPrefix;
let available = firstWidth;
for (const word of words) {
if (!current) {
if (visibleWidth(word) > available) {
if (isCopySensitiveToken(word)) {
current = word;
continue;
}
pushWrappedWordSegments({
word,
available,
firstPrefix: prefix,
continuationPrefix: nextPrefix,
lines,
});
prefix = nextPrefix;
available = nextWidth;
continue;
}
current = word;
continue;
}
const candidate = `${current} ${word}`;
if (visibleWidth(candidate) <= available) {
current = candidate;
continue;
}
lines.push(prefix + current);
prefix = nextPrefix;
available = nextWidth;
if (visibleWidth(word) > available) {
if (isCopySensitiveToken(word)) {
current = word;
continue;
}
pushWrappedWordSegments({
word,
available,
firstPrefix: prefix,
continuationPrefix: prefix,
lines,
});
current = "";
continue;
}
current = word;
}
if (current || words.length === 0) {
lines.push(prefix + current);
}
return lines;
}
function coerceNoteMessage(message: unknown): string {
if (typeof message === "string") {
return message;
}
if (message == null) {
return "";
}
if (typeof message === "number" || typeof message === "boolean" || typeof message === "bigint") {
return String(message);
}
if (message instanceof Error) {
return message.message ? `${message.name}: ${message.message}` : message.name;
}
return "";
}
export function wrapNoteMessage(
message: unknown,
options: { maxWidth?: number; columns?: number } = {},
): string {
const text = coerceNoteMessage(message);
const columns = options.columns ?? resolveNoteColumns(process.stdout.columns);
const maxWidth = options.maxWidth ?? Math.max(40, Math.min(88, columns - 10));
return text
.split("\n")
.flatMap((line) => wrapLine(line, maxWidth))
.join("\n");
}
export function resolveNoteColumns(columns: number | undefined): number {
if (!Number.isFinite(columns) || !columns || columns < MIN_NOTE_COLUMNS) {
return MIN_NOTE_COLUMNS;
}
return columns;
}
export function resolveNoteOutputColumns(message: string, columns: number): number {
const widestLine = message
.split("\n")
.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
return Math.max(columns, widestLine + 6);
}
function createNoteOutput(columns: number): NodeJS.WriteStream {
if (process.stdout.columns === columns) {
return process.stdout;
}
const output = Object.create(process.stdout) as NodeJS.WriteStream;
Object.defineProperty(output, "columns", {
value: columns,
configurable: true,
});
output.write = process.stdout.write.bind(process.stdout);
return output;
}
export function note(message: unknown, title?: string) {
if (
suppressNotesStorage.getStore() === true ||
isSuppressedByEnv(process.env.OPENCLAW_SUPPRESS_NOTES)
) {
return;
}
const columns = resolveNoteColumns(process.stdout.columns);
const wrappedMessage = wrapNoteMessage(message, { columns });
clackNote(wrappedMessage, stylePromptTitle(title), {
output: createNoteOutput(resolveNoteOutputColumns(wrappedMessage, columns)),
format: (line) => line,
});
}
export function withSuppressedNotes<T>(callback: () => T): T {
return suppressNotesStorage.run(true, callback);
}

View File

@@ -0,0 +1,30 @@
// Terminal Core tests cover osc progress behavior.
import { describe, expect, it } from "vitest";
import { createOscProgressController, supportsOscProgress } from "./osc-progress.js";
describe("OSC progress", () => {
it("detects supported terminal environments", () => {
expect(supportsOscProgress({ TERM_PROGRAM: "WezTerm" }, true)).toBe(true);
expect(supportsOscProgress({ TERM_PROGRAM: "Apple_Terminal" }, true)).toBe(false);
expect(supportsOscProgress({ WT_SESSION: "1" }, false)).toBe(false);
});
it("writes sanitized OSC 9;4 progress sequences", () => {
const writes: string[] = [];
const controller = createOscProgressController({
env: { TERM_PROGRAM: "ghostty" },
isTty: true,
write: (chunk) => writes.push(chunk),
});
controller.setIndeterminate("Build\u001b]bad\u0007");
controller.setPercent("Build", 42.6);
controller.clear();
expect(writes).toEqual([
"\u001b]9;4;3;;Buildbad\u001b\\",
"\u001b]9;4;1;43;Build\u001b\\",
"\u001b]9;4;0;0;Build\u001b\\",
]);
});
});

View File

@@ -0,0 +1,77 @@
// OSC 9;4 progress reporting for terminals that support shell integration progress.
const OSC_PROGRESS_PREFIX = "\u001b]9;4;";
const OSC_PROGRESS_ST = "\u001b\\";
const OSC_PROGRESS_BEL = "\u0007";
const OSC_PROGRESS_C1_ST = "\u009c";
/** Controller for terminal progress state. */
export type OscProgressController = {
setIndeterminate: (label: string) => void;
setPercent: (label: string, percent: number) => void;
clear: () => void;
};
/** Return true when the terminal is known to support OSC progress messages. */
export function supportsOscProgress(env: NodeJS.ProcessEnv, isTty: boolean): boolean {
if (!isTty) {
return false;
}
const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase();
return (
termProgram.includes("ghostty") || termProgram.includes("wezterm") || Boolean(env.WT_SESSION)
);
}
/** Remove OSC terminators and escape introducers from progress labels. */
function sanitizeOscProgressLabel(label: string): string {
return label
.replaceAll(OSC_PROGRESS_ST, "")
.replaceAll(OSC_PROGRESS_BEL, "")
.replaceAll(OSC_PROGRESS_C1_ST, "")
.split("\u001b")
.join("")
.replaceAll("]", "")
.trim();
}
/** Format one OSC progress control sequence. */
function formatOscProgress(state: number, percent: number | null, label: string): string {
const cleanLabel = sanitizeOscProgressLabel(label);
if (percent === null) {
return `${OSC_PROGRESS_PREFIX}${state};;${cleanLabel}${OSC_PROGRESS_ST}`;
}
const normalizedPercent = Math.max(0, Math.min(100, Math.round(percent)));
return `${OSC_PROGRESS_PREFIX}${state};${normalizedPercent};${cleanLabel}${OSC_PROGRESS_ST}`;
}
/** Create a progress controller, returning no-op methods on unsupported terminals. */
export function createOscProgressController(params: {
env: NodeJS.ProcessEnv;
isTty: boolean;
write: (chunk: string) => void;
}): OscProgressController {
if (!supportsOscProgress(params.env, params.isTty)) {
return {
setIndeterminate: () => {},
setPercent: () => {},
clear: () => {},
};
}
let lastLabel = "";
return {
setIndeterminate: (label: string) => {
lastLabel = label;
params.write(formatOscProgress(3, null, label));
},
setPercent: (label: string, percent: number) => {
lastLabel = label;
params.write(formatOscProgress(1, percent, label));
},
clear: () => {
params.write(formatOscProgress(0, 0, lastLabel));
},
};
}

View File

@@ -0,0 +1,12 @@
// Lobster palette tokens for CLI/UI theming. Use this palette for all CLI color output.
// Keep in sync with docs/cli/index.md (CLI palette section).
export const LOBSTER_PALETTE = {
accent: "#FF5A2D",
accentBright: "#FF7A3D",
accentDim: "#D14A22",
info: "#FF8A5B",
success: "#2FBF71",
warn: "#FFB020",
error: "#E23D2D",
muted: "#8B7F77",
} as const;

View File

@@ -0,0 +1,30 @@
// Tracks the active terminal progress line so callers can clear it before other output.
let activeStream: NodeJS.WriteStream | null = null;
/** Register the stream that currently owns an inline progress line. */
export function registerActiveProgressLine(stream: NodeJS.WriteStream): void {
if (!stream.isTTY) {
return;
}
activeStream = stream;
}
/** Clear the active progress line when it is attached to a TTY stream. */
export function clearActiveProgressLine(): void {
if (!activeStream?.isTTY) {
return;
}
activeStream.write("\r\x1b[2K");
}
/** Unregister the active progress line, optionally only for a matching stream. */
export function unregisterActiveProgressLine(stream?: NodeJS.WriteStream): void {
if (!activeStream) {
return;
}
if (stream && activeStream !== stream) {
return;
}
activeStream = null;
}

View File

@@ -0,0 +1,37 @@
// Terminal Core module implements prompt select styled params behavior.
import { stylePromptHint, stylePromptMessage } from "./prompt-style.js";
// Pure prompt parameter styler used by interactive prompts and tests.
/** Minimal select-like params accepted by the prompt styler. */
type SelectParamsLike = {
message: string;
options: readonly object[];
};
/** Styling callbacks for prompt messages and hints. */
type PromptSelectStylers = {
message: (value: string) => string;
hint: (value: string) => string | undefined;
};
/** Default terminal stylers for select prompts. */
const defaultStylers: PromptSelectStylers = {
message: stylePromptMessage,
hint: stylePromptHint,
};
/** Return select params with styled prompt message and per-option hints. */
export function styleSelectParams<TParams extends SelectParamsLike>(
params: TParams,
stylers: PromptSelectStylers = defaultStylers,
): TParams {
return {
...params,
message: stylers.message(params.message),
options: params.options.map((opt) => {
const hint = "hint" in opt && typeof opt.hint === "string" ? opt.hint : undefined;
return hint === undefined ? opt : { ...opt, hint: stylers.hint(hint) };
}),
} as TParams;
}

View File

@@ -0,0 +1,49 @@
// Terminal Core tests cover prompt select styled behavior.
import { describe, expect, it } from "vitest";
import { styleSelectParams } from "./prompt-select-styled-params.js";
describe("styleSelectParams", () => {
it("styles message and option hints before select receives params", () => {
expect(
styleSelectParams(
{
message: "Pick channel",
options: [
{ value: "stable", label: "Stable", hint: "Tagged releases" },
{ value: "dev", label: "Dev" },
],
},
{
message: (value) => `msg:${value}`,
hint: (value) => `hint:${value}`,
},
),
).toEqual({
message: "msg:Pick channel",
options: [
{ value: "stable", label: "Stable", hint: "hint:Tagged releases" },
{ value: "dev", label: "Dev" },
],
});
});
it("keeps unhinted options unchanged", () => {
const option = { value: "dev", label: "Dev" };
const params = styleSelectParams(
{
message: "Pick channel",
options: [option],
},
{
message: (value) => `msg:${value}`,
hint: (value) => `hint:${value}`,
},
);
expect(params).toEqual({
message: "msg:Pick channel",
options: [{ value: "dev", label: "Dev" }],
});
expect(params.options[0]).toBe(option);
});
});

View File

@@ -0,0 +1,10 @@
// Terminal Core module implements prompt select styled behavior.
import { select } from "@clack/prompts";
import { styleSelectParams } from "./prompt-select-styled-params.js";
// Clack select wrapper that applies OpenClaw prompt styling.
/** Run a clack select prompt with styled message and hints. */
export function selectStyled<T>(params: Parameters<typeof select<T>>[0]) {
return select(styleSelectParams(params));
}

View File

@@ -0,0 +1,16 @@
// Terminal Core module implements prompt style behavior.
import { isRich, theme } from "./theme.js";
// Shared styling helpers for interactive prompt copy.
/** Style a prompt message when rich terminal output is active. */
export const stylePromptMessage = (message: string): string =>
isRich() ? theme.accent(message) : message;
/** Style a prompt title when rich terminal output is active. */
export const stylePromptTitle = (title?: string): string | undefined =>
title && isRich() ? theme.heading(title) : title;
/** Style a prompt hint when rich terminal output is active. */
export const stylePromptHint = (hint?: string): string | undefined =>
hint && isRich() ? theme.muted(hint) : hint;

View File

@@ -0,0 +1,114 @@
// Terminal Core tests cover restore behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
const clearActiveProgressLine = vi.hoisted(() => vi.fn());
vi.mock("./progress-line.js", () => ({
clearActiveProgressLine,
}));
import { restoreTerminalState } from "./restore.js";
function configureTerminalIO(params: {
stdinIsTTY: boolean;
stdoutIsTTY: boolean;
setRawMode?: (mode: boolean) => void;
resume?: () => void;
isPaused?: () => boolean;
}) {
Object.defineProperty(process.stdin, "isTTY", { value: params.stdinIsTTY, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: params.stdoutIsTTY, configurable: true });
(process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode = params.setRawMode;
(process.stdin as { resume?: () => void }).resume = params.resume;
(process.stdin as { isPaused?: () => boolean }).isPaused = params.isPaused;
}
function setupPausedTTYStdin() {
const setRawMode = vi.fn();
const resume = vi.fn();
const isPaused = vi.fn(() => true);
configureTerminalIO({
stdinIsTTY: true,
stdoutIsTTY: false,
setRawMode,
resume,
isPaused,
});
return { setRawMode, resume };
}
describe("restoreTerminalState", () => {
const originalStdinIsTTY = process.stdin.isTTY;
const originalStdoutIsTTY = process.stdout.isTTY;
const originalSetRawMode = (process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode;
const originalResume = (process.stdin as { resume?: () => void }).resume;
const originalIsPaused = (process.stdin as { isPaused?: () => boolean }).isPaused;
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(process.stdin, "isTTY", {
value: originalStdinIsTTY,
configurable: true,
});
Object.defineProperty(process.stdout, "isTTY", {
value: originalStdoutIsTTY,
configurable: true,
});
(process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode = originalSetRawMode;
(process.stdin as { resume?: () => void }).resume = originalResume;
(process.stdin as { isPaused?: () => boolean }).isPaused = originalIsPaused;
});
it("does not resume paused stdin by default", () => {
const { setRawMode, resume } = setupPausedTTYStdin();
restoreTerminalState("test");
expect(setRawMode).toHaveBeenCalledWith(false);
expect(resume).not.toHaveBeenCalled();
});
it("resumes paused stdin when resumeStdin is true", () => {
const { setRawMode, resume } = setupPausedTTYStdin();
restoreTerminalState("test", { resumeStdinIfPaused: true });
expect(setRawMode).toHaveBeenCalledWith(false);
expect(resume).toHaveBeenCalledOnce();
});
it("does not touch stdin when stdin is not a TTY", () => {
const setRawMode = vi.fn();
const resume = vi.fn();
const isPaused = vi.fn(() => true);
configureTerminalIO({
stdinIsTTY: false,
stdoutIsTTY: false,
setRawMode,
resume,
isPaused,
});
restoreTerminalState("test", { resumeStdinIfPaused: true });
expect(setRawMode).not.toHaveBeenCalled();
expect(resume).not.toHaveBeenCalled();
});
it("writes kitty and modifyOtherKeys reset sequences to stdout", () => {
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
configureTerminalIO({
stdinIsTTY: false,
stdoutIsTTY: true,
});
restoreTerminalState("test");
expect(writeSpy).toHaveBeenCalled();
const output = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join("");
expect(output).toContain("\x1b[<u");
expect(output).toContain("\x1b[>4;0m");
});
});

View File

@@ -0,0 +1,70 @@
// Terminal Core module implements restore behavior.
import { clearActiveProgressLine } from "./progress-line.js";
const RESET_SEQUENCE =
"\x1b[0m\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[<u\x1b[>4;0m";
type RestoreTerminalStateOptions = {
/**
* Resumes paused stdin after restoring terminal mode.
* Keep this off when the process should exit immediately after cleanup.
*
* Default: false (safer for "cleanup then exit" call sites).
*/
resumeStdin?: boolean;
/**
* Alias for resumeStdin. Prefer this name to make the behavior explicit.
*
* Default: false.
*/
resumeStdinIfPaused?: boolean;
};
function reportRestoreFailure(scope: string, err: unknown, reason?: string): void {
const suffix = reason ? ` (${reason})` : "";
const message = `[terminal] restore ${scope} failed${suffix}: ${String(err)}`;
try {
process.stderr.write(`${message}\n`);
} catch (writeErr) {
console.error(`[terminal] restore reporting failed${suffix}: ${String(writeErr)}`);
}
}
export function restoreTerminalState(
reason?: string,
options: RestoreTerminalStateOptions = {},
): void {
// Docker TTY note: resuming stdin can keep a container process alive even
// after the wizard is "done" (stdin_open: true), making installers appear hung.
const resumeStdin = options.resumeStdinIfPaused ?? options.resumeStdin ?? false;
try {
clearActiveProgressLine();
} catch (err) {
reportRestoreFailure("progress line", err, reason);
}
const stdin = process.stdin;
if (stdin.isTTY && typeof stdin.setRawMode === "function") {
try {
stdin.setRawMode(false);
} catch (err) {
reportRestoreFailure("raw mode", err, reason);
}
if (resumeStdin && typeof stdin.isPaused === "function" && stdin.isPaused()) {
try {
stdin.resume();
} catch (err) {
reportRestoreFailure("stdin resume", err, reason);
}
}
}
if (process.stdout.isTTY) {
try {
process.stdout.write(RESET_SEQUENCE);
} catch (err) {
reportRestoreFailure("stdout reset", err, reason);
}
}
}

View File

@@ -0,0 +1,21 @@
// Terminal Core tests cover safe text behavior.
import { describe, expect, it } from "vitest";
import { sanitizeTerminalText } from "./safe-text.js";
describe("sanitizeTerminalText", () => {
it("removes C1 control characters", () => {
expect(sanitizeTerminalText("a\u009bb\u0085c")).toBe("abc");
});
it("strips cursor and erase ANSI sequences", () => {
expect(sanitizeTerminalText("\u001b[2K\u001b[1Arewritten")).toBe("rewritten");
});
it("removes OSC clipboard payloads", () => {
expect(sanitizeTerminalText("safe\u001b]52;c;YWJj\u0007text")).toBe("safetext");
});
it("escapes line controls while preserving printable text", () => {
expect(sanitizeTerminalText("a\tb\nc\rd")).toBe("a\\tb\\nc\\rd");
});
});

View File

@@ -0,0 +1,21 @@
// Terminal Core module implements safe text behavior.
import { stripAnsi } from "./ansi.js";
/**
* Normalize untrusted text for single-line terminal/log rendering.
*/
export function sanitizeTerminalText(input: string): string {
const normalized = stripAnsi(input)
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t");
let sanitized = "";
for (const char of normalized) {
const code = char.charCodeAt(0);
const isControl = (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f);
if (!isControl) {
sanitized += char;
}
}
return sanitized;
}

View File

@@ -0,0 +1,59 @@
// Terminal Core tests cover stream writer behavior.
import { describe, expect, it } from "vitest";
import { createSafeStreamWriter } from "./stream-writer.js";
function createSpy<Args extends unknown[], ReturnValue>(
implementation?: (...args: Args) => ReturnValue,
) {
const calls: Args[] = [];
const spy = (...args: Args) => {
calls.push(args);
return implementation?.(...args) as ReturnValue;
};
spy.calls = calls;
spy.clear = () => {
calls.length = 0;
};
return spy;
}
describe("createSafeStreamWriter", () => {
it("signals broken pipes and closes the writer", () => {
const onBrokenPipe = createSpy<[], void>();
const writer = createSafeStreamWriter({ onBrokenPipe });
const stream = {
write: createSpy<[string], boolean>(() => {
const err = new Error("EPIPE") as NodeJS.ErrnoException;
err.code = "EPIPE";
throw err;
}),
} as unknown as NodeJS.WriteStream;
expect(writer.writeLine(stream, "hello")).toBe(false);
expect(writer.isClosed()).toBe(true);
expect(onBrokenPipe.calls).toHaveLength(1);
onBrokenPipe.clear();
expect(writer.writeLine(stream, "again")).toBe(false);
expect(onBrokenPipe.calls).toHaveLength(0);
});
it("treats broken pipes from beforeWrite as closed", () => {
const onBrokenPipe = createSpy<[], void>();
const writer = createSafeStreamWriter({
onBrokenPipe,
beforeWrite: () => {
const err = new Error("EIO") as NodeJS.ErrnoException;
err.code = "EIO";
throw err;
},
});
const stream = {
write: createSpy<[string], boolean>(() => true),
} as unknown as NodeJS.WriteStream;
expect(writer.write(stream, "hi")).toBe(false);
expect(writer.isClosed()).toBe(true);
expect(onBrokenPipe.calls).toHaveLength(1);
});
});

View File

@@ -0,0 +1,74 @@
// Safe terminal stream writer that treats broken pipes as closed output.
/** Hooks for safe stream writes. */
export type SafeStreamWriterOptions = {
beforeWrite?: () => void;
onBrokenPipe?: (err: NodeJS.ErrnoException, stream: NodeJS.WriteStream) => void;
};
/** Writer facade that tracks closed/broken-pipe state. */
export type SafeStreamWriter = {
write: (stream: NodeJS.WriteStream, text: string) => boolean;
writeLine: (stream: NodeJS.WriteStream, text: string) => boolean;
reset: () => void;
isClosed: () => boolean;
};
/** Detect broken pipe style stream errors. */
function isBrokenPipeError(err: unknown): err is NodeJS.ErrnoException {
const code = (err as NodeJS.ErrnoException)?.code;
return code === "EPIPE" || code === "EIO";
}
/** Create a stream writer that stops writing after EPIPE/EIO. */
export function createSafeStreamWriter(options: SafeStreamWriterOptions = {}): SafeStreamWriter {
let closed = false;
let notified = false;
const noteBrokenPipe = (err: NodeJS.ErrnoException, stream: NodeJS.WriteStream) => {
if (notified) {
return;
}
notified = true;
options.onBrokenPipe?.(err, stream);
};
const handleError = (err: unknown, stream: NodeJS.WriteStream): boolean => {
if (!isBrokenPipeError(err)) {
throw err;
}
closed = true;
noteBrokenPipe(err, stream);
return false;
};
const write = (stream: NodeJS.WriteStream, text: string): boolean => {
if (closed) {
return false;
}
try {
options.beforeWrite?.();
} catch (err) {
return handleError(err, process.stderr);
}
try {
stream.write(text);
return !closed;
} catch (err) {
return handleError(err, stream);
}
};
const writeLine = (stream: NodeJS.WriteStream, text: string): boolean =>
write(stream, `${text}\n`);
return {
write,
writeLine,
reset: () => {
closed = false;
notified = false;
},
isClosed: () => closed,
};
}

View File

@@ -0,0 +1,9 @@
// Shared terminal string normalization helpers.
/** Normalize string input to lowercase, returning empty string for non-strings. */
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
}

View File

@@ -0,0 +1,423 @@
// Terminal Core tests cover table behavior.
import path from "node:path";
import { note as clackNote } from "@clack/prompts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { visibleWidth } from "./ansi.js";
import { resolveNoteColumns, resolveNoteOutputColumns, wrapNoteMessage } from "./note.js";
import { renderTable } from "./table.js";
function mockProcessPlatform(platform: NodeJS.Platform): void {
vi.spyOn(process, "platform", "get").mockReturnValue(platform);
}
describe("renderTable", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it("prefers shrinking flex columns to avoid wrapping non-flex labels", () => {
const out = renderTable({
width: 40,
columns: [
{ key: "Item", header: "Item", minWidth: 10 },
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
],
rows: [{ Item: "Dashboard", Value: "http://127.0.0.1:18789/" }],
});
expect(out).toContain("Dashboard");
expect(out).toMatch(/[│|] Dashboard\s+[│|]/);
});
it("expands flex columns to fill available width", () => {
const width = 60;
const out = renderTable({
width,
columns: [
{ key: "Item", header: "Item", minWidth: 10 },
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
],
rows: [{ Item: "OS", Value: "macos 26.2 (arm64)" }],
});
const firstLine = out.trimEnd().split("\n")[0] ?? "";
expect(visibleWidth(firstLine)).toBe(width);
});
it("wraps ANSI-colored cells without corrupting escape sequences", () => {
const out = renderTable({
width: 36,
columns: [
{ key: "K", header: "K", minWidth: 3 },
{ key: "V", header: "V", flex: true, minWidth: 10 },
],
rows: [
{
K: "X",
V: `\x1b[33m${"a".repeat(120)}\x1b[0m`,
},
],
});
const ansiToken = new RegExp(String.raw`\u001b\[[0-9;]*m|\u001b\]8;;.*?\u001b\\`, "gs");
let escapeIndex = out.indexOf("\u001b");
while (escapeIndex >= 0) {
ansiToken.lastIndex = escapeIndex;
const match = ansiToken.exec(out);
expect(match?.index).toBe(escapeIndex);
escapeIndex = out.indexOf("\u001b", escapeIndex + 1);
}
});
it("resets ANSI styling on wrapped lines", () => {
const reset = "\x1b[0m";
const out = renderTable({
width: 24,
columns: [
{ key: "K", header: "K", minWidth: 3 },
{ key: "V", header: "V", flex: true, minWidth: 10 },
],
rows: [
{
K: "X",
V: `\x1b[31m${"a".repeat(80)}${reset}`,
},
],
});
const lines = out.split("\n").filter((line) => line.includes("a"));
for (const line of lines) {
const resetIndex = line.lastIndexOf(reset);
const lastSep = Math.max(line.lastIndexOf("│"), line.lastIndexOf("|"));
expect(resetIndex).toBeGreaterThan(-1);
expect(lastSep).toBeGreaterThan(resetIndex);
}
});
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("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,
columns: [
{ key: "A", header: "A", minWidth: 6 },
{ key: "B", header: "B", minWidth: 10, flex: true },
],
rows: [{ A: "row", B: "line1\nline2" }],
});
const lines = out.trimEnd().split("\n");
const line1Index = lines.findIndex((line) => line.includes("line1"));
const line2Index = lines.findIndex((line) => line.includes("line2"));
expect(line1Index).toBeGreaterThan(-1);
expect(line2Index).toBe(line1Index + 1);
});
it("shortens only exact home paths and child paths in table cells", () => {
const home = path.resolve("test-home", "alice");
vi.stubEnv("HOME", home);
vi.stubEnv("USERPROFILE", "");
vi.stubEnv("OPENCLAW_HOME", "");
const out = renderTable({
border: "none",
columns: [{ key: "Path", header: "Path" }],
rows: [
{ Path: home },
{ Path: `${home}/project` },
{ Path: `${home}2/project` },
{ Path: `Workspace: ${home}/project` },
],
});
expect(out).toContain("~\n");
expect(out).toContain("~/project");
expect(out).toContain(`${home}2/project`);
expect(out).toContain("Workspace: ~/project");
expect(out).not.toContain("~2/project");
});
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("keeps borders aligned when a wide grapheme lands in a narrow cell", () => {
// A width-2 CJK/emoji glyph in a column whose content width is 1 cannot be
// wrapped, so padCell must clamp it instead of overflowing the cell and
// pushing the right border out of alignment.
const out = renderTable({
border: "ascii",
padding: 0,
columns: [{ key: "B", header: "B", minWidth: 1, maxWidth: 1 }],
rows: [{ B: "表" }],
});
const lines = out.trimEnd().split("\n");
for (const line of lines) {
expect(visibleWidth(line)).toBe(3);
}
});
it("keeps borders aligned when a narrow flex column receives wide content", () => {
const out = renderTable({
width: 10,
border: "ascii",
columns: [
{ key: "A", header: "long header here" },
{ key: "B", header: "", flex: true },
],
rows: [{ A: "data", B: "📸" }],
});
const lines = out.trimEnd().split("\n");
const headerWidth = visibleWidth(lines[0] ?? "");
for (const line of lines) {
expect(visibleWidth(line)).toBe(headerWidth);
}
});
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");
});
it("falls back to ASCII borders on legacy Windows consoles", () => {
mockProcessPlatform("win32");
vi.stubEnv("WT_SESSION", "");
vi.stubEnv("TERM_PROGRAM", "");
vi.stubEnv("TERM", "vt100");
const out = renderTable({
columns: [
{ key: "A", header: "A", minWidth: 6 },
{ key: "B", header: "B", minWidth: 10, flex: true },
],
rows: [{ A: "row", B: "value" }],
});
expect(out).toContain("+");
expect(out).not.toContain("┌");
});
it("keeps unicode borders on modern Windows terminals", () => {
mockProcessPlatform("win32");
vi.stubEnv("WT_SESSION", "1");
vi.stubEnv("TERM", "");
vi.stubEnv("TERM_PROGRAM", "");
const out = renderTable({
columns: [
{ key: "A", header: "A", minWidth: 6 },
{ key: "B", header: "B", minWidth: 10, flex: true },
],
rows: [{ A: "row", B: "value" }],
});
expect(out).toContain("┌");
expect(out).not.toContain("+");
});
});
describe("wrapNoteMessage", () => {
it("preserves long filesystem paths without inserting spaces/newlines", () => {
const input =
"/Users/user/Documents/Github/impact-signals-pipeline/with/really/long/segments/file.txt";
const wrapped = wrapNoteMessage(input, { maxWidth: 22, columns: 80 });
expect(wrapped).toBe(input);
});
it("preserves long urls without inserting spaces/newlines", () => {
const input =
"https://example.com/this/is/a/very/long/url/segment/that/should/not/be/split/for-copy";
const wrapped = wrapNoteMessage(input, { maxWidth: 24, columns: 80 });
expect(wrapped).toBe(input);
});
it("preserves long file-like underscore tokens for copy safety", () => {
const input = "administrators_authorized_keys_with_extra_suffix";
const wrapped = wrapNoteMessage(input, { maxWidth: 14, columns: 80 });
expect(wrapped).toBe(input);
});
it("still chunks generic long opaque tokens to avoid pathological line width", () => {
const input = "x".repeat(70);
const wrapped = wrapNoteMessage(input, { maxWidth: 20, columns: 80 });
expect(wrapped).toContain("\n");
expect(wrapped.replace(/\n/g, "")).toBe(input);
});
it("wraps bullet lines while preserving bullet indentation", () => {
const input = "- one two three four five six seven eight nine ten";
const wrapped = wrapNoteMessage(input, { maxWidth: 18, columns: 80 });
const lines = wrapped.split("\n");
expect(lines.length).toBeGreaterThan(1);
expect(lines[0]?.startsWith("- ")).toBe(true);
const unindentedContinuationLines = lines.slice(1).filter((line) => !line.startsWith(" "));
expect(unindentedContinuationLines).toStrictEqual([]);
});
it("preserves long Windows paths without inserting spaces/newlines", () => {
// No spaces: wrapNoteMessage splits on whitespace, so a "Program Files" style path would wrap.
const input = "C:\\\\State\\\\OpenClaw\\\\bin\\\\openclaw.exe";
const wrapped = wrapNoteMessage(input, { maxWidth: 10, columns: 80 });
expect(wrapped).toBe(input);
});
it("preserves UNC paths without inserting spaces/newlines", () => {
const input = "\\\\\\\\server\\\\share\\\\some\\\\really\\\\long\\\\path\\\\file.txt";
const wrapped = wrapNoteMessage(input, { maxWidth: 12, columns: 80 });
expect(wrapped).toBe(input);
});
it("clamps bogus TTY columns before clack wraps note text", () => {
expect(resolveNoteColumns(undefined)).toBe(80);
expect(resolveNoteColumns(0)).toBe(80);
expect(resolveNoteColumns(1)).toBe(80);
expect(resolveNoteColumns(79)).toBe(80);
expect(resolveNoteColumns(120)).toBe(120);
});
it("widens note output columns so clack does not re-wrap copy-sensitive lines", () => {
const wrapped = wrapNoteMessage(
[
"- Found 1 session lock file.",
"- ~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock pid=86519 (alive) age=2m47s stale=no",
].join("\n"),
{ columns: 80 },
);
const writes: string[] = [];
const output = {
columns: resolveNoteOutputColumns(wrapped, 80),
write(chunk: string) {
writes.push(chunk);
return true;
},
} as unknown as NodeJS.WriteStream;
clackNote(wrapped, "Session locks", { output, format: (line) => line });
const rendered = writes.join("");
expect(rendered).toContain(".jsonl.lock");
expect(rendered).not.toContain(".js\n");
expect(rendered).toContain(
"- ~/.openclaw/agents/main/sessions/9c2acae5-841f-4aea-936b-fdb513b60202.jsonl.lock",
);
});
it("coerces nullish and non-string note messages before wrapping", () => {
expect(wrapNoteMessage(undefined, { maxWidth: 20, columns: 80 })).toBe("");
expect(wrapNoteMessage(null, { maxWidth: 20, columns: 80 })).toBe("");
expect(wrapNoteMessage(12345, { maxWidth: 20, columns: 80 })).toBe("12345");
expect(wrapNoteMessage(new Error("boom"), { maxWidth: 20, columns: 80 })).toBe("Error: boom");
expect(wrapNoteMessage({ message: "boom" }, { maxWidth: 20, columns: 80 })).toBe("");
});
it("keeps wrapped lines within the visible-column budget for wide (CJK) words", () => {
// A long CJK run with no separators reaches splitLongWord; each fullwidth char is 2 columns,
// so splitting by code-point count would emit lines up to 2x the budget.
const input = "東京特許許可局長今日休暇許可局長今日休暇東京特許";
const lines = wrapNoteMessage(input, { maxWidth: 20, columns: 80 }).split("\n");
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(20);
}
expect(lines.join("")).toBe(input);
});
});

View File

@@ -0,0 +1,612 @@
// Terminal Core module implements table behavior.
import { splitGraphemes, truncateToVisibleWidth, visibleWidth } from "./ansi.js";
import { displayString } from "./display-string.js";
type Align = "left" | "right" | "center";
export type TableColumn = {
key: string;
header: string;
align?: Align;
minWidth?: number;
maxWidth?: number;
flex?: boolean;
};
export type RenderTableOptions = {
columns: TableColumn[];
rows: Array<Record<string, string>>;
width?: number;
padding?: number;
border?: "unicode" | "ascii" | "none";
};
function resolveDefaultBorder(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): "unicode" | "ascii" {
if (platform !== "win32") {
return "unicode";
}
const term = env.TERM ?? "";
const termProgram = env.TERM_PROGRAM ?? "";
const isModernTerminal =
Boolean(env.WT_SESSION) ||
term.includes("xterm") ||
term.includes("cygwin") ||
term.includes("msys") ||
termProgram === "vscode";
return isModernTerminal ? "unicode" : "ascii";
}
function repeat(ch: string, n: number): string {
if (n <= 0) {
return "";
}
return ch.repeat(n);
}
function padCell(text: string, width: number, align: Align): string {
// A single grapheme wider than the cell (e.g. a width-2 CJK/emoji glyph in a
// width-1 column) survives wrapLine intact, so clamp here to keep every cell
// exactly `width` columns and preserve the border-alignment invariant.
const content = visibleWidth(text) > width ? truncateToVisibleWidth(text, width) : text;
const w = visibleWidth(content);
if (w >= width) {
return content;
}
const pad = width - w;
if (align === "right") {
return `${repeat(" ", pad)}${content}`;
}
if (align === "center") {
const left = Math.floor(pad / 2);
const right = pad - left;
return `${repeat(" ", left)}${content}${repeat(" ", right)}`;
}
return `${content}${repeat(" ", pad)}`;
}
function wrapLine(text: string, width: number): string[] {
if (width <= 0) {
return [text];
}
// ANSI-aware wrapping: never split inside ANSI SGR/OSC-8 sequences.
// 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[] = [];
for (let i = 0; i < text.length; ) {
if (text[i] === ESC) {
// SGR: ESC [ ... m
if (text[i + 1] === "[") {
let j = i + 2;
while (j < text.length) {
const ch = text[j];
if (ch === "m") {
break;
}
if (ch && ch >= "0" && ch <= "9") {
j += 1;
continue;
}
if (ch === ";") {
j += 1;
continue;
}
break;
}
if (text[j] === "m") {
tokens.push({ kind: "ansi", value: text.slice(i, j + 1) });
i = j + 1;
continue;
}
}
// OSC-8 link open/close: ESC ] 8 ; ; ... ST (ST = ESC \)
if (text[i + 1] === "]" && text.slice(i + 2, i + 5) === "8;;") {
const st = text.indexOf(`${ESC}\\`, i + 5);
if (st >= 0) {
tokens.push({ kind: "ansi", value: text.slice(i, st + 2) });
i = st + 2;
continue;
}
}
}
let nextEsc = text.indexOf(ESC, i);
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 });
}
i = nextEsc;
}
const firstCharIndex = tokens.findIndex((t) => t.kind === "char");
if (firstCharIndex < 0) {
return [text];
}
let lastCharIndex = -1;
for (let i = tokens.length - 1; i >= 0; i -= 1) {
if (tokens[i]?.kind === "char") {
lastCharIndex = i;
break;
}
}
const prefixAnsi = tokens
.slice(0, firstCharIndex)
.filter((t) => t.kind === "ansi")
.map((t) => t.value)
.join("");
const suffixAnsi = tokens
.slice(lastCharIndex + 1)
.filter((t) => t.kind === "ansi")
.map((t) => t.value)
.join("");
const coreTokens = tokens.slice(firstCharIndex, lastCharIndex + 1);
const lines: string[] = [];
const isBreakChar = (ch: string) =>
ch === " " || ch === "\t" || ch === "/" || ch === "-" || ch === "_" || ch === ".";
const isSpaceChar = (ch: string) => ch === " " || ch === "\t";
let skipNextLf = false;
const buf: Token[] = [];
let bufVisible = 0;
let lastBreakIndex: number | null = null;
const bufToString = (slice?: Token[]) => (slice ?? buf).map((t) => t.value).join("");
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 = (tokensValue: 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 tokensValue) {
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 (visibleWidth(cleaned) === 0) {
return;
}
lines.push(cleaned);
};
const trimLeadingSpaces = (tokensLocal: Token[]) => {
while (true) {
const firstCharIndexLocal = tokensLocal.findIndex((token) => token.kind === "char");
if (firstCharIndexLocal < 0) {
return;
}
const firstChar = tokensLocal[firstCharIndexLocal];
if (!firstChar || !isSpaceChar(firstChar.value)) {
return;
}
tokensLocal.splice(firstCharIndexLocal, 1);
}
};
const flushAt = (breakAt: number | null) => {
if (buf.length === 0) {
return;
}
if (breakAt == null || breakAt <= 0) {
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;
}
const left = buf.slice(0, breakAt);
const rest = buf.slice(breakAt);
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);
bufVisible = bufVisibleWidth(buf);
lastBreakIndex = null;
};
for (const token of coreTokens) {
if (token.kind === "ansi") {
buf.push(token);
continue;
}
const ch = token.value;
if (skipNextLf) {
skipNextLf = false;
if (ch === "\n") {
continue;
}
}
if (ch === "\n" || ch === "\r") {
flushAt(buf.length);
if (ch === "\r") {
skipNextLf = true;
}
continue;
}
const charWidth = visibleWidth(ch);
if (bufVisible + charWidth > width && bufVisible > 0) {
flushAt(lastBreakIndex);
}
if (bufVisible === 0 && isSpaceChar(ch)) {
continue;
}
buf.push(token);
bufVisible += charWidth;
if (isBreakChar(ch)) {
lastBreakIndex = buf.length;
}
}
flushAt(buf.length);
if (!lines.length) {
return [""];
}
if (!prefixAnsi && !suffixAnsi) {
return lines;
}
return lines.map((line) => {
if (!line) {
return line;
}
return `${prefixAnsi}${line}${suffixAnsi}`;
});
}
function normalizeWidth(n: number | undefined): number | undefined {
if (n == null) {
return undefined;
}
if (!Number.isFinite(n) || n <= 0) {
return 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> = {};
for (const [key, value] of Object.entries(row)) {
next[key] = displayString(value);
}
return next;
});
const border = opts.border ?? resolveDefaultBorder(process.platform, process.env);
if (border === "none") {
const columns = opts.columns;
const header = columns.map((c) => c.header).join(" | ");
const lines = [header, ...rows.map((r) => columns.map((c) => r[c.key] ?? "").join(" | "))];
return `${lines.join("\n")}\n`;
}
const padding = Math.max(0, opts.padding ?? 1);
const columns = opts.columns;
const metrics = columns.map((c) => {
const headerW = visibleWidth(c.header);
const cellW = Math.max(0, ...rows.map((r) => visibleWidth(r[c.key] ?? "")));
return { headerW, cellW };
});
const widths = columns.map((c, i) => {
const m = metrics[i];
const base = Math.max(m?.headerW ?? 0, m?.cellW ?? 0) + padding * 2;
const capped = c.maxWidth ? Math.min(base, c.maxWidth) : base;
return Math.max(c.minWidth ?? 3, capped);
});
const maxWidth = normalizeWidth(opts.width);
const sepCount = columns.length + 1;
const total = widths.reduce((a, b) => a + b, 0) + sepCount;
const preferredMinWidths = columns.map((c, i) =>
Math.max(c.minWidth ?? 3, (metrics[i]?.headerW ?? 0) + padding * 2, 3),
);
const absoluteMinWidths = columns.map((_c, i) =>
Math.max((metrics[i]?.headerW ?? 0) + padding * 2, 3),
);
if (maxWidth && total > maxWidth) {
let over = total - maxWidth;
const flexOrder = columns
.map((_c, i) => ({ i, w: widths[i] ?? 0 }))
.filter(({ i }) => Boolean(columns[i]?.flex))
.toSorted((a, b) => b.w - a.w)
.map((x) => x.i);
const nonFlexOrder = columns
.map((_c, i) => ({ i, w: widths[i] ?? 0 }))
.filter(({ i }) => !columns[i]?.flex)
.toSorted((a, b) => b.w - a.w)
.map((x) => x.i);
const shrink = (order: number[], minWidths: number[]) => {
while (over > 0) {
let progressed = false;
for (const i of order) {
if ((widths[i] ?? 0) <= (minWidths[i] ?? 0)) {
continue;
}
widths[i] = (widths[i] ?? 0) - 1;
over -= 1;
progressed = true;
if (over <= 0) {
break;
}
}
if (!progressed) {
break;
}
}
};
// Prefer shrinking flex columns; only shrink non-flex if necessary.
// If required to fit, allow flex columns to shrink below user minWidth
// down to their absolute minimum (header + padding).
shrink(flexOrder, preferredMinWidths);
shrink(flexOrder, absoluteMinWidths);
shrink(nonFlexOrder, preferredMinWidths);
shrink(nonFlexOrder, absoluteMinWidths);
}
// If we have room and any flex columns, expand them to fill the available width.
// This keeps tables from looking "clipped" and reduces wrapping in wide terminals.
if (maxWidth) {
const sepCountLocal = columns.length + 1;
const currentTotal = widths.reduce((a, b) => a + b, 0) + sepCountLocal;
let extra = maxWidth - currentTotal;
if (extra > 0) {
const flexCols = columns
.map((c, i) => ({ c, i }))
.filter(({ c }) => Boolean(c.flex))
.map(({ i }) => i);
if (flexCols.length > 0) {
const caps = columns.map((c) =>
typeof c.maxWidth === "number" && c.maxWidth > 0
? Math.floor(c.maxWidth)
: Number.POSITIVE_INFINITY,
);
while (extra > 0) {
let progressed = false;
for (const i of flexCols) {
if ((widths[i] ?? 0) >= (caps[i] ?? Number.POSITIVE_INFINITY)) {
continue;
}
widths[i] = (widths[i] ?? 0) + 1;
extra -= 1;
progressed = true;
if (extra <= 0) {
break;
}
}
if (!progressed) {
break;
}
}
}
}
}
const box =
border === "ascii"
? {
tl: "+",
tr: "+",
bl: "+",
br: "+",
h: "-",
v: "|",
t: "+",
ml: "+",
m: "+",
mr: "+",
b: "+",
}
: {
tl: "┌",
tr: "┐",
bl: "└",
br: "┘",
h: "─",
v: "│",
t: "┬",
ml: "├",
m: "┼",
mr: "┤",
b: "┴",
};
const hLine = (left: string, mid: string, right: string) =>
`${left}${widths.map((w) => repeat(box.h, w)).join(mid)}${right}`;
const contentWidthFor = (i: number) => Math.max(1, widths[i] - padding * 2);
const padStr = repeat(" ", padding);
const renderRow = (record: Record<string, string>, isHeader = false) => {
const cells = columns.map((c) => (isHeader ? c.header : (record[c.key] ?? "")));
const wrapped = cells.map((cell, i) => wrapLine(cell, contentWidthFor(i)));
const height = Math.max(...wrapped.map((w) => w.length));
const out: string[] = [];
for (let li = 0; li < height; li += 1) {
const parts = wrapped.map((lines, i) => {
const raw = lines[li] ?? "";
const aligned = padCell(raw, contentWidthFor(i), columns[i]?.align ?? "left");
return `${padStr}${aligned}${padStr}`;
});
out.push(`${box.v}${parts.join(box.v)}${box.v}`);
}
return out;
};
const lines: string[] = [];
lines.push(hLine(box.tl, box.t, box.tr));
lines.push(...renderRow({}, true));
lines.push(hLine(box.ml, box.m, box.mr));
for (const row of rows) {
lines.push(...renderRow(row, false));
}
lines.push(hLine(box.bl, box.b, box.br));
return `${lines.join("\n")}\n`;
}

View File

@@ -0,0 +1,41 @@
// Terminal Core tests cover terminal hyperlink formatting behavior.
import { describe, expect, it } from "vitest";
import { formatTerminalLink } from "./terminal-link.js";
describe("formatTerminalLink", () => {
it("strips terminal control characters from OSC labels and urls", () => {
const out = formatTerminalLink(
"safe\u0007la\u001bbel",
"https://example.test/a\u0007oops\u001b[31m",
{ force: true },
);
expect(out).toBe("\u001b]8;;https://example.test/aoops[31m\u0007safelabel\u001b]8;;\u0007");
});
it("strips terminal control characters from plain fallback text", () => {
const out = formatTerminalLink("safe\u0007label", "https://example.test/a\u001b[31m", {
force: false,
});
expect(out).toBe("safelabel (https://example.test/a[31m)");
});
it("strips terminal control characters from explicit fallback text", () => {
const out = formatTerminalLink("label", "https://example.test", {
fallback: "fallback\u0007text\u001b[31m",
force: false,
});
expect(out).toBe("fallbacktext[31m");
});
it("preserves explicit empty fallback text", () => {
const out = formatTerminalLink("label", "https://example.test", {
fallback: "",
force: false,
});
expect(out).toBe("");
});
});

View File

@@ -0,0 +1,30 @@
// OSC 8 terminal hyperlink formatting with plain-text fallback.
function stripTerminalLinkControls(value: string): string {
let out = "";
for (const char of value) {
const code = char.charCodeAt(0);
const isControl = (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f);
if (!isControl) {
out += char;
}
}
return out;
}
/** Format a clickable terminal link when supported, otherwise return a readable fallback. */
export function formatTerminalLink(
label: string,
url: string,
opts?: { fallback?: string; force?: boolean },
): string {
const safeLabel = stripTerminalLinkControls(label);
const safeUrl = stripTerminalLinkControls(url);
const allow = opts?.force === true ? true : opts?.force === false ? false : process.stdout.isTTY;
if (!allow) {
return opts?.fallback === undefined
? `${safeLabel} (${safeUrl})`
: stripTerminalLinkControls(opts.fallback);
}
return `\u001b]8;;${safeUrl}\u0007${safeLabel}\u001b]8;;\u0007`;
}

View File

@@ -0,0 +1,36 @@
// Terminal Core module implements theme behavior.
import chalk, { Chalk } from "chalk";
import { LOBSTER_PALETTE } from "./palette.js";
// Shared terminal color theme that respects NO_COLOR and FORCE_COLOR.
const hasForceColor =
typeof process.env.FORCE_COLOR === "string" &&
process.env.FORCE_COLOR.trim().length > 0 &&
process.env.FORCE_COLOR.trim() !== "0";
const baseChalk = process.env.NO_COLOR && !hasForceColor ? new Chalk({ level: 0 }) : chalk;
const hex = (value: string) => baseChalk.hex(value);
/** Shared terminal theme color functions. */
export const theme = {
accent: hex(LOBSTER_PALETTE.accent),
accentBright: hex(LOBSTER_PALETTE.accentBright),
accentDim: hex(LOBSTER_PALETTE.accentDim),
info: hex(LOBSTER_PALETTE.info),
success: hex(LOBSTER_PALETTE.success),
warn: hex(LOBSTER_PALETTE.warn),
error: hex(LOBSTER_PALETTE.error),
muted: hex(LOBSTER_PALETTE.muted),
heading: baseChalk.bold.hex(LOBSTER_PALETTE.accent),
command: hex(LOBSTER_PALETTE.accentBright),
option: hex(LOBSTER_PALETTE.warn),
} as const;
/** Return true when color styling is active. */
export const isRich = () => baseChalk.level > 0;
/** Conditionally apply a color function based on caller rich-output state. */
export const colorize = (rich: boolean, color: (value: string) => string, value: string) =>
rich ? color(value) : value;