@guangnao/agent-ui
v1.0.1
Published
CLI UX+UI framework — 专为命令行而生的底层 TUI 开发框架
Readme
@guangnao/agent-ui
CLI UX/UI 底层框架
专为命令行而生,对标 Claude Code CLI / Codex CLI 的底层架构。
安全可嵌入 · CJK 感知 · 管道/CI 自动降级 · 依赖极简(chalk + string-width)。
特性
- 🎨 完整颜色主题系统 — 暗/亮双主题,coral 品牌色,
NO_COLOR合规,DockTheme可注入自定义品牌色 - 📐 CJK + ANSI 双重感知 — 所有排版计算均使用视觉宽度(中文字符 = 2 列)
- ⌨️ 高性能 TUI 引擎(BottomDock) — 行级差分渲染(仅改写变化行)、写合并缓冲(4× syscall → 1×)、空闲零开销、高度钳制、resize 零残影
- 🔁 响应式布局运行时(LiveView) —
视图 = f(width,height):窗口变化自动用新尺寸重排、行级差分无闪烁。纯布局函数(rule/box/table/grid)挂进来即天生 resize 自适应,无需每个 app 手写终端控制 - 🔑 完整输入处理 — VT100 按键解析、readline 行编辑、命令历史、Tab 补全
- 🎯 丰富组件库 — Spinner / ProgressBar / Table / Tree / Badge / Box / Grid
- 📝 富文本渲染 — Markdown、语法高亮(7 种语言)、Unified Diff
- 🌐 管道/CI 全兼容 — 所有 prompt 在非 TTY 环境自动降级为 readline 数字菜单;DirectSink 自动检测 stderr.isTTY
- 🛡️ 嵌入安全 — Ctrl+C 抛
PromptCancelledError而非process.exit();不劫持uncaughtException - 🈯 盘古之白 — 内置
pangu()自动补全中文与英文/数字间的排版空格 - ✅ 100% TypeScript — strict 模式,node16 模块解析
架构
src/
core/ → 原语层(ANSI 序列、字符宽度、终端能力探测)
paint/ → 颜色主题(Palette 接口、buildPalette、默认 c 实例)
layout/ → 文本排版(wrap / pad / box / grid)
rich/ → 富文本(highlight / markdown / diff)
widgets/ → UI 组件(spinner / progress / table / tree / badge)
input/ → 输入处理(editops / history / vt-parser / completer)
dock/ → 「活」UI 运行时(RenderSink / DirectSink / BottomDock 内联活动区 / LiveView 全屏响应式视图)
prompt/ → 交互式 Prompt(errors / text / select / multiselect / password / approval)
app/ → CLI 框架(CommandRouter / CliApp)
utils/ → 工具函数(format / path / signal / pangu)
index.ts → 公共 API 统一导出快速开始
import { CliApp, BottomDock, c, fmtMs } from '@guangnao/agent-ui';
const app = new CliApp({ name: 'mycli', version: '1.0.0', description: '我的命令行工具' });
app.command({
name: 'chat',
description: '启动对话',
handler: async () => {
const dock = new BottomDock({
onSubmit: (line) => { /* 处理用户输入 */ },
onInterrupt: () => process.exit(0),
});
dock.enable();
dock.setStatusLine(c.meta('就绪'));
},
});
await app.run(process.argv.slice(2));核心模块
core/ansi.ts
所有 ANSI/VT100 转义序列的单一来源:
import { HIDE_CURSOR, SHOW_CURSOR, cursorUp, stripAnsi } from '@guangnao/agent-ui';
process.stdout.write(HIDE_CURSOR); // 隐藏光标
process.stdout.write(cursorUp(3)); // 向上移动 3 行
const plain = stripAnsi('\x1b[31mred\x1b[0m'); // → 'red'core/chars.ts
CJK + ANSI 感知的字符宽度计算:
import { visualWidth, truncatePure, GLYPH } from '@guangnao/agent-ui';
visualWidth('hello') // → 5
visualWidth('你好') // → 4
visualWidth('hi你好') // → 6
GLYPH.done // '✓'(narrow-safe)
GLYPH.spinFrames // ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']paint/color.ts
主题色板系统:
import { c, buildPalette } from '@guangnao/agent-ui';
// 使用默认调色板(根据终端主题自动切换暗/亮)
c.done('✓ 完成') // 绿色
c.fail('✗ 失败') // 红色
c.warning('! 警告') // 黄色
c.brand('GCLI') // 品牌色 #D97757
c.meta('辅助信息') // 灰色TUI 引擎:BottomDock
BottomDock 是框架的核心,对标 Claude Code 的 InkApp:
┌─────────────────────────────────────┐
│ 【永久历史区】 │
│ commit() 写入的内容落入 scrollback │
│ 永远不再变动 │
├─────────────────────────────────────┤
│ 【活动区 - 差分渲染】 │
│ ▸ 正在分析代码... (3s) │ ← activity()
│ ✓ 读取文件 │ ← tasks()
│ ⎿ 写入文件 │
│ function hello() { │ ← streamTail()
├─────────────────────────────────────┤
│ > 输入消息_ │ ← 输入框
├─────────────────────────────────────┤
│ 就绪 · token: 1.2K │ ← statusLine
└─────────────────────────────────────┘import { BottomDock } from '@guangnao/agent-ui';
const dock = new BottomDock({
onSubmit: (line) => console.log('用户输入:', line),
onInterrupt: () => { dock.disable(); process.exit(0); },
commands: () => [
{ name: 'help', desc: '显示帮助' },
{ name: 'clear', desc: '清屏' },
],
});
dock.enable();
// 输出永久内容(落入 scrollback)
dock.commit('欢迎使用 GCLI\n');
// 设置 spinner 文案
dock.activity('正在调用 AI...');
// 更新任务清单
dock.tasks([' ✓ 读取代码', ' ▸ 分析结构...']);
// 流式旁白(逐字)
dock.streamTail('正在生成:function hello');
// 冻结任务(打印到历史区)
dock.freezeTasks();
// HITL 审批
const verdict = await dock.requestApproval(
'执行 shell 命令',
'rm -rf ./dist',
[
{ label: '允许', value: 'approve' },
{ label: '拒绝', value: 'reject', danger: true },
],
);响应式全屏视图:LiveView
让任意「纯布局」天生随窗口自适应。你只写视图函数 (ctx) => string,LiveView 负责接管 alt-screen、监听 resize(防抖)、用最新 {width,height} 重排并行级差分重绘(无闪烁):
import { LiveView, rule, buildStatusLine, renderTable } from '@guangnao/agent-ui';
const view = new LiveView(
({ width, height }) =>
rule({ title: '仪表盘 dashboard', width }) + '\n' +
renderTable(rows, { columns, maxWidth: width }) + '\n' +
buildStatusLine({ label: 'svc', tokens, cost, width }),
{ onKey: (k) => { if (k.name === 'ctrl-c' || k.char === 'q') view.stop(); } },
);
view.start(); // 接管终端、首帧渲染、挂 resize
setInterval(() => view.update(), 1000); // 状态变了主动重渲染(差分,仅改动行)- 窗口缩放 → 自动用新宽高重排(横线、表格、状态栏全部跟随),零残影。
update()→ 行级差分,只改变化行(一个时钟/进度更新仅重写 1 行,不闪不抖)。- 非 TTY(管道/CI)→ 自动降级为一次性渲染,不进 alt-screen、不挂监听。
- 与
BottomDock(内联活动区)互补:LiveView 管「整屏即视图」的仪表盘/监视/watch 场景。
这是「布局系统 resize 自适应」的运行时答案:组件保持纯函数,自适应由运行时重新调用 + 差分实现。
可组合布局:Node 组合层
layout/widgets 全是 (width) => string 的纯函数——对,但只能纵向拼接(a + '\n' + b),缺水平并排与尺寸模型。Node 在叶子与运行时之间补一层极薄的盒模型,让布局可以像 flexbox 一样组合:
import {
vstack, hstack, sized, boxNode, text, fromString, fixed, flex, spacer,
LiveView, renderTree, renderTable, buildStatusLine,
} from '@guangnao/agent-ui';
// 页眉(fixed 1) / 侧栏 + 主区(hstack, flex 1:2) / 页脚(fixed 1) —— 整树即 f(width,height)
const dashboard = ({ width, height }) => vstack([
sized(text('⏺ 仪表盘 dashboard'), fixed(1)),
sized(hstack([
sized(boxNode(fromString(w => renderTree(tree, { width: w })), { title: '进程' }), flex(1)),
sized(boxNode(fromString(w => renderTable(rows, { maxWidth: w })), { title: '指标' }), flex(2)),
], { gap: 1 }), flex(1)),
sized(fromString(w => buildStatusLine({ label: 'svc', tokens: 1234, width: w })), fixed(1)),
]);
new LiveView(dashboard).start(); // LiveView 直接接受返回 Node 的视图函数,render(width,height) → 差分6 个原语:
| 原语 | 作用 |
|------|------|
| text(s, {align, wrap}) | 文本叶子(CJK 折行/对齐) |
| vstack(children, {gap}) | 纵向叠 + 高度分配(主轴=高) |
| hstack(children, {gap}) | 并排 + 宽度分配(主轴=宽,CJK 列对齐) |
| boxNode(node, {title,style}) | 给节点套边框 |
| sized(node, fixed(n) \| flex(w) \| auto()) | 主轴尺寸策略(供容器分配剩余) |
| fromString(w => render(...)) | 适配器:现有 widget 零改造入树 |
外加 spacer(weight)(弹性空白,把兄弟挤到两端)。
契约:node.render(width, height) 返回恰好 height 行、每行恰好 width 视觉列——这是 hstack 列对齐与差分定位成立的前提,全程按视觉宽(CJK 双宽)计算。
两个运行时都已接入:
LiveView的视图函数可返回string(既有)或Node(按{width,height}渲染、随窗口自适应)。BottomDock.setPanel(node)把 Node 面板驻活动区最上方、随终端宽自适应(内容高度驱动)。
这层纯叠加:
render()吐的还是string[],正是差分引擎已在吃的东西——不动_draw/_render的差分/擦除/resize。
agent-ui-test dashboard(或 dash)可直接看一个随窗口实时重排的仪表盘 demo。
交互式 Prompt
import { textPrompt, selectPrompt, confirmPrompt, passwordPrompt, PromptCancelledError } from '@guangnao/agent-ui';
// 文本输入
const name = await textPrompt('请输入你的名字', {
default: 'Alice',
validate: (v) => v.length >= 2 || '至少 2 个字符',
});
// 单选
const env = await selectPrompt('选择环境', {
options: [
{ label: '开发', value: 'dev' },
{ label: '生产', value: 'prod', hint: '⚠ 谨慎' },
],
});
// 确认
const ok = await confirmPrompt('确定要删除吗?');
// 密码
const pwd = await passwordPrompt('请输入密码');Ctrl+C 错误处理
所有 prompt 在用户按下 Ctrl+C 时均抛出 PromptCancelledError,而非直接调用 process.exit(),由调用方决定后续行为:
import { textPrompt, PromptCancelledError } from '@guangnao/agent-ui';
try {
const input = await textPrompt('请输入内容');
} catch (e) {
if (e instanceof PromptCancelledError) {
process.stdout.write('\n已取消\n');
process.exit(0); // 应用层决定退出
}
throw e;
}Esc 的语义不同:表示「跳过/不选」,返回 null 而不是抛出异常。
颜色主题与品牌定制
BottomDock 支持注入自定义颜色主题(DockTheme),用于第三方项目覆盖默认品牌色:
import { BottomDock, buildPalette } from '@guangnao/agent-ui';
const myPalette = buildPalette('dark'); // 或 'light'
const dock = new BottomDock({
onSubmit: (line) => { /* ... */ },
onInterrupt: () => process.exit(0),
// 只覆盖需要修改的颜色 key,其余保持默认
theme: {
user: (s) => `\x1b[36m${s}\x1b[0m`, // 青色提示符
accent: (s) => `\x1b[35m${s}\x1b[0m`, // 紫色高亮
},
});DockTheme 是 Partial<Palette>,未提供的字段自动回退到默认调色板。
非 TTY / 管道 / CI 兼容
所有交互组件均支持自动降级,在管道或 CI 环境中仍可正常工作:
| 场景 | 行为 |
|------|------|
| selectPrompt / multiSelectPrompt | 打印编号列表,读取数字输入 |
| passwordPrompt | readline 普通输入(无法掩码,提示用户) |
| confirmPrompt | 同 selectPrompt 降级 |
| standaloneApproval | 检测到非 TTY 自动允许(CI 场景)|
| DirectSink spinner | 自动检测 stderr.isTTY,管道时静默 |
import { isInteractive, isTTY, isCI } from '@guangnao/agent-ui';
// isInteractive() — stdin 是否为 TTY(决定是否能 raw mode 输入)
// isTTY() — stdout 是否为 TTY(决定是否渲染颜色/TUI)
// isCI() — 是否在 CI 环境中
if (!isInteractive()) {
// 脚本模式:跳过交互确认
}盘古之白
自动在 CJK 字符与英文/数字间插入空格,改善排版可读性:
import { pangu } from '@guangnao/agent-ui';
pangu('这是一段含有English和123数字的中文');
// → '这是一段含有 English 和 123 数字的中文'
pangu('使用`code`块不受影响'); // 内联代码不处理
// → '使用 `code` 块不受影响'CLI 框架
import { CliApp, CommandRouter } from '@guangnao/agent-ui';
const app = new CliApp({
name: 'mytool',
version: '2.0.0',
description: '命令行工具示例',
});
app
.command({
name: 'deploy',
aliases: ['d'],
description: '部署应用',
argDefs: [{ name: 'env', description: '目标环境', required: true }],
optDefs: [
{ name: 'dry-run', alias: 'n', type: 'boolean', description: '仅预演' },
],
handler: async ({ args, opts }) => {
if (opts['dry-run']) console.log('[dry-run]');
console.log(`部署到 ${args.env}`);
},
})
.command({
name: 'logs',
description: '查看日志',
handler: async () => { /* ... */ },
});
await app.run(process.argv.slice(2));工具函数
import { fmtMs, fmtK, fmtCost, fmtBytes, fmtRelative, formatError } from '@guangnao/agent-ui';
fmtMs(1234) // '1.2s'
fmtMs(65000) // '1m5s'
fmtK(1_234_567) // '1.23M'
fmtCost(0.00123) // '$0.001'
fmtBytes(1536) // '1.5KB'
fmtRelative(new Date(Date.now() - 3600_000)) // '1小时前'
formatError(new Error('oops')) // 'oops'import { shortenPath, truncatePath } from '@guangnao/agent-ui';
shortenPath('/Users/alice/projects/myapp/src/index.ts') // 'src/index.ts'(相对 cwd)
truncatePath('/very/long/path/to/some/file.txt', 20) // '/very/.../file.txt'import { onCleanup, gracefulShutdown } from '@guangnao/agent-ui';
// 注册 cleanup(SIGINT/SIGTERM 时自动调用,LIFO 顺序)
const remove = onCleanup(() => {
dock.disable();
process.stdout.write('\n已清理\n');
});
// 手动触发所有 cleanup 并退出
await gracefulShutdown(0);
// 取消注册(不再需要时)
remove();注意:
onCleanup只注册 SIGINT/SIGTERM,不会劫持uncaughtException。
如需处理未捕获异常,请在应用层自行注册process.on('uncaughtException', ...)。
渲染组件
语法高亮
import { highlightBlock } from '@guangnao/agent-ui';
const highlighted = highlightBlock(`
function hello(name: string) {
console.log(\`Hello, \${name}\`);
}
`, 'ts');
process.stdout.write(highlighted);支持语言:ts/tsx/js/jsx、py、sh/bash/zsh、go、rust、json、yaml/toml
Markdown 渲染
import { renderMarkdown } from '@guangnao/agent-ui';
const md = renderMarkdown(`
# 标题
这是一段 **粗体** 和 _斜体_ 文本。
\`\`\`ts
const x = 1;
\`\`\`
- 列表项 1
- 列表项 2
`);
process.stdout.write(md + '\n'); // renderMarkdown 返回渲染好的多行字符串表格
import { renderTable } from '@guangnao/agent-ui';
const output = renderTable(
[
{ name: 'Alice', score: '98', grade: 'A' },
{ name: 'Bob', score: '85', grade: 'B' },
],
{
columns: [
{ key: 'name', header: '姓名', align: 'left' },
{ key: 'score', header: '分数', align: 'right', width: 6 },
{ key: 'grade', header: '等级', align: 'center' },
],
},
);
process.stdout.write(output);树形结构
import { renderTree, pathsToTree } from '@guangnao/agent-ui';
const root = pathsToTree(['src/index.ts', 'src/core/ansi.ts', 'src/paint/color.ts']);
process.stdout.write(renderTree(root));
// src/
// ├ index.ts
// └ core/
// └ ansi.ts任务清单(可嵌套状态)
import { renderTaskList } from '@guangnao/agent-ui';
renderTaskList([
{ label: '读取代码', status: 'done' },
{ label: '重构', status: 'running', children: [{ label: '抽函数', status: 'pending' }] },
], { spinnerFrame: '⠙' });
// ✓ 读取代码
// ⠙ 重构
// ◌ 抽函数状态:done(✓绿) / running(▸浅珊瑚,可传 spinnerFrame 转圈) / pending(◌灰) / failed(✗红)。countTasks(items) 统计 {done,total}(含嵌套)。
标注块 Alert
import { alert } from '@guangnao/agent-ui';
alert('磁盘空间不足,请清理。', 'warning', { title: '警告' });
// ! 警告
// │ 磁盘空间不足,请清理。变体:info / success / warning / error(语义色图标 + 竖条引导多行正文)。
条形图 / 趋势线
import { barChart, sparkline } from '@guangnao/agent-ui';
barChart([{ label: '请求', value: 120 }, { label: 'errors', value: 18, color: c.fail }], { width: 20 });
// 请求 ████████████████████ 120
// errors ███░░░░░░░░░░░░░░░░░ 18
sparkline([3, 5, 2, 8, 6, 9, 4]); // → ▂▄▁▆▅█▃(着色),紧凑趋势状态栏 StatusBar
纯组合器:把 标题 · 上下文进度 · token · 花费 拼成一行,交给 dock.setStatusLine()。
import { buildStatusLine } from '@guangnao/agent-ui';
dock.setStatusLine(buildStatusLine({
label: '光脑AI gmodel-plus',
context: { used: 60000, total: 128000 }, // 进度条 + 百分比,≥90% 红 / ≥70% 黄
tokens: 60000,
cost: 0.1037,
}));
// 光脑AI gmodel-plus · 上下文 ▰▰▰▰▰▱▱▱▱▱ 60.0K/128.0K 47% · 60.0K token · $0.104各段全可选;按终端宽度(视觉宽)自动截断,绝不溢出折行。
测试
pnpm test # 运行所有测试(vitest)
pnpm typecheck # TypeScript 类型检查
pnpm build # 编译到 dist/设计原则
CJK 感知 — 所有可视宽度计算使用
string-width,绝不用.lengthEast-Asian Ambiguous 安全 — 只使用 narrow-safe 字形(来自
GLYPH常量)写合并缓冲 — 所有 ANSI 攒进
_wbuf,每帧一次write()syscall行级差分渲染 — 每帧逐行比对上一帧,只改写变化行(spinner/打字通常仅 1 行 → ~N× 字节削减);同帧静默跳过、空闲零开销;结构变化退回全量擦除重绘;footer 按终端高度钳制,绝不溢出
NO_COLOR 合规 — 检测
NO_COLOR环境变量,自动降级纯函数优先 — 行编辑/历史/格式化均为纯函数,便于测试
嵌入安全 — Ctrl+C 抛错误,不调
process.exit();不注册uncaughtException管道/CI 兼容 — 所有 prompt 有 non-TTY 降级路径,无运行时崩溃
纯布局 + 响应式运行时分离 — layout/widgets 全是按
width参数化的纯函数(可测、可组合); resize 自适应不靠「每个组件自己监听」,而由运行时(LiveView全屏 /BottomDock内联)在窗口变化时 用新尺寸重新调用视图并差分重绘(对标 Ink 的「UI = f(state)」)。布局系统天生 resize 自适应,不再是硬伤。两套 resize 模型 — 主缓冲区 footer 重排后「在哪」不可自知,必须问终端(改 dock 前必读):
| |
BottomDock(内联活动区) |LiveView(全屏视图) | |---|---|---| | 帧定位 | 相对:footer 浮在正文下方、随对话下沉,无固定绝对行号 | 绝对:\x1b[r;cH每行独立寻址(整屏即视图,行号固定) | | resize 重锚 | DSR 实测光标行号(\x1b[6n→CPR)定位 footer 顶 → 一刀清到屏底 → 重画 |\x1b[2J全屏清 + 整画(无状态,不读上一帧) |根本难点:
BottomDock的 footer 活在主缓冲区、与 scrollback 交织。终端重排后,footer 被挪到屏幕 哪一行,程序无法自知——「footer 恒在屏底」只在 scrollback 锚底的终端成立,变大 / 部分终端会破, 旧 footer 原地留存 = 「窗口变小/变大后莫名多组件」。纯客户端的几何猜测做不到鲁棒。解法分两层:
- 抢画竞态(快拖动):resize 事件一到即冻结绘制(
_resizing),挡住防抖窗口内的动画 tick /dock.tasks()抢画污染擦除基准;所有写 scrollback 的路径(commit/freezeTasks/disable)动手前先_flushResize()。 - 重排定位(慢拖动):防抖到点不再猜,而是发 DSR
\x1b[6n问终端要光标真实行号,CPR 经 stdin 回来 (在_onData截获)→footerTop = 实测行 − 上方各行按新宽回卷高度→ 绝对跳顶清底重画。带超时回退到 几何估算(终端不支持 CPR 时不卡死)。
LiveView因「绝对定位 + 全屏清」对这两类问题天然免疫——它根本不读上一帧来擦除,抢画即便污染_last也会被2J抹净,无需冻结、无需 DSR。一句话给后来者:主缓冲区的相对 footer,重排后的真实位置只有终端知道——别猜,用 DSR 问; 异步 resize/动画期间必须冻结绘制。无状态的绝对全清(alt-screen)则天然安全。
⚠️ DSR 重锚的像素级正确依赖终端真实 reflow,纯字节模拟器(VTScreen)测不出—— 单测只能锁握手协议与重锚算术,最终画面须真机逐步拖动验证。
- 抢画竞态(快拖动):resize 事件一到即冻结绘制(
许可
MIT
