docs: document terminal core helpers

This commit is contained in:
Peter Steinberger
2026-06-04 01:36:23 -04:00
parent 5b98f03c64
commit d14fe163b5
13 changed files with 67 additions and 0 deletions

View File

@@ -1,5 +1,8 @@
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;
@@ -9,6 +12,7 @@ export type DecorativeEmojiOptions = {
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();
@@ -24,6 +28,7 @@ function isKnownEmojiTerminal(env: NodeJS.ProcessEnv): boolean {
);
}
/** 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,
@@ -34,6 +39,7 @@ function hasUtf8Locale(env: NodeJS.ProcessEnv): boolean {
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;
@@ -57,10 +63,12 @@ export function supportsDecorativeEmoji(options: DecorativeEmojiOptions = {}): b
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,
@@ -70,6 +78,7 @@ export function decorativePrefix(
return prefix ? `${prefix} ${text}` : text;
}
/** Strip decorative emoji for terminals that should not receive them. */
export function stripDecorativeEmojiForTerminal(
text: string,
options: DecorativeEmojiOptions = {},

View File

@@ -1,11 +1,15 @@
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());
@@ -14,6 +18,7 @@ function normalizeSafe(fn: () => string | undefined): string | 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)) {
@@ -25,6 +30,7 @@ function resolveTermuxHome(env: NodeJS.ProcessEnv): string | 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) ??
@@ -34,6 +40,7 @@ function resolveRawOsHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): str
);
}
/** Resolve raw home with OPENCLAW_HOME tilde expansion. */
function resolveRawHomeDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
@@ -46,6 +53,7 @@ function resolveRawHomeDir(
return resolveRawOsHomeDir(env, homedir);
}
/** Resolve the effective absolute home directory for display replacement. */
function resolveEffectiveHomeDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
@@ -54,6 +62,7 @@ function resolveEffectiveHomeDir(
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) {
@@ -63,6 +72,7 @@ function resolveHomeDisplayPrefix(): { home: string; prefix: string } | undefine
return explicitHome ? { home, prefix: "$OPENCLAW_HOME" } : { home, prefix: "~" };
}
/** Replace the effective home path with "~" or "$OPENCLAW_HOME" for terminal display. */
export function displayString(input: string): string {
if (!input) {
return input;

View File

@@ -1,6 +1,9 @@
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;

View File

@@ -1,3 +1,5 @@
// Public barrel for shared terminal formatting helpers.
export * from "./ansi.js";
export * from "./decorative-emoji.js";
export * from "./health-style.js";

View File

@@ -1,14 +1,18 @@
// 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;
@@ -19,6 +23,7 @@ export function supportsOscProgress(env: NodeJS.ProcessEnv, isTty: boolean): boo
);
}
/** Remove OSC terminators and escape introducers from progress labels. */
function sanitizeOscProgressLabel(label: string): string {
return label
.replaceAll(OSC_PROGRESS_ST, "")
@@ -30,6 +35,7 @@ function sanitizeOscProgressLabel(label: string): string {
.trim();
}
/** Format one OSC progress control sequence. */
function formatOscProgress(state: number, percent: number | null, label: string): string {
const cleanLabel = sanitizeOscProgressLabel(label);
if (percent === null) {
@@ -39,6 +45,7 @@ function formatOscProgress(state: number, percent: number | null, label: string)
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;

View File

@@ -1,5 +1,8 @@
// 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;
@@ -7,6 +10,7 @@ export function registerActiveProgressLine(stream: NodeJS.WriteStream): void {
activeStream = stream;
}
/** Clear the active progress line when it is attached to a TTY stream. */
export function clearActiveProgressLine(): void {
if (!activeStream?.isTTY) {
return;
@@ -14,6 +18,7 @@ export function clearActiveProgressLine(): void {
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;

View File

@@ -1,20 +1,26 @@
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,

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
// 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;
@@ -10,11 +14,13 @@ export type SafeStreamWriter = {
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;

View File

@@ -1,3 +1,6 @@
// 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 "";

View File

@@ -1,3 +1,6 @@
// OSC 8 terminal hyperlink formatting with plain-text fallback.
/** Format a clickable terminal link when supported, otherwise return a readable fallback. */
export function formatTerminalLink(
label: string,
url: string,

View File

@@ -1,6 +1,8 @@
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 &&
@@ -10,6 +12,7 @@ const baseChalk = process.env.NO_COLOR && !hasForceColor ? new Chalk({ level: 0
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),
@@ -24,7 +27,9 @@ export const theme = {
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;