Files
adolf/packages/terminal-core/src/note.ts
alvis bedb527145
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
Vendor OpenClaw source as Adolf fork baseline
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
2026-07-05 09:36:54 +00:00

240 lines
6.6 KiB
TypeScript

// 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);
}