Skip to content

Latest commit

 

History

History
310 lines (277 loc) · 23.7 KB

File metadata and controls

310 lines (277 loc) · 23.7 KB

AGENTS.md

本文件面向 AI 编码代理,假设读者对本项目一无所知。

项目概述

pkg 是从原 Textual/Python 项目(old/,见下)迁移而来的终端 TUI 工具, 用于统一管理本机已安装的各种包管理器。迁移目标框架为 OpenTUI(Zig 原生 渲染核心 + TypeScript/React 绑定),运行于 Bun

当前为核心版:已迁移主界面(已安装列表 + 批量更新/卸载 + 本地过滤 + 仅显示 可更新)、全局搜索、包详情、确认对话框、设置界面(快捷键/图标/语言,写回 ~/.config/pkg-tui/config.json),以及全部 8 个后端(npm/pnpm/bun/winget/scoop/cargo/choco/uv)、 i18n(中/英)、配置持久化、快捷键配置。

主要功能:

  • 列出各包管理器已安装的全局包("全部"视图聚合所有可用管理器,每行标注来源)
  • 查看可更新的包、批量勾选更新、卸载(带确认框 + 命令预览)
  • 全局搜索包(同 registry 的管理器只搜一次)、查看包详情、安装包
  • 命令输出查看(o):安装/更新/卸载的实时执行日志,运行中自动跟随底部
  • 底栏右侧任务状态:执行中显示转圈 + "{n}个任务",全部成功显示 ✓(3 秒隐藏), 有失败显示 ✗ 常驻到下一批开始(执行结果不再弹右下角 toast,失败明细按 o 查看)
  • 本地过滤框按包名实时过滤当前列表

界面语言默认随系统 locale(zh_CN/en_US),可经配置文件切换。 目标运行平台为桌面终端(开发环境为 Windows)。

