@easbot/terminal
v0.3.24
Published
Terminal interaction toolkit for the EASBOT ecosystem — themed colors, ANSI utilities, OSC-8 hyperlinks, command formatting, table rendering, styled prompts, safe stream writes, and terminal-state recovery
Maintainers
Readme
English | 中文
@easbot/terminal
终端交互工具库 — 为 EASBOT 生态提供统一的终端输出、格式化、提示、Prompt 与状态恢复能力。
简介
@easbot/terminal 是一个面向 EASBOT 生态的终端交互工具库,封装了命令行 / TUI 场景下常用的能力:终端主题色、ANSI 处理、表格渲染、命令输出格式化、提示样式化、JSON 规范输出、人类输出统一封装(printHuman / renderLines)、一个统一的交互式 Prompt namespace、一个可搜索的多选交互 prompt、一个CLI 输入 helper、一个轻量级 markdown 渲染器、图标 / 状态符号统一常量、Loader 旋转动画、终端状态恢复等。所有能力都基于终端能力自动检测(NO_COLOR / FORCE_COLOR / TTY)进行降级,可在管道、文件重定向等非 TTY 场景下安全使用。
本包完全 vendor-neutral:不绑定任何外部文档站或 vendor-specific 配置。formatRootedLink / formatRootLink 等 helper 显式接受 root 参数,由调用方决定集成方式。
特性
- 终端主题:基于 chalk + Lobster 调色板,提供统一的强调色 / 信息 / 成功 / 警告 / 错误等样式
- 终端能力检测:自动识别 TTY、颜色等级、
NO_COLOR/FORCE_COLOR,不支持富文本时自动降级为纯文本 - ANSI 工具:剥离 SGR / OSC-8 序列,计算可见字符宽度,处理 Unicode / emoji 文本对齐
- 路径显示:支持
~/$EASBOT_HOME缩短用户主目录路径(resolveUserPath/shortenHomePath/displayPath) - OSC-8 超链接:跨平台终端超链接,自动检测终端能力,提供降级文本
- 表格渲染:
renderTable支持自适应列宽、Unicode / ASCII / 无边框三种样式、文本换行、ANSI 样式保持 - 命令输出格式化:成功 / 错误 / 警告 / 信息 / 标题 / 键值对 / 命令示例 / 列表项 / 进度状态 / 计数等
- 提示样式化:纯样式化封装(
selectStyled/note/stylePromptMessage/stylePromptTitle/stylePromptHint) - 统一交互 Prompt namespace:
Prompt.select / groupMultiselect / text / password / confirm / multiselect / intro / outro / cancel / Log.* / spinner—— 业务方唯一交互入口(详见"Prompt namespace"节) - 可搜索多选 prompt:
Search.multiselect是交互式、实时过滤的多选 prompt,支持完整键盘控制、可选锁定分区、对 stdin / 信号优雅清理 - Loader 旋转动画:
Loader.create()返回stateless spinner 实例(不继承 tui 组件模型),可定制帧 / 颜色 / 消息 - Input CLI 输入:
Input.readText/Input.readConfirm/Input.readPassword是 readline 薄包装的 CLI prompt;支持注入input/output用于测试隔离 - Markdown 渲染:
renderMarkdown解析极简 markdown 子集(标题 / 列表 / 代码块 / 加粗 / 斜体 / 链接),不引marked,保持 CLI 包体小 - 人类输出统一封装:
printHuman(lines, { title, outro })把分组细节 + 总结 + outro 收尾一页搞定,所有 CLI 子命令默认人类输出走这条 - 图标 / 状态符号统一:所有
✓ / ✗ / ⚠ / ℹ / ◆ / ● / ○ / │ / ─ / ┘ / • / ↡ / ⊘等图标硬编码仅出现在symbols.ts,业务方通过Symbol.statusIcon(...)/Symbol.colorize(...)调用 - JSON 规范输出:
emitJson / emitJsonOk / emitJsonError / wrapJson / wrapError实现 CLI--json规范的{ ok, data, meta? }/{ ok, false, error: { code, message } }协议 - 安全流写入:
createSafeStreamWriter自动处理EPIPE/EIO,流关闭后静默忽略后续写入 - 进度行管理:注册 / 清除 / 注销活动进度行,仅在 TTY 下生效
- 终端状态恢复:
restoreTerminalState在退出 / 异常时重置光标、鼠标跟踪、括号粘贴模式 - 色彩一致性:所有样式化输出统一经过
isRich()+colorize(),快照 / 非 TTY 场景不会泄漏 ANSI 转义 - TTY 注入:交互组件(
formatTerminalLink、Search.multiselect、Input.readText等)接受可选isTTY参数,单元测试不再依赖process.stdout.isTTY
安装
pnpm add @easbot/terminal快速开始
主题与终端能力
import { theme, isRich, colorize } from '@easbot/terminal';
const rich = isRich();
console.log(colorize(rich, theme.accent, '重要提示'));
console.log(colorize(rich, theme.success, '✓ 操作成功'));
console.log(colorize(rich, theme.error, '✗ 操作失败'));命令输出格式化
import {
formatSuccess,
formatError,
formatWarning,
formatInfo,
formatHeading,
formatKeyValue,
formatCommandExample,
formatListItem,
formatStatus,
} from '@easbot/terminal';
console.log(formatHeading('构建产物'));
console.log(formatKeyValue('入口', './dist/index.mjs'));
console.log(formatKeyValue('格式', 'esm / cjs'));
console.log(formatSuccess('构建完成'));
console.log(formatError('缺少必填参数 --name'));
console.log(formatWarning('检测到未提交变更'));
console.log(formatCommandExample('pnpm build', '构建所有包'));
console.log(formatListItem('◇', 'lint', 'biome check .'));
console.log(formatStatus('running', '正在执行构建...'));路径与超链接
import { resolveUserPath, resolveConfigDir, displayPath, formatTerminalLink } from '@easbot/terminal';
const configDir = resolveConfigDir(); // ~/.config/easbot(如设了 EASBOT_STATE_DIR 则用之)
console.log(displayPath(configDir));
// TTY: OSC-8 链接;非 TTY: "Label (URL)" 降级
console.log(formatTerminalLink('EASBOT 仓库', 'https://github.com/houjallen/easbot'));
// 通用根域名 helper(vendor-neutral —— 调用方决定 root)
import { formatRootedLink } from '@easbot/terminal';
console.log(formatRootedLink('https://docs.example.com', '/guide', '快速开始'));表格渲染
import { renderTable } from '@easbot/terminal';
console.log(
renderTable({
columns: [
{ key: 'name', header: '名称', align: 'left' },
{ key: 'version', header: '版本', align: 'left' },
{ key: 'status', header: '状态', align: 'center' },
],
rows: [
{ name: '@easbot/terminal', version: '0.3.13', status: '✓' },
{ name: '@easbot/note', version: '0.3.13', status: '✓' },
],
width: 80,
border: 'unicode',
}),
);人类输出统一封装(printHuman)
import { printHuman, renderLines } from '@easbot/terminal';
// 业务侧常见的"分组细节 + 总结 + outro"一页输出
printHuman(
[' ✓ eas-agent-creation', ' ✓ eas-agent-evolution', ' ✗ eas-legacy-tool'],
{
title: 'Project Skills Update',
outro: 'Run `easbot skills list` to see current state.',
empty: 'No skills tracked in lock file.',
},
);
// renderLines:构造 list / find 风格的分组树状
const lines = renderLines({
title: 'Project Skills (3)',
groups: [
{ key: 'universal (.agents/skills)', rows: [' ✓ foo', ' ✓ bar'] },
{ key: 'claude-code', rows: [' ✓ baz'] },
],
});Prompt namespace(统一交互入口)
Prompt 是 @easbot/terminal 提供的统一交互 namespace —— 业务方唯一推荐的交互入口。内部按 B+A 混合方案封装:
select / groupMultiselect / text / password / confirm:薄包@clack/prompts(多年踩坑调优过的 raw mode / 信号 / 取消)multiselect:自实现,调用本包Search.multiselect(fuzzy + 锁定分区 + 翻页)intro / outro / cancel / Log.* / spinner:自实现(chalk + theme),统一配色到本包theme
import { Prompt } from '@easbot/terminal';
// 单选
const choice = await Prompt.select({
message: '选择安装范围',
options: [
{ value: 'project', label: 'Project', hint: '当前目录' },
{ value: 'global', label: 'Global', hint: '主目录' },
],
});
if (Prompt.isCancel(choice)) Prompt.cancel('用户已取消');
// 多选
const selected = await Prompt.multiselect({
message: '选择要启用的 Agent',
options: [
{ value: 'claude-code', label: 'Claude Code' },
{ value: 'easbot', label: 'EASBot' },
],
initialValues: ['easbot'], // 预选
required: true,
});
// 分组多选
const skills = await Prompt.groupMultiselect({
message: '选择要安装的技能',
options: {
General: [{ value: 'foo', label: 'foo' }, { value: 'bar', label: 'bar' }],
'Advanced Plugins': [{ value: 'baz', label: 'baz' }],
},
});
// 流程骨架
Prompt.intro(' skills ');
// ... do something ...
Prompt.outro(pc.green('Done!'));
// 取消:Prompt.cancel 是 `never`,内置 process.exit(0)
if (Prompt.isCancel(selected)) Prompt.cancel('用户已取消');
// 状态流(统一配色到 terminal theme)
Prompt.Log.info('操作成功');
Prompt.Log.warn('警告');
Prompt.Log.error('错误');
Prompt.Log.step('步骤');
Prompt.Log.message('普通消息(无前缀)');
// Spinner
const spinner = Prompt.spinner();
spinner.start('解析源...');
spinner.stop(`Found ${count} skill(s)`);取消语义收口:
Prompt.cancelSymbol—— 唯一 sentinel(与Search.cancelSymbol同引用)Prompt.isCancel(value)—— 类型守卫,同时识别Prompt.cancelSymbol与 clack 的Symbol('')Prompt.cancel(message?)——never返回 + 内置process.exit(0)
样式化提示(低阶辅助)
import { selectStyled, note, wrapNoteMessage } from '@easbot/terminal';
const choice = await selectStyled({
message: '请选择要执行的操作',
options: [
{ value: 'build', label: '构建', hint: '运行 pnpm build' },
{ value: 'test', label: '测试', hint: '运行 pnpm test:run' },
],
});
note(wrapNoteMessage('配置文件已成功保存到 ~/.config/app/config.json'), '成功');图标 / 符号(统一硬编码源)
业务方禁止直接写 ✓ ✗ ⚠ ℹ ◆ ● ○ │ ─ • 等字面量。所有图标必须通过 Symbol namespace 取得。
import { Symbol as Glyph } from '@easbot/terminal';
// 状态图标(自带主题色)
console.log(`${Glyph.statusIcon('success')} 操作成功`); // ✓ 绿色
console.log(`${Glyph.statusIcon('error')} 操作失败`); // ✗ 红色
console.log(`${Glyph.statusIcon('warning')} 警告`); // ⚠ 黄色
console.log(`${Glyph.statusIcon('info')} 提示`); // ℹ 蓝色
console.log(`${Glyph.statusIcon('active')} 进行中`); // ◆ 强调色
console.log(`${Glyph.statusIcon('skipped')} 已跳过`); // ↡ 灰色
console.log(`${Glyph.statusIcon('blocked')} 已拦截`); // ⊘ 红色
// 装饰条
console.log(Glyph.bar.vertical); // │
console.log(Glyph.bar.bullet); // •
// 任意字符套上主题色
console.log(Glyph.colorize('自定义字符', 'success'));Loader 旋转动画
import { Loader, colorize, isRich, theme } from '@easbot/terminal';
const loader = Loader.create({
message: '正在加载…',
spinnerColor: (s) => colorize(isRich(), theme.accent, s),
messageColor: (s) => colorize(isRich(), theme.muted, s),
});
loader.start();
// 每帧调用 line() 取出最新一行的文本
process.stdout.write(`\r${loader.line()}\x1b[K`);
loader.stop();
process.stdout.write('\n');Loader 是一个 namespace(stateless 工厂),不依赖 tui 组件模型 —— 返回一个普通对象,方法:start / stop / isRunning / line / setMessage / setIndicator。默认:10 帧 Braille spinner,80 ms 间隔。
Input CLI 输入
import { Input } from '@easbot/terminal';
const name = await Input.readText({ message: '请输入名称:' });
const ok = await Input.readConfirm({ message: '继续吗?' });
const pw = await Input.readPassword({ message: '密码:' });Input 是一个 namespace,含 3 个 stateless helper。测试时通过 input / output 注入 mock stream(使用 __tests__/helpers/mock-terminal.ts 的 MockTerminalIO)。
Markdown 渲染
import { renderMarkdown } from '@easbot/terminal';
console.log(renderMarkdown('# 标题\n\n这是一段 **加粗** 文本。').join('\n'));支持 # / ## / ### 标题、- / * / 1. 列表、围栏代码块、行内 **加粗** / *斜体* / `code` / [文本](链接)。不依赖 marked,保持 CLI 包体小。
可搜索多选(Search.multiselect)
import { Search, cancelSymbol } from '@easbot/terminal';
const result = await Search.multiselect({
message: '请选择要启用的 Agent:',
items: [
{ value: 'a', label: 'Agent A', hint: '通用任务' },
{ value: 'b', label: 'Agent B', hint: '代码导向' },
],
required: true,
lockedSection: {
title: '始终包含',
items: [{ value: 'core', label: '核心 Agent' }],
hiddenCount: 2,
},
// 测试 / 嵌入式场景可显式注入 TTY 状态:
// isTTY: true // 强制启用 raw mode + 颜色
// isTTY: false // 强制禁用 raw mode 与颜色
});
if (result === cancelSymbol) return;
// result 为已选项的值数组(type T[])键盘约定:↑↓ ←→ 单步移动 · PageUp/Down 翻页 · Home/End 跳首末 · Space 切换 · Backspace 删除搜索字符 · Enter 提交 · Esc / Ctrl+C 取消。
安全流写入与状态恢复
import { createSafeStreamWriter, restoreTerminalState, registerActiveProgressLine } from '@easbot/terminal';
const writer = createSafeStreamWriter();
registerActiveProgressLine(process.stdout);
process.on('exit', () => restoreTerminalState('process exit'));
process.on('SIGINT', () => restoreTerminalState('SIGINT', { exit: true }));
// 流断开(如管道关闭)后再次写入不会抛 EPIPE
writer.write('first\n');
writer.write('second\n');CLI --json 规范输出
import { emitJson, emitJsonOk, emitJsonError, wrapJson, wrapError } from '@easbot/terminal';
// 低阶:构造 envelope
const env = wrapJson({ items: [{ id: 1 }] }, { count: 1 });
// { ok: true, data: { items: [{ id: 1 }] }, meta: { count: 1 } }
const err = wrapError({ reason: 'not found' });
// { ok: false, data: { reason: 'not found' } }
// 高阶:直接 emit 到 stdout
emitJsonOk({ items: [{ id: 1 }, { id: 2 }] }, { count: 2 });
// stdout: { "ok": true, "data": { "items": [...] }, "meta": { "count": 2 } }
emitJsonError('E_NOT_FOUND', '资源不存在');
// stdout: { "ok": false, "error": { "code": "E_NOT_FOUND", "message": "资源不存在" } }API 总览
主题与样式
theme— 主题颜色对象(accent/accentBright/accentDim/info/success/warn/error/muted/heading/command/option)isRich()— 判断终端是否支持富文本颜色colorize(rich, color, value)— 条件着色工具LOBSTER_PALETTE— Lobster 调色板原始色值(8 种颜色,as const锁定)
ANSI 工具
stripAnsi(input)— 剥离 ANSI SGR / OSC-8 序列,返回纯文本visibleWidth(input)— 计算剥离 ANSI 后的可见字符宽度
路径与显示
resolveUserPath(input)— 解析用户路径(支持~波浪号)resolveConfigDir(env?, homedir?)— 解析配置目录(可被EASBOT_STATE_DIR覆盖;默认~/.config/easbot)resolveHomeDir()— 解析用户主目录shortenHomePath(input)/displayPath(input)— 缩短路径中的主目录部分shortenHomeInString(input)/displayString(input)— 替换字符串中所有匹配的主目录路径resolveEffectiveHomeDir/resolveRequiredHomeDir/expandHomePrefix— 主目录解析工具formatTerminalLink(label, url, opts?, isTTY?)— 格式化 OSC-8 终端超链接,支持isTTY注入formatRootedLink(root, path, label?, opts?, isTTY?)— 通用根 URL 链接 helper(vendor-neutral)formatRootLink(root, label?, isTTY?)— 通用根 URL 单链接
命令输出格式化
formatSuccess/formatError/formatWarning/formatInfo— 带图标的前缀消息formatHeading/formatKeyValue/formatCommandExample/formatListItem— 章节与列表formatStatus/formatSeparator/formatEmptyMessage/formatCount— 进度与计数
表格
TableColumn/RenderTableOptions— 表格列配置与渲染选项类型renderTable(opts)— 渲染表格字符串
人类输出统一封装
PrintHumanOptions/RenderLinesGroup/RenderLinesOptions— 类型printHuman(lines, opts)—lines+title+outro一页输出(boxed 走note())formatPrintHuman(lines, opts)— 返回字符串而非直接打印(用于嵌入更大输出)renderLines(opts)— 构造分组树状行(list / find / store list 风格)printRenderLines(opts)— 同上但直接打印formatListTruncated(items, maxShow?)/formatList(items, maxShow?)— 截断列表(≤N 全显 + "N more" 提示)
提示样式化(低阶)
stylePromptMessage/stylePromptTitle/stylePromptHint— 提示文本样式化selectStyled<T>(params)— 样式化的选择提示note(message, title?, options?)— 样式化的注释框(支持columns/maxWidth注入)wrapNoteMessage(message, options?)— 注释消息自动换行
Prompt namespace(统一交互入口)
业务方唯一推荐的交互入口。细节见上文"Prompt namespace"节:
Prompt.select<T>(opts)— 单选Prompt.groupMultiselect<T>(opts)— 分组多选Prompt.multiselect<T>(opts)— 搜索多选(带 fuzzy + 锁定分区 + 翻页)Prompt.text(opts)— 文本输入Prompt.password(opts)— 隐藏输入Prompt.confirm(opts)— y/n 确认Prompt.intro(title)— 流程开篇(heading + 装饰线)Prompt.outro(message?, { success? })— 流程收尾(success/error 主题色)Prompt.cancel(message?)—never,内置process.exit(0)Prompt.cancelSymbol— 唯一 sentinel(与Search.cancelSymbol同引用)Prompt.isCancel(value)— 类型守卫Prompt.spinner(opts?)— Loader.create 薄包装,返回SpinnerInstancePrompt.SpinnerInstance—{ start, stop, update, line }Prompt.Log.info / warn / error / success / step / message(msg, detail?)— 状态流
图标 / 符号(统一硬编码源)
Symbolnamespace:Symbol.icons: Record<SymbolKind, string>— 状态图标裸字符(success/error/warning/info/accent/muted/heading/active/submit/cancel/locked/pending/radioActive/radioInactive/skipped/blocked/cursor)Symbol.bar.{ vertical, horizontal, cornerBottom, cornerBottomRight, teeRight, stepActive, bullet }Symbol.colorize(char, kind)— 任意字符套上主题色Symbol.statusIcon(kind)— 返回Symbol.icons[kind]+ 主题色Symbol.prefixed(kind, text)—"<icon> <text>"形式前缀
SymbolKind类型
Loader 旋转动画
Loader 是 namespace,提供单一工厂方法:
Loader.create(options?)→Loader.Instanceoptions.message?— 主文本(默认'Loading...')options.spinnerColor?— 帧字符颜色函数(默认colorize(isRich(), theme.accent, ...))options.messageColor?— 消息颜色函数(默认colorize(isRich(), theme.muted, ...))options.indicator?—{ frames?: string[]; intervalMs?: number }(默认 10 帧 Braille / 80 ms)
Loader.Instance:start()— 幂等(重启 interval)stop()— 幂等isRunning()— 当前动画状态line()— 当前渲染行('帧 消息')setMessage(message)— 更新文本setIndicator(indicator?)— 更新帧 / 间隔;自动重启
Input CLI 输入
Input 是 namespace,含 3 个 stateless helper。测试时通过 input / output 注入 mock stream。
Input.readText(options)— 读单行。Options:message/hint?/defaultValue?/hidden?/messageColor?/hintColor?/input?/output?Input.readConfirm(options)— y/n 确认,无效输入会递归重试。Options:message/hint?/defaultValue?(默认true)/messageColor?/hintColor?/input?/output?Input.readPassword(options)—readText的hidden: true别名
Markdown 渲染
renderMarkdown(md, opts?)→string[](每行一段)- 支持:
# / ## / ###标题、---分隔线、- / * / 1.列表、围栏代码块(```)、行内**加粗**/*斜体*/`code`/[文本](链接) opts.headingColor?/boldColor?/italicColor?/codeColor?/linkColor?— 覆盖默认主题的颜色函数
- 支持:
JSON 规范输出
wrapJson<T>(data, meta?, ok?)→JsonEnvelope<T>(构造 envelope;ok 默认 true)wrapError<T>(data, meta?)→JsonEnvelope<T>(ok:false 错误态)emitJson(envelope, indent?)→ 序列化 + console.logemitCommandJson(envelope)— 同上固定 indent=2emitJsonOk(data, meta?)— 直接 emit{ ok: true, data, meta? }emitJsonError(code, message)— 直接 emit{ ok: false, error: { code, message } }JsonEnvelope<T>—{ ok: boolean; data: T; meta?: Record<string, unknown> }
流与状态
SafeStreamWriterOptions/SafeStreamWriter— 安全流写入器类型createSafeStreamWriter(options?)— 创建安全流写入器,自动处理EPIPE/EIOregisterActiveProgressLine(stream)/clearActiveProgressLine()/unregisterActiveProgressLine(stream?)— 进度行管理restoreTerminalState(reason?, options?)— 恢复终端状态(光标 / 鼠标 / 括号粘贴模式)RestoreTerminalStateOptions—{ resumeStdin? | resumeStdinIfPaused? }(默认false;Docker TTY 行为)
其它
styleHealthChannelLine(line, rich)— 健康检查通道行样式化StatusKind—'pending' | 'running' | 'success' | 'error' | 'skipped'
环境变量
| 变量 | 作用 |
|------|------|
| NO_COLOR | 存在时禁用终端颜色(除非同时设置了 FORCE_COLOR),遵循 no-color.org 规范 |
| FORCE_COLOR | 非空且不为 '0' 时强制启用颜色 |
| HOME / USERPROFILE | 主目录解析的备选来源(OS 默认值) |
| EASBOT_TEST_HOME | 测试专用 最高优先级的主目录覆盖(详见 home-dir.ts) |
| EASBOT_HOME | 显式指定主目录;设置后路径显示前缀由 ~ 切换为 $EASBOT_HOME |
| EASBOT_STATE_DIR | 配置目录覆盖项 |
主目录解析顺序(高 → 低):
EASBOT_TEST_HOME → EASBOT_HOME → HOME → USERPROFILE → os.homedir()。
开发
# 安装依赖
pnpm install
# 构建
pnpm build
# 测试
pnpm test:run
# 类型检查
pnpm type-check
# Lint / Format
pnpm lint
pnpm format:fix发布
# Windows
powershell -ExecutionPolicy Bypass -File scripts/publish.ps1
# Unix/macOS
bash scripts/publish.sh许可证
MIT
