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
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:
107
packages/terminal-core/package.json
Normal file
107
packages/terminal-core/package.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "@openclaw/terminal-core",
|
||||
"version": "0.0.0-private",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./ansi": {
|
||||
"types": "./dist/ansi.d.mts",
|
||||
"import": "./dist/ansi.mjs",
|
||||
"default": "./dist/ansi.mjs"
|
||||
},
|
||||
"./decorative-emoji": {
|
||||
"types": "./dist/decorative-emoji.d.mts",
|
||||
"import": "./dist/decorative-emoji.mjs",
|
||||
"default": "./dist/decorative-emoji.mjs"
|
||||
},
|
||||
"./health-style": {
|
||||
"types": "./dist/health-style.d.mts",
|
||||
"import": "./dist/health-style.mjs",
|
||||
"default": "./dist/health-style.mjs"
|
||||
},
|
||||
"./links": {
|
||||
"types": "./dist/links.d.mts",
|
||||
"import": "./dist/links.mjs",
|
||||
"default": "./dist/links.mjs"
|
||||
},
|
||||
"./note": {
|
||||
"types": "./dist/note.d.mts",
|
||||
"import": "./dist/note.mjs",
|
||||
"default": "./dist/note.mjs"
|
||||
},
|
||||
"./osc-progress": {
|
||||
"types": "./dist/osc-progress.d.mts",
|
||||
"import": "./dist/osc-progress.mjs",
|
||||
"default": "./dist/osc-progress.mjs"
|
||||
},
|
||||
"./palette": {
|
||||
"types": "./dist/palette.d.mts",
|
||||
"import": "./dist/palette.mjs",
|
||||
"default": "./dist/palette.mjs"
|
||||
},
|
||||
"./progress-line": {
|
||||
"types": "./dist/progress-line.d.mts",
|
||||
"import": "./dist/progress-line.mjs",
|
||||
"default": "./dist/progress-line.mjs"
|
||||
},
|
||||
"./prompt-select-styled": {
|
||||
"types": "./dist/prompt-select-styled.d.mts",
|
||||
"import": "./dist/prompt-select-styled.mjs",
|
||||
"default": "./dist/prompt-select-styled.mjs"
|
||||
},
|
||||
"./prompt-select-styled-params": {
|
||||
"types": "./dist/prompt-select-styled-params.d.mts",
|
||||
"import": "./dist/prompt-select-styled-params.mjs",
|
||||
"default": "./dist/prompt-select-styled-params.mjs"
|
||||
},
|
||||
"./prompt-style": {
|
||||
"types": "./dist/prompt-style.d.mts",
|
||||
"import": "./dist/prompt-style.mjs",
|
||||
"default": "./dist/prompt-style.mjs"
|
||||
},
|
||||
"./restore": {
|
||||
"types": "./dist/restore.d.mts",
|
||||
"import": "./dist/restore.mjs",
|
||||
"default": "./dist/restore.mjs"
|
||||
},
|
||||
"./safe-text": {
|
||||
"types": "./dist/safe-text.d.mts",
|
||||
"import": "./dist/safe-text.mjs",
|
||||
"default": "./dist/safe-text.mjs"
|
||||
},
|
||||
"./stream-writer": {
|
||||
"types": "./dist/stream-writer.d.mts",
|
||||
"import": "./dist/stream-writer.mjs",
|
||||
"default": "./dist/stream-writer.mjs"
|
||||
},
|
||||
"./table": {
|
||||
"types": "./dist/table.d.mts",
|
||||
"import": "./dist/table.mjs",
|
||||
"default": "./dist/table.mjs"
|
||||
},
|
||||
"./terminal-link": {
|
||||
"types": "./dist/terminal-link.d.mts",
|
||||
"import": "./dist/terminal-link.mjs",
|
||||
"default": "./dist/terminal-link.mjs"
|
||||
},
|
||||
"./theme": {
|
||||
"types": "./dist/theme.d.mts",
|
||||
"import": "./dist/theme.mjs",
|
||||
"default": "./dist/theme.mjs"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.6.0",
|
||||
"chalk": "5.6.2"
|
||||
}
|
||||
}
|
||||
83
packages/terminal-core/src/ansi.test.ts
Normal file
83
packages/terminal-core/src/ansi.test.ts
Normal 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("[31mab[0m", 1)).toBe("[31ma[0m");
|
||||
expect(truncateToVisibleWidth("[31m表文[0m", 1)).toBe("[31m[0m");
|
||||
expect(visibleWidth(truncateToVisibleWidth("[31m表文[0m", 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");
|
||||
});
|
||||
});
|
||||
212
packages/terminal-core/src/ansi.ts
Normal file
212
packages/terminal-core/src/ansi.ts
Normal 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;
|
||||
}
|
||||
89
packages/terminal-core/src/decorative-emoji.test.ts
Normal file
89
packages/terminal-core/src/decorative-emoji.test.ts
Normal 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. 🦞",
|
||||
);
|
||||
});
|
||||
});
|
||||
95
packages/terminal-core/src/decorative-emoji.ts
Normal file
95
packages/terminal-core/src/decorative-emoji.ts
Normal 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();
|
||||
}
|
||||
47
packages/terminal-core/src/display-string.test.ts
Normal file
47
packages/terminal-core/src/display-string.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
118
packages/terminal-core/src/display-string.ts
Normal file
118
packages/terminal-core/src/display-string.ts
Normal 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;
|
||||
}
|
||||
48
packages/terminal-core/src/health-style.ts
Normal file
48
packages/terminal-core/src/health-style.ts
Normal 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;
|
||||
}
|
||||
19
packages/terminal-core/src/index.ts
Normal file
19
packages/terminal-core/src/index.ts
Normal 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";
|
||||
48
packages/terminal-core/src/links.test.ts
Normal file
48
packages/terminal-core/src/links.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
30
packages/terminal-core/src/links.ts
Normal file
30
packages/terminal-core/src/links.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
239
packages/terminal-core/src/note.ts
Normal file
239
packages/terminal-core/src/note.ts
Normal 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);
|
||||
}
|
||||
30
packages/terminal-core/src/osc-progress.test.ts
Normal file
30
packages/terminal-core/src/osc-progress.test.ts
Normal 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\\",
|
||||
]);
|
||||
});
|
||||
});
|
||||
77
packages/terminal-core/src/osc-progress.ts
Normal file
77
packages/terminal-core/src/osc-progress.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
12
packages/terminal-core/src/palette.ts
Normal file
12
packages/terminal-core/src/palette.ts
Normal 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;
|
||||
30
packages/terminal-core/src/progress-line.ts
Normal file
30
packages/terminal-core/src/progress-line.ts
Normal 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;
|
||||
}
|
||||
37
packages/terminal-core/src/prompt-select-styled-params.ts
Normal file
37
packages/terminal-core/src/prompt-select-styled-params.ts
Normal 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;
|
||||
}
|
||||
49
packages/terminal-core/src/prompt-select-styled.test.ts
Normal file
49
packages/terminal-core/src/prompt-select-styled.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
10
packages/terminal-core/src/prompt-select-styled.ts
Normal file
10
packages/terminal-core/src/prompt-select-styled.ts
Normal 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));
|
||||
}
|
||||
16
packages/terminal-core/src/prompt-style.ts
Normal file
16
packages/terminal-core/src/prompt-style.ts
Normal 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;
|
||||
114
packages/terminal-core/src/restore.test.ts
Normal file
114
packages/terminal-core/src/restore.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
70
packages/terminal-core/src/restore.ts
Normal file
70
packages/terminal-core/src/restore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/terminal-core/src/safe-text.test.ts
Normal file
21
packages/terminal-core/src/safe-text.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
21
packages/terminal-core/src/safe-text.ts
Normal file
21
packages/terminal-core/src/safe-text.ts
Normal 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;
|
||||
}
|
||||
59
packages/terminal-core/src/stream-writer.test.ts
Normal file
59
packages/terminal-core/src/stream-writer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
packages/terminal-core/src/stream-writer.ts
Normal file
74
packages/terminal-core/src/stream-writer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
9
packages/terminal-core/src/string.ts
Normal file
9
packages/terminal-core/src/string.ts
Normal 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();
|
||||
}
|
||||
423
packages/terminal-core/src/table.test.ts
Normal file
423
packages/terminal-core/src/table.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
612
packages/terminal-core/src/table.ts
Normal file
612
packages/terminal-core/src/table.ts
Normal 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`;
|
||||
}
|
||||
41
packages/terminal-core/src/terminal-link.test.ts
Normal file
41
packages/terminal-core/src/terminal-link.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
30
packages/terminal-core/src/terminal-link.ts
Normal file
30
packages/terminal-core/src/terminal-link.ts
Normal 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`;
|
||||
}
|
||||
36
packages/terminal-core/src/theme.ts
Normal file
36
packages/terminal-core/src/theme.ts
Normal 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;
|
||||
Reference in New Issue
Block a user