npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/

设计原则

  1. CJK 感知 — 所有可视宽度计算使用 string-width,绝不用 .length

  2. East-Asian Ambiguous 安全 — 只使用 narrow-safe 字形(来自 GLYPH 常量)

  3. 写合并缓冲 — 所有 ANSI 攒进 _wbuf,每帧一次 write() syscall

  4. 行级差分渲染 — 每帧逐行比对上一帧,只改写变化行(spinner/打字通常仅 1 行 → ~N× 字节削减);同帧静默跳过、空闲零开销;结构变化退回全量擦除重绘;footer 按终端高度钳制,绝不溢出

  5. NO_COLOR 合规 — 检测 NO_COLOR 环境变量,自动降级

  6. 纯函数优先 — 行编辑/历史/格式化均为纯函数,便于测试

  7. 嵌入安全 — Ctrl+C 抛错误,不调 process.exit();不注册 uncaughtException

  8. 管道/CI 兼容 — 所有 prompt 有 non-TTY 降级路径,无运行时崩溃

  9. 纯布局 + 响应式运行时分离 — layout/widgets 全是按 width 参数化的纯函数(可测、可组合); resize 自适应不靠「每个组件自己监听」,而由运行时(LiveView 全屏 / BottomDock 内联)在窗口变化时 用新尺寸重新调用视图并差分重绘(对标 Ink 的「UI = f(state)」)。布局系统天生 resize 自适应,不再是硬伤。

  10. 两套 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)测不出—— 单测只能锁握手协议与重锚算术,最终画面须真机逐步拖动验证。


许可

MIT