# pi-speed-sparkline

[English](./README.md) | **简体中文**

**npm 包**: [`@xiaosat/pi-speed-sparkline`](https://www.npmjs.com/package/@xiaosat/pi-speed-sparkline) · **作者**: xiaosat · **License**: MIT

一个 [pi](https://pi.dev) 扩展包,在输入框下方提供**三行状态区**:第一行是 **AI 输出耗时 + 输出累计 + 🔧 工具时长分项 + 💬 对话时长**,第二行是 **回答速度折线图 + 实时速度 + 统计**,第三行是 **技能数量 + 默认上下文占用估算**。

```
 ⏱  1m23s / 🤖 12m05s  🔧 3m40s  💬 45m
 📈 ▁▂▅▇▆▃▂▁▅▇ 93.2 tok/s ↑128 ↓~61
 📚 Skills 12 (project 3)  system ~5.8k tok (2.9%)
```

## 主要功能

### 状态区布局

- **三行显示**:pi 内置 footer 会把所有 `setStatus` 项合并成同一行且过滤换行,无法分行;本扩展改用 `ctx.ui.setWidget()` 在**输入框下方、footer 上方**渲染多行状态区。
- **第 0 行**:⏱ 输出耗时 / 🤖 输出累计(`agent-timer.ts`) + 🔧 工具时长分项 + 💬 对话时长 + 行尾徽标(`speed-sparkline.ts`)。
- **第 1 行**:📈 速度折线图 + 实时/最近速度 + 最大/中位统计。
- **第 2 行**:已加载技能总数、项目级技能数,以及系统提示词整体占用估算;token 统一以 k 为单位(1k = 1,000 token)。
- **统一渲染**:三个扩展通过 `status-bus.ts` 各自注册「片段」(line / order),由总线合成多行;TUI 与 RPC 使用 widget,其他支持 UI 的模式回退为单行 `setStatus`。

### 速度折线图(`speed-sparkline.ts`)

- **速度折线图**:每完成一次回答,记录该次平均速度(tok/s),折线图随之向左滚动,最多保留最近 10 次;图表**固定占 10 格**,不足 10 个样本时左侧用 `▁`(零高度)占位。速度按 `10 / 30 / 50 / 80 / 120 / 200` 档位映射到 `▁▂▃▄▅▆▇` 字符高度(7 档:低于 10 tok/s 为 `▁`,200 tok/s 及以上为 `▇`),一图看清趋势。
- **流式实时速度**:回答生成过程中,状态区实时刷新当前速度(输出 token ÷ 经过时间),最右侧追加当次实时格与数字。
- **吐字间隙不空白**:上一次下行的 token 结束后进入工具执行或等待新一轮回复时,保留上一次吐字速度持续展示,不再出现速度空白。
- **工具时长分项**:第 0 行显示工具执行的累计时长(`🔧 3m40s`),与 ⏱ 的纯输出耗时对照,一眼看出“AI 吐字”与“工具(code)执行”谁占用更多时间;工具正在执行时，行尾以 `⚙ bash` 显示工具名(状态，与 🔧 时长统计分开),充当“正在操作工具”标志;多个工具并行只计一次,等待你回答权限/选择题时暂停不计。
- **对话时长**:本会话从创建到现在经过的时间(`💬 45m`);恢复/重载会话时仍按会话创建时间计算。
- **持久化**:工具累计时长与计时器一样写入会话记录(`speed-sparkline-tool`),`/reload`、重启恢复后继续累加;新会话清零。
- **统计信息**:空闲时附带显示最近 10 次的**最大速度**(`↑`)与**中位速度**(`↓~`),兼顾峰值与典型水平。
- **token 计数兜底**:优先使用 provider 返回的 `usage.output`;缺失时按 text / thinking / toolCall 内容字符数估算(≈4 字符/token)。
- **多模型感知**:记录每次速度对应的 `provider/model`,便于区分不同模型的速度表现。

### AI 输出计时器(`agent-timer.ts`)

- **只计 AI 真正吐字的时间**:每次计时区间为一条助手消息的 `message_start → message_end`(流式输出开始到结束),**不包括工具执行**——工具花多久都停着不走表。
- **工具后重新计时**:每次调用工具后,模型的下一条输出会**从 0 重新计时**(每条助手消息都是新一轮计数)。
- **剔除等待用户的时间**:输出途中,阻塞等待你回答的 UI 提问(权限确认、选择题、`ask_user_question` 等)不计入,此时计时冻结并显示 `⏸`;两次输出之间(工具/输入空闲)同样不计入。
- **本次 / 会话累计**:`⏱` 后左侧为当前(空闲时为最近一次)输出耗时,右侧 `🤖` 为本次会话**每一次输出耗时的求和**。
- **每秒刷新**:输出中每秒重新渲染,时长实时增长;内容无变化时不重复刷 UI。
- **跨重载/重启保留**:每次输出结算后用 `pi.appendEntry()` 把累计值写入会话记录,`/reload`、重启并恢复会话后继续累加;新会话(`/new`)自动清零。
- **中断也计入**:输出被打断、或退出/重载时输出尚未结束,已完成的那部分时间同样结算保留。

### 技能与默认上下文(`skill-status.ts`)

示例:`📚 Skills 12 (project 3)  system ~5.8k tok (2.9%)`。

- **Skills 12**:pi 实际成功加载了 12 个技能,包含只能显式调用的技能。未启用、格式错误或重名后被丢弃的资源不计入。基于 `pi.getCommands()` 提供的已加载资源信息,不扫描磁盘;关闭技能命令补全不会改变数量。
- **project 3**:其中 3 个的 `sourceInfo.scope === "project"`,包括安装在项目目录以外的项目级包。其余 9 个可能属于用户级或临时加载资源。
- 不再单独显示默认技能目录的数量与 token 占用;技能目录本身已经包含在系统提示词整体估算中。
- **system ~5.8k tok (2.9%)**:当前完整系统提示词约占 5,800 token,相当于所选模型上下文窗口的 2.9%,已包含技能目录。token 按十进制 k 单位保留一位小数(例如 2,152 token → `~2.2k tok`),百分比仍使用舍入前的估算值计算。它不是完整 API 请求或整段会话的占用:**不含工具 JSON schema、对话历史、消息及按需读取的技能正文**。会话整体占用仍看 pi 内置 footer。
- **估算而非计费值**:`~` 表示按 `ceil(字符数 / 4)` 估算,并非模型 tokenizer 精确计数;不同语言和模型会有偏差。宿主 API 不可用时显示 `?`,而不是误报为零。
- 启动/重载、每轮运行/请求前以及切换模型时刷新;不额外调用模型、不读取技能正文、不增加定时器。

### 健壮性

- 状态展示失败静默忽略(会话重载等场景不报错);速度历史存于 `globalThis` 符号键;`session_shutdown` 时注销片段并清空状态区。
- 因为 pi 的扩展加载器使用 `moduleCache: false`,同一包内的扩展各有独立模块实例,`status-bus` 的注册表因此放在 `globalThis` 的符号键上。

## 安装

```bash
pi install npm:@xiaosat/pi-speed-sparkline
```

重启 `pi` 即可在输入框下方看到三行状态区。

也可以用 `pi -e npm:@xiaosat/pi-speed-sparkline` 单次启动试用,不写入配置。

## 工作原理

### 多行状态区(status-bus)

`pi.extensions` 注册了三个扩展,它们都通过 `status-bus.ts` 提供片段:

| 扩展 | line | order | 内容 |
|---|---|---|---|
| `agent-timer` | 0 | 0 | `⏱  1m23s / 🤖 12m05s`(等待用户时追加 `⏸`) |
| `speed-sparkline` | 0 | 10 | `🔧 3m40s`(工具累计时长;执行中实时累加) |
| `speed-sparkline` | 0 | 20 | `💬 45m`(会话创建至今) |
| `speed-sparkline` | 0 | 30 | `⚙ bash` / `⚡ streaming` / `⏳ waiting`(行尾;各自仅在对应状态下显示,`⚙` 表示工具执行中) |
| `speed-sparkline` | 1 | 0 | `📈 ▁▂▅▇▆▃▂▁▅▇ 93.2 tok/s ↑128 ↓~61` |
| `skill-status` | 2 | 0 | `📚 Skills 12 (project 3)  system ~5.8k tok (2.9%)` |

总线按 `line` 分行、按 `order` 排序,同一行用两个空格连接,再通过 `ctx.ui.setWidget(key, lines, { placement: "belowEditor" })` 渲染;内容无变化时跳过渲染。

### 速度折线图事件

| 事件 | 行为 |
|---|---|
| `session_start` | 清空历史并注册片段;恢复工具累计时长(新会话清零);从会话头确定对话起始时间 |
| `before_provider_request` | 标记"等待首个 token" |
| `message_start` / `message_update` | 开始计时、实时刷新流式速度并清除工具标志 |
| `message_end` | 结算该次平均速度,推入历史并左滚折线 |
| `tool_execution_start` / `tool_execution_end` | 第 0 行行尾显示 `⚙ 工具名`(执行结束即消失),累计工具时长(并行取并集),保留上一次吐字速度 |
| `ui_prompt_start` / `ui_prompt_end` | 暂停/恢复工具时长计时 |
| `agent_settled` | 结算工具累计时长并写入会话记录 |
| `agent_end` / `session_shutdown` | 清理进行中状态;注销片段并清空状态区 |

### AI 输出计时器事件

| 事件 | 行为 |
|---|---|
| `session_start` | 从会话记录恢复累计值(新会话清零),注册片段,启动每秒刷新 |
| `message_start` | 一条新的助手消息:从 0 重新计时(工具后的下一条输出由此重新开始) |
| `ui_prompt_start` / `ui_prompt_end` | 开始/结束等待用户回答,期间暂停计时(`⏸`) |
| `message_end` | 结算本次输出耗时,并入会话累计并写入会话记录 |
| `agent_settled` | 兜底结算尚未结束的输出(异常中断),并落盘 |
| `session_shutdown` | 清理定时器;输出中被打断时结算已耗时;注销片段 |

计时语义(工具执行不计时,每次输出重新从 0 开始):

```
输出1 开始 ──► 输出1 结束 ──► 工具执行(不计时) ──► 输出2 开始(重新计时) ──► ... 
                    等待你回答的提问何时出现,都剔除不计,显示 ⏸
```

## 状态区图例

```
 ⏱  1m23s / 🤖 12m05s  🔧 3m40s  💬 45m  ← 第 0 行:输出耗时累计 + 工具时长分项 + 对话时长(行尾另有 ⚙ 工具名 / ⚡ / ⏳)
 📈 ▁▂▅▇▆▃▂▁▅▇ 93.2 tok/s ↑128 ↓~61   ← 第 1 行:折线 + 速度 + 统计
 📚 Skills 12 (project 3)  system ~5.8k tok (2.9%) ← 第 2 行:技能与默认提示词估算
 ~/proj · model · 12% ctx            ← pi 内置 footer
```

| 显示 | 含义 |
|---|---|
| `⏱  1m23s / 🤖 12m05s` | 当前(空闲时为最近一次)输出耗时 / 会话内每一次输出耗时之和(不含工具执行) |
| `⏱  1m23s ⏸ / 🤖 12m05s` | 正等待你回答,计时已暂停冻结 |
| `🔧 3m40s` | 工具(code)执行累计耗时,与 ⏱ 对照;工具执行中实时累加 |
| `⚙ bash` | 正在执行工具 bash(行尾),执行结束即消失 |
| `⚡ streaming` | 本次回答正在吐字(行尾);与 `⚙ 工具名` 各自独立显示 |
| `⏳ waiting` | 请求已发出、等待下一个首 token(行尾);与 `⚡ streaming` 互斥,一次回答的周期为 `⏳ waiting → ⚡ streaming →（结束）` |
| `💬 45m` | 本对话从创建到现在经过的时间 |
| `📈 ▁▂▅▇▆▃▂▁▅▇` | 最近 10 次回答速度趋势(高度 = 速度档位;`▁` 占位 = 尚无样本,固定 10 格) |
| `93.2 tok/s`(第 1 行) | 实时速度(流式中)或空闲/等待时最近一次平均速度 |
| `…`(第 1 行) | 工具执行中且暂无速度数据 |
| `↑128 ↓~61` | 最近 10 次:最大速度 / 中位速度 |

时长格式:`3.4s`(<10 秒) / `42s`(<1 分) / `1m23s`(<1 小时) / `2h05m`。

## 自测

状态区与计时逻辑带有零依赖自测(用假 pi/ctx 按真实事件顺序驱动,校验暂停剔除、空闲剔除、持久化恢复与渲染等)。另有技能状态测试覆盖项目级包来源、默认目录、token 估算、三行组合、切换模型、重载、RPC 和无 UI 模式。测试文件位于源码仓库中,npm 包不包含这些文件:

```bash
node --experimental-strip-types agent-timer.test.ts
node --experimental-strip-types skill-status.test.ts
```

## 依赖

- pi 及官方包(`@earendil-works/pi-coding-agent`、`@earendil-works/pi-ai`)以 peerDependencies 方式由运行时注入,无需额外安装。
- Node.js ≥ 22.19.0

## 更新日志

### 1.6.0

- 新增第三行:已加载技能总数、项目级技能数、系统提示词的 token 估算(以 k 为单位),以及占模型上下文窗口的比例;不再单独显示 `default` 数量和技能 token。
- 基于 pi 已加载的资源信息与实际系统提示词统计,不扫描技能文件,不把完整技能正文误计为默认上下文。
- 新增零依赖技能状态与三行组合显示测试。
- 速度折线行前新增 `📈` 图标和空格;加宽 `⏱` 与计时数字之间的间距。

### 1.5.1

- **统计与状态拆分**:`🔧` 片段只显示工具累计时长(`🔧 3m40s`,工具执行中仍实时累加);正在执行的工具名移到行尾,以状态徽标 `⚙ bash` 显示,仅在工具执行中出现,执行结束即消失。
- **文档**:状态区表格与图例已同步到新布局。

### 1.5.0

- **计时语义重构**:⏱ 只计模型真实吐字时间(每条助手消息 `message_start → message_end`),**不再包含工具执行**;工具花多久都不计入。
- **工具后重新计时**:每次调用工具后,模型下一条输出从 0 重新计时。
- **🤖 会话累计** = 会话内每一次输出耗时的求和。
- **工具时长分项**:第 0 行新增 `🔧 3m40s`(工具执行的累计时长),与 ⏱ 纯输出耗时对照;工具执行中同时显示工具名(如 `🔧 bash 1m23s`)。
- **对话时长移位**:💬 排在工具分项之后(新第 0 行顺序:`⏱ / 🤖 → 🔧 → 💬`);行尾徽标仅 `⚡ streaming` / `⏳ waiting`。

### 1.4.0

- **对话时长** `💬 45m`:第 0 行显示本对话从创建至今经过的时间;reload/恢复会话后按创建时间继续计算,`/new` 清零。
- **吐字徽标** `⚡ streaming`:回答正在吐字时显示在第 0 行行尾;与 `🔧 <工具名>` 同时存在时并排显示。
- **等待徽标** `⏳ waiting`:请求已发出但首 token 未到达时显示在第 0 行行尾;与 `⚡ streaming` 互斥(一次回答周期为 `⏳ waiting → ⚡ streaming → 结束`)。第 1 行不再显示等待状态。
- **固定长度折线**:折线图始终渲染 10 格,不足部分从第一次回答起就用 `▁` 占位。
- **工具时长统计**:内部累计工具执行的墙钟时长(并行工具只计一次,等待你回答时暂停),并随会话持久化(`speed-sparkline-tool`)。暂不在状态区展示。
- **图标变更**:计时器的会话累计由 `Σ` 改为 `🤖`。
- **公共函数**:`formatDuration` 移入 `status-bus.ts`,两个扩展共用;`agent-timer.ts` 仍保留导出以兼容旧引用。
- **文档**:中英文 README 同步更新了状态区布局、事件表与图例。

## License

MIT © xiaosat