技术栈

  • 语言/运行时:TypeScript + Bun(bun run src/index.tsx
  • UI 框架@opentui/core + @opentui/react(React 19)。通过 createCliRenderer 创建原生渲染器,createRoot(renderer).render(<App/>) 挂载。OpenTUI 渲染器需 native FFI, Bun 直接支持;Node.js 需 26.4.0 + --experimental-ffi
  • 构建:无打包步骤,Bun 直接解释运行 TSX;tsconfig.json 仅做类型检查(noEmit)。
  • 类型strict,但特意关闭 noUncheckedIndexedAccessnoImplicitOverride (原 Python 不带这类检查,迁移期保持等价行为优先;CLI 表格解析大量按索引访问字符, 开启会徒增噪音)。

常用命令

# 安装依赖
bun install

# 运行(带 watch)
bun dev
# 或直接
bun run src/index.tsx

# 测试(bun:test,tests/*.test.tsx)
bun test

# 类型检查
bunx tsc --noEmit

# Lint(Biome,~25ms 全量)
bun run lint          # 仅检查,不改代码
bun run check         # 检查 + 安全自动修
bun run format        # 格式化全部源码(--write)

# 只跑不依赖渲染器的纯逻辑测试(快,~40ms)
bun test tests/date.test.ts tests/search-groups.test.ts

入口:src/index.tsx

关于 Biome

biome.jsonrecommended 规则集,但迁移期下调了一批噪声规则为 warn(不阻塞 lint): noExplicitAny(CLI JSON 解析大量 as any,迁移期保留)、useExhaustiveDependencies (OpenTUI useEffect 多为渲染器副作用,自动补依赖有风险,勿自动改)、noArrayIndexKey (命令/列等非 ID 数据刻意用下标 key)、noControlCharactersInRegex(winget/scoop 切列依赖 ANSI 控制符正则,业务必需)、noAssignInExpressionsobj[k] ??= [] 惯用法)、 以及 a11y/*(终端 UI 非_web,不适用)。noUnusedImports 保持 error 级守门。 改 biome.json 时注意勿把这些升级回 error——否则 bun run lint 会被迁移期代码阻塞。

架构

src/
├── index.tsx              # 入口:createCliRenderer + createRoot(<App/>)
├── App.tsx                # 主应用:ManagerRegistry 运行时状态、useKeyboard 分发、
│                          #   overlay 栈(search/detail/confirm)、toast、表格/顶栏/底栏
├── runtime.ts             # 领域逻辑层(不依赖渲染):ManagerRegistry、buildInstalledRows、
│                          #   buildSearchGroups(registry 去重)、previewCommands、doUpdateAll/doUninstallAll
├── ops.ts                 # 命令执行日志(OpLog 单例):_cli.runCommand({log:true}) 逐行写入
│                          #   stdout/stderr,OutputScreen 订阅实时查看(无渲染依赖,可独立测试)
├── focus.ts               # isTextInputFocused(renderer):判断渲染器焦点是否在文本输入框
│                          #   (本地 state 不可信,见运行时要点)
├── i18n.ts                # t() 翻译 + 语言检测/切换
├── config.ts              # ~/.config/pkg-tui/config.json 持久化、快捷键/图标/语言 getter
├── date.ts                # formatRelativeTime 相对时间(i18n,zh/en;解析失败原样返回)
├── width.ts               # dispWidthStr 显示宽度(CJK 全角/emoji 计 2 列)
├── terminal-colors.ts     # getTerminalBackground 终端默认背景色(跟随主页背景);getTerminalBackgroundSync 同步读缓存
├── terminal-progress.ts   # Windows Terminal OSC 9;4 标签页/任务栏转圈(引用计数;非 TTY 不写、try/catch 兜底)
├── locales/{zh_CN,en_US}.json
├── managers/
│   ├── types.ts           # PackageInfo / SearchResult / PackageDetail / OperationResult
│   ├── base.ts            # PackageManager 抽象类 + registerManager/listManagers 注册表
│   ├── _cli.ts            # runCommand(Bun.spawn 参数数组,无 shell)、parseJson、isAvailable
│   ├── npm.ts             # npm + _parseSearchResults/_parsePackageDetail/_makeResult(共享)
│   ├── pnpm.ts            # 复用 npm 解析
│   ├── bun.ts             # 纯文本正则解析;outdated 逐个 view;search/view 复用 npm
│   ├── winget.ts          # 表格按显示宽度切列(中文全角)+ show 键值解析
│   ├── scoop.ts           # ASCII 表头切列 + cat manifest JSON
│   ├── cargo.ts           # install --list 正则解析;outdated 逐个 info;search/info 显式走 crates.io(镜像源无搜索 API)
│   ├── choco.ts           # --limit-output 管道分隔解析;info 键值解析(0 packages found 判失败)
│   ├── uv.ts              # tool list 正则;outdated 逐个 dry-run 解析(结果在 stderr);search/view 不支持(uv 无此子命令)
│   └── index.ts           # import 各后端触发注册 + 统一导出
├── components/
│   ├── PackageTable.tsx   # 自建受控表格(box+text,光标行高亮、鼠标悬浮高亮、勾选前缀、滚动窗口、columnGap 列间隔、横向滚动 scrollX)
│   ├── LoadingIndicator.tsx # 全局加载指示器(单方向扫描 + 色衰减动画,setInterval 推帧)
│   ├── ModalBackdrop.tsx  # 模态背景容器(overlay 实底盖住主页;ConfirmDialog/
│   │                      #   DetailScreen/SettingsScreen 共用,终端背景色见 terminal-colors)
│   ├── ManagerStrip.tsx   # 顶栏:设置/搜索按钮 + 过滤输入 + "全部"+各管理器按钮
│   └── TaskStatus.tsx     # 底栏右侧任务状态(订阅 opLog:运行中转圈+计数、✓ 3 秒隐藏、✗ 常驻)
└── screens/
    ├── ConfirmDialog.tsx  # 确认框(命令预览 + 确定/取消;options 多按钮模式:合并
    │                      #   registry 安装时每个可用管理器一个按钮,预览随聚焦切换)
    ├── SearchScreen.tsx   # 搜索(registry 分组并发,i 安装 / v 详情;失败来源在状态栏标注;
    │                      #   范围条按 registry 分组显示(pnpm/bun 并入 npm);active=false
    │                      #   时被上层 overlay 压住,不响应按键、输入框不聚焦)
    ├── DetailScreen.tsx   # 包详情(后台 view,加载态;已安装视图:更新(默认聚焦)/删除/安装版本,
    │                      #   搜索视图:安装(默认聚焦,装最新版)/安装版本/关闭;active 门控同 SearchScreen)
    ├── OutputScreen.tsx   # 命令输出(安装/更新/卸载执行日志:左条目列表 + 右 sticky 输出区,
    │                      #   运行中实时追加并跟随底部,↑↓ 切条目,PgUp/PgDn/Home/End 滚动;
    │                      #   p 终止运行中条目,r 重试/a 管理员重试 failed 条目,底栏上下文提示)
    └── SettingsScreen.tsx # 设置(快捷键/图标/语言,保存回 config.json;自建列表交互
                           #   同 PackageTable,见设置界面鼠标行为段落与 settings-screen-mouse 测试)

与原项目(old/)的对应

原 Python 现 TS
models.py managers/types.ts
managers/base.py managers/base.ts
managers/_cli.py managers/_cli.ts(Bun.spawn)
managers/{npm,pnpm,bun,winget,scoop}.py 同名 .ts
i18n.py + locales/ i18n.ts + locales/
config.py config.ts
app.py(主界面+编排) App.tsx + runtime.ts
screens/*_screen.py screens/* + components/
原设置 screen(若有) screens/SettingsScreen.tsx
—(迁移时新增) focus.tscomponents/ModalBackdrop.tsx(OpenTUI 无 Textual 模态/焦点原语,迁移时补)
Textual DataTable 自建 PackageTable
Textual @work worker 普通 async + setState/rerender
push_screen/dismiss overlay 栈(多层叠放,仅顶层接收按键)

运行时要点

  • 解耦App.tsx 只依赖 PackageManager 抽象与注册表,不 import 具体后端; 新增管理器只需实现接口 + registerManager + 在 managers/index.ts import。
  • 领域逻辑在 runtime.ts,不碰 React/渲染,可独立测试与复用。
  • OpenTUI 渲染特性约束
    • <text> 不支持 backgroundColor(用 bg),不支持 ellipsis;超宽用 truncate
      • width(布局宽度)裁切。
    • <box> 单边框用 border={["bottom"]} 数组,无 borderBottom prop。
    • <text> 内可用 <span>/<b>/<strong>/<em>/<br> 富文本;t\...`模板字面量与fg(color)(text)bold(text)均从@opentui/core` 导入。
    • <input> props:value/placeholder/focused/onInput/onChange/onSubmitonSubmit 因与 React DOM SubmitEvent 同名有类型冲突,赋值时用 as any 绕过,运行时是 (value:string)=>void)。
    • 键盘统一用 useKeyboard(key => ...)keyname/ctrl/shift/meta,有 preventDefault()/stopPropagation()name 归一:Enter→"return",Esc→"escape"
    • 焦点是渲染器的状态,不是组件的 state:鼠标点击输入框会直接改 renderer.currentFocusedRenderable,而 filterMode/focusOnTable 这类本地 state 不会跟着变。全局 useKeyboard 若只信本地 state 判断"是否在打字",字符键 会既进输入框又被当快捷键执行(曾导致过滤框里输入 opencoded 触发卸载)。 因此:判断一律用 src/focus.tsisTextInputFocused(renderer);输入框加 onMouseDown 把 state 同步回来;退出输入模式要拿 ref 显式 blur() (鼠标聚焦时 focused prop 本就是 false,React diff 不出变化)。 回归测试见 tests/focus-keys.test.tsxbun test 运行全部)。
    • 顶栏 ← → 导航到过滤框时 input 连带聚焦focused={filterMode || filterFocused}, 用于显示光标,与原 Python 项目一致):此时 filterMode=false 但渲染器焦点在 input, 全局 useKeyboard 的顶栏分支必须先于 isTextInputFocused 判断执行——OpenTUI 的全局 keyHandler(renderer.keyInput,React useKeyboard 绑定处)先于聚焦的 renderable 处理按键,对 ← → /↓ /Esc/Enter preventDefault() 后 input 不会吞掉 导航键;未处理的字符键则落进 input 直接输入过滤框。
    • PackageTable 用法须全局一致:所有用到 PackageTable 的界面(主页已安装表格、 搜索结果表格等)都必须保持相同的交互行为——鼠标滚轮上下移动光标行 (onScrollMove 回写 cursor)、单击选中行、双击触发 onRowDoubleClick、 列宽 autoFitWidths + columnGap、横向溢出时 scrollX。新增界面用 PackageTable 时直接复用这套回调,不要省略 onScrollMove(否则滚轮在表格上无反应,与主页割裂)。 这条是"整体风格一致"规范,改动 PackageTable 默认行为或任一界面的回调均需同步另一处。
    • 设置界面(SettingsScreen)的鼠标行为与列表滚动窗口:设置界面的自建列表与 PackageTable 交互一致——单击行 = 选中并激活(onMouseDown + stopPropagation, 同 ConfirmDialog 按钮)、悬浮行高亮(hover state,光标 > 悬浮 > 透明)、滚轮在 行区内移动光标(onMouseScroll + VSCROLL_STEP=3)。行区盒子必须给显式 height:模态框 maxHeight="85%" 会让 Yoga 压缩内容,行区实际高度 ≠ 终端高 ×85% − 固定开销(小终端下连配置路径行都会被压扁,行内容被裁掉);显式 height={listRows}min(rows.length, max(2, floor(h*0.85) - ROWS_OVERHEAD))ROWS_OVERHEAD=9:内边距2+标题1+行区上边距1+配置路径3+底栏2)后 Yoga 不再收缩 行区,窗口与盒子严格一致。极矮终端(h≤12)下固定内容放不下,边距/配置路径被 压扁属可接受退化。测试见 tests/settings-screen-mouse.test.tsx:小终端 height=10 行区 2 行,滚动到底窗口停在 (o)/完成((a) 放不下),点击"完成"关闭回传结果。
    • 鼠标事件测试的 stdin 解析器时序:OpenTUI 的 StdinParser 是异步的且对连发 事件(mockMouse 背靠背 emit)会丢失(解析器等待态 + 20ms 超时窗口依赖时钟), mockMouse.scroll/click 后不能立即断言——必须多轮 tick() + renderOnce()(约 10 轮)消化,事件逐条处理;测试里每次事件后都要 pump,否则偶发丢事件导致断言失败 (曾表现为"滚动一次后窗口不动"的假故障)。
    • 加载状态统一用 LoadingIndicator:所有需要显示"加载中"的地方都应使用 src/components/LoadingIndicator.tsx(单方向扫描 + 尾部色衰减动画),不要再写 静态 <text>加载中...</text>。已接入:主页 loadingHint 空表占位、详情屏 state.status==="loading"、搜索屏底栏右侧状态(搜索中)与 PackageTable.emptyHint(加载态)。 动画靠 setInterval 推帧 + React state 重绘,卸载清计时器;PackageTable.emptyHint 已放宽为 ReactNode 以容纳它。新增加载场景一律复用此组件,保证全局风格一致。
    • scrollX 模式的空白行修复:OpenTUI ScrollBox 在内容未横向溢出时,横向滚动条 本应隐藏,但首布局仍为它预留 1 行(visible 切到 false 后布局未刷新),导致表格 底部多一行空白。PackageTableuseEffect 在内容 onSizeChange 时调 horizontalScrollBar.resetVisibilityControl() 重算可见性并重排,回收该预留行。 故 scrollX 表格的 visibleRows 按"无预留"算(搜索界面用 height - 2 输入行1+底栏1,主页 无 scrollXheight - 4 顶栏1+底栏1+表头1+paddingTop1)。
    • 不画竖直滚动条:PackageTable 的纵向滚动是光标驱动的窗口(windowStart), 不是 ScrollBox 平移,故不复用 ScrollBox 原生 scrollY,也没有自绘滚动条—— 长列表靠滚轮快速滚动(每档 VSCROLL_STEP=3 行,经 onScrollMove)与键盘。 主页和搜索界面共用此机制。
    • 搜索"全部"并发 + 失败可见SearchScreen.doSearchPromise.allSettled 并发 各 registry 代表(buildSearchGroups:同 registry 只搜一次,npm 系用 npm;不同 registry 如 scoop/winget 各自独立)。任一来源 search 抛错不能静默吞掉——要在 底栏右侧状态用 search.status_partial_failed(部分来源搜索失败:{names}))标注, 否则用户会误以为"全部"没搜某个管理器(曾误判 scoop 未被搜索)。
    • overlay 是栈不是单层:搜索页打开详情/确认框时压栈(下层保持挂载、搜索 状态不丢),关闭上层回到下层——下载页装包后不再被踢回主页。只有顶层接收 按键:SearchScreen/DetailScreenactive prop 为 false 时 useKeyboard 直接返回,且搜索输入框 focused 连带 false(否则字符键会穿过上层 overlay 落进搜索框)。确认框 onConfirm 用 popOverlay(2) 连详情一起关,onCancel 用 popOverlay() 回到详情。
    • 合并 registry 安装在确认框选管理器:同 registry(npm/pnpm/bun)搜索只搜 代表(npm)、结果合并。安装入口(搜索 i、详情"安装"/"安装版本")统一走 confirmSearchInstall:确认框 options 多按钮模式,每个可用管理器一个按钮 (代表排最前),命令预览随聚焦按钮切换;已安装视图的更新/卸载/安装版本 管理器固定,用经典确定/取消。确认后留在搜索页(后台执行)。
    • overlay 背景跟随终端SearchScreen 等 overlay 用 terminal-colors.tsgetTerminalBackground 取终端默认背景色(主页根容器透明,overlay 必须实底盖住)。 为避免"先渲染一帧 FALLBACK 深色再切到真实背景"的闪烁,App 启动时即调 getTerminalBackground(renderer) 预填模块级缓存,overlay 挂载时用 getTerminalBackgroundSync() 同步读缓存初始化。新增 overlay 时同样走这套。
    • 命令输出日志(ops.ts:安装/更新/卸载的执行日志由 _cli.runCommand{ log: true } 时实时写入 ops.opLog(OpLog 单例:跨 chunk 拼行、\r 进度帧折叠、 ANSI 清洗、条目/行数上限)。OutputScreen 打开时 subscribe 推送重渲染,运行中 输出实时追加;关闭即退订。新增加入操作日志的命令必须传 { log: true }, 否则用户在"命令输出"界面看不到它;纯查询类命令不要传。opLog 是 mutable 单例, OutputScreen 每次渲染直接读 opLog.entries(同 ManagerRegistry 的刷新约定)。 失败条目可重试OutputScreenfailed 条目提供 r(普通重试)/ a (win32 以管理员身份重试,UAC 提权)。重试目标取条目结构化的 executable/argsrunCommandlog:true 时注入 opLog.begin(title, { executable, args })), 未注入时回退解析 title。提权由 _cli.runCommandElevated 实现:经 PowerShell Start-Process -Verb RunAs 提权运行 cmd /c "<exe> <args> > out.tmp 2> err.tmp", 把提权子进程 stdout/stderr 重定向到临时文件后读回写入同一 opLog(用户仍在"命令输出" 界面可见);UAC 被拒绝/spawn 失败则记一行失败说明(不二次弹窗)。重试经 onRetry 回调交 App 执行并 reloadManagers([executable])(executable==管理器 name)。
    • 命令执行状态不走 toast:安装/更新/卸载的结果不再弹右下角 toast(其他提示 如"没有选中的包"仍用 toast)。主页底栏右侧 components/TaskStatus.tsx 订阅同一 opLog:有运行中条目显示转圈(Braille 字符帧)+ {n}个任务;一批全部结束且全部 成功显示 ✓(3 秒隐藏);有失败显示 ✗ 常驻到下一批任务开始,失败明细按 o 查看。 失败期间底栏 o 查看输出 提示段高亮为正文色 #dddTaskStatusonOutcomeChange 回调上报终态,App 按段渲染提示;不要把 opLog 订阅提升到 App——逐行输出会触发整页重渲)。用户按 o 进入输出界面时 App 递增 clearToken,TaskStatus 清除已结算的终态(视为已知晓结果,高亮随之回落); 运行中的批次不受影响,结束后仍正常结算。
    • 终端标签页转圈(terminal-progress.ts:耗时任务期间向 stdout 写 Windows Terminal OSC 9;4"不确定进度"序列(state=3 转圈),结束写 state=0 清除。两个 驱动源:trackOpLogProgress()(opLog 有运行中条目=安装/更新/卸载)与主页 loadingHint(首页加载/刷新),共用引用计数防重叠互踩;进程 exit 兜底清除。 纯交互优化:非 TTY 不写、写入 try/catch——任何失败不得影响主流程。
  • 状态刷新ManagerRegistryuseRef 单例)内部是 mutable;数据加载后调 rerender() 强制重渲染,buildInstalledRows/buildStripItems 每次直接计算(不能用 useMemo 缓存, 否则 reg 内部变化不会反映)。

主要快捷键(主界面)

按键(默认,可在 config.json 改) 功能
s 打开搜索
o 查看命令输出(安装/更新/卸载日志)
r 刷新
u 更新选中(或当前行)
d 卸载选中(或当前行)
space 勾选/取消勾选当前行
f 仅显示可更新的包
(表格首行)/ (顶栏) 顶栏 ↔ 表格 切换焦点
(顶栏聚焦时) 顶栏按钮间移动焦点(含设置/搜索/过滤框)
enter(顶栏聚焦时) 激活顶栏按钮
(表格聚焦时) 切换当前管理器视图
enter 查看选中包详情
alt+s 打开设置
Ctrl+C 退出

搜索界面:i 安装(确认框内选管理器,确认后留在搜索页)、v 详情、Esc 返回。详情/确认:← → 切按钮、Esc 关闭(逐层返回)。

如何新增一个包管理器后端

  1. src/managers/ 新建模块,定义 PackageManager 子类,实现全部抽象方法: listInstalled / listOutdated / search / view / install / update / uninstall / updateCommand / uninstallCommand
  2. 设置实例字段 name(必填)、display_name / icon / description / registry
  3. 文件末尾调用 registerManager(YourClass)
  4. src/managers/index.ts 加一行 import "./your_module"
  5. 子进程调用复用 _cli.tsrunCommand/parseJson;npm 系解析可复用 npm.ts_parseSearchResults/_parsePackageDetail/_makeResult

UI 层(顶栏按钮、全部视图、搜索、确认)会自动识别新管理器,无需改 App/screens

安全与注意事项

  • 本工具会真实执行系统包管理器命令(npm install -g 等);所有破坏性操作(更新/卸载) 在 UI 中都有确认对话框二次确认,确认框展示将执行的完整命令——改动相关逻辑务必保留。
  • 子进程用 Bun.spawn([path, ...args])参数数组执行(无 shell 拼接),包名直接来自 CLI 输出,不要引入 shell: true
  • 无遥测、无网络请求发往 npm registry 之外的地方;registry 地址由用户本机 npm 配置决定。