@caph42/pi-ccline 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,68 +1,72 @@
1
1
  # pi-ccline
2
2
 
3
- 把 pi 的 footer 交给原版 [CCometixLine](https://github.com/Haleclipse/CCometixLine)(`ccline`)渲染。
3
+ English | [简体中文](./README_zh.md)
4
4
 
5
- 这个扩展不做任何渲染:它从 pi 会话采集状态,拼成 Claude Code `statusLine` 形状的 JSON,喂给 `ccline` 二进制的 stdin,再把它的 stdout 当成 footer 显示。配色、主题、图标、折行全部由上游决定;上游升级或改主题,这里不需要跟着改。
5
+ Hands pi's footer over to upstream [CCometixLine](https://github.com/Haleclipse/CCometixLine) (`ccline`) to render.
6
+
7
+ This extension does no rendering itself: it collects state from the pi session, assembles it into JSON shaped like Claude Code's `statusLine` input, feeds it to the `ccline` binary's stdin, and shows its stdout as the footer. Colors, themes, icons and wrapping are all decided upstream; when upstream upgrades or changes a theme, nothing here needs to follow.
6
8
 
7
9
  ```
8
- pi 会话状态 → buildCclineInput() → JSON → ccline(spawn,COLUMNS=当前宽度)→ ANSI 行 → footer
10
+ pi session state → buildCclineInput() → JSON → ccline (spawn, COLUMNS=current width) → ANSI line → footer
9
11
  ```
10
12
 
11
- ## 能力
13
+ ## What it shows
12
14
 
13
- | 显示内容 | 来源 |
15
+ | What you see | Where it comes from |
14
16
  |---|---|
15
- | 模型名(含 provider) | `ctx.model`(`display_name` 取 `model.name`,回退 `model.id`;`showProvider` 为真时追加 ` (provider)`,如 `Sonnet 4.5 (anthropic)`) |
16
- | 推理强度 | `ctx.thinkingLevel` → `effort.level` |
17
- | 工作目录 | `ctx.cwd` → `cwd` / `workspace.current_dir` |
18
- | 上下文百分比 / token 数 | `ctx.getContextUsage()` → 当前上下文(聚合估计值);**不发会话累计 token** |
19
- | 成本、会话时长 | assistant 消息 `usage.cost.total` 累加;时长 = 当前时间 − 分支最早 entry |
20
- | 会话 id / 名称 | `ctx.sessionManager` |
21
- | git 分支等 ccline 自行探测的信息 | ccline 自己读磁盘 |
22
- | 其他扩展的 status | `footerData.getExtensionStatuses()`,按 key 排序拼成一行放在 ccline 帧下方 |
17
+ | Model name (with provider) | `ctx.model` (`display_name` takes `model.name`, falling back to `model.id`; when `showProvider` is true it appends ` (provider)`, e.g. `Sonnet 4.5 (anthropic)`) |
18
+ | Reasoning effort | `ctx.thinkingLevel` → `effort.level` |
19
+ | Working directory | `ctx.cwd` → `cwd` / `workspace.current_dir` |
20
+ | Context percentage / token count | `ctx.getContextUsage()` → current context (the aggregate estimate); **no lifetime session tokens are sent** |
21
+ | Prompt-cache hit share | assistant messages' `usage.cacheRead` over `input + cacheRead + cacheWrite`, summed over the branch → `prompt_cache.hit_ratio`; sent from the second request on |
22
+ | Output speed (`tok/s`) | measured from `message_start` → first `message_update` → `message_end`, written to ccline's `bridge/<session>.json` for its `timing.tps` metric |
23
+ | Cost, session duration | assistant messages' `usage.cost.total` summed; duration = now − earliest entry on the branch |
24
+ | Session id / name | `ctx.sessionManager` |
25
+ | git branch and anything else ccline probes itself | ccline reads the disk itself |
26
+ | Status lines from other extensions | `footerData.getExtensionStatuses()`, sorted by key into one line below the ccline frame |
23
27
 
24
- 不显示、也不会伪造的:5h / 7d 配额段(pi 没有 `rate_limits` 数据源,字段不传,该段自动消失)。
28
+ Not shown, and never fabricated: the 5h / 7d quota segment (pi has no `rate_limits` source, the field is omitted, and the segment disappears by itself), and the cache warmth / TTL / miss-cause fields (pi knows neither the provider's cache expiry nor why a cache was rebuilt, so only the hit share is sent and ccline is left to hide the rest).
25
29
 
26
- ## 安装
30
+ ## Installation
27
31
 
28
- 先准备外部二进制(扩展不自带渲染器,必须有它):
32
+ Start with the external binary (the extension ships no renderer, so this is required):
29
33
 
30
34
  ```sh
31
35
  npm i -g @cometix/ccline
32
- # 或者只放一份到 ~/.claude/ccline/ccline
36
+ # or drop a single copy at ~/.claude/ccline/ccline
33
37
  ```
34
38
 
35
- **已在 ccline 2.0.1 上验证。** 更老的 1.1.x 会把 `model` / `workspace` / `transcript_path` 当必填字段,收到这里的输入会直接报
36
- `missing field ...` 并非 0 退出(footer 会停在上一帧并标 stale),所以旧安装请先升级。
39
+ **Verified against ccline 2.0.1.** Older 1.1.x treats `model` / `workspace` / `transcript_path` as required fields and exits non-zero with
40
+ `missing field ...` on the input built here (the footer then stays on its previous frame and is marked stale), so upgrade first.
37
41
 
38
- 再装 pi 包(任选其一):
42
+ Then install the pi package (pick one):
39
43
 
40
44
  ```sh
41
- # 从 npm 正式安装(会写 ~/.pi/agent/settings.json)
45
+ # install from npm for real (writes ~/.pi/agent/settings.json)
42
46
  pi install npm:@caph42/pi-ccline
43
47
 
44
- # 从 npm 试用一次,不写 settings.json
48
+ # try it from npm once, without touching settings.json
45
49
  pi -e npm:@caph42/pi-ccline
46
50
 
47
- # 本地源码试用 / 正式安装(在包根目录执行)
51
+ # try / install from local source (run in the package root)
48
52
  pi -e .
49
53
  pi install .
50
54
  ```
51
55
 
52
- 启动后 footer 直接变成 ccline 的渲染结果。终端需要 Nerd Font v3+ 才能显示图标;`style.mode = "plain"` 的 ccline 主题可以退化成 emoji。
56
+ After startup the footer is whatever ccline renders. Your terminal needs Nerd Font v3+ to show the icons; ccline themes with `style.mode = "plain"` degrade to emoji.
53
57
 
54
- ## 命令
58
+ ## Commands
55
59
 
56
- | 命令 | 作用 |
60
+ | Command | Effect |
57
61
  |---|---|
58
- | `/pi-ccline on` | 打开 footer(写入配置,立即生效) |
59
- | `/pi-ccline off` | 关闭并恢复 pi 自带 footer(写入配置) |
60
- | `/pi-ccline status` | 启用状态、模式、解析到的二进制、节流/超时/定时刷新、刷新次数、上一帧是否 stale |
61
- | `/pi-ccline path` | 解析到的二进制路径、来源(config/home/path)与 `ccline --version` |
62
+ | `/pi-ccline on` | Turn the footer on (writes config, takes effect immediately) |
63
+ | `/pi-ccline off` | Turn it off and restore pi's built-in footer (writes config) |
64
+ | `/pi-ccline status` | Enabled state, mode, resolved binary, throttle / timeout / live refresh, refresh count, whether the last frame is stale |
65
+ | `/pi-ccline path` | Resolved binary path, its source (config/home/path) and `ccline --version` |
62
66
 
63
- ## 配置
67
+ ## Configuration
64
68
 
65
- `~/.pi/agent/pi-ccline/config.json`(文件不存在、JSON 坏了、字段类型不对,一律回退默认值,不报错):
69
+ `~/.pi/agent/pi-ccline/config.json` (a missing file, broken JSON or wrongly typed fields all fall back to the defaults silently):
66
70
 
67
71
  ```json
68
72
  {
@@ -76,80 +80,85 @@ pi install .
76
80
  }
77
81
  ```
78
82
 
79
- | 字段 | 默认 | 说明 |
83
+ | Field | Default | Meaning |
80
84
  |---|---|---|
81
- | `enabled` | `true` | 会话启动时是否安装 footer |
82
- | `binary` | `null` | 显式指定 ccline 路径;`null` 走下面的解析顺序。路径不存在时静默回退 |
83
- | `timeoutMs` | `5000` | 单次调用的硬超时(200–60000) |
84
- | `refreshThrottleMs` | `1000` | 两次刷新最小间隔,低于 1000 会被抬到 1000 |
85
- | `liveRefreshMs` | `1000` | agent 运行期间的定时刷新间隔(让 `耗时` 逐秒走);`0` 关闭定时器,只保留事件刷新。非 0 时低于 250 会被抬到 250 |
86
- | `showProvider` | `true` | 是否在模型段追加 provider,如 `Sonnet 4.5 (anthropic)`;`false` 只显示模型名。终端窄到模型段被 ccline 截断时 provider 可能不可见 |
87
- | `env` | `{}` | 追加给 ccline 的环境变量;同名变量覆盖 pi 继承的值(`COLUMNS` 除外,它由扩展按当前宽度写入) |
88
-
89
- 二进制解析顺序:`config.binary` → `~/.claude/ccline/ccline` → `PATH` 中的 `ccline`。
90
-
91
- ## 实现要点
92
-
93
- - `render(width)` 是同步热路径,**绝不在里面 spawn**:只返回缓存帧;宽度变化只记录并投递一次刷新请求。
94
- - 刷新由 `session_start` / `turn_start` / `turn_end` / `model_select` / `thinking_level_select` / `session_info_changed` / git 分支变化触发,经 `RefreshScheduler` 单飞 + 节流 + latest-wins:任何时刻最多一个子进程,排队请求合并为一次,读的是发起时刻的最新状态。
95
- - **耗时这类随时间变化的段靠定时器推进**:纯事件刷新只能在事件发生时算一次「现在 − 起点」,所以 agent 运行期间(`agent_start` → `agent_end`)另有 `RefreshTicker` 按 `liveRefreshMs` 投递刷新请求,`耗时` 才会逐秒走;run 结束即停表,之后只有一次已排队的刷新可能落地,再没有周期刷新。下一轮的 `turn_start` 会重新起表;如果在 run 途中才装上/打开 footer,`attach()` 读 `ctx.isIdle()` 后直接起表。
96
- - 超时 5s(可配)或非 0 退出:保留上一帧并在行尾加 ` ·stale`(放得下才加),不刷屏、不抛错。
97
- - 找不到二进制:footer 显示一行安装提示。
98
- - 行宽一律用 `visibleWidth` / `truncateToWidth` 计算,不用 `.length`。
99
- - **其他扩展的 `ctx.ui.setStatus` 照样可见**:`render(width)` 读 `footerData.getExtensionStatuses()`,按 key 排序、用空格拼成一行、超宽时用 dim `…` 截断,追加在 ccline 帧下方——行为对齐 pi 内置 footer。读的是实时 map,`setStatus` 自己会触发重绘,所以状态变化不受 1Hz 节流限制。
100
- - 因为 footer 槽位全局只有一个,本扩展装上后其他扩展的自定义 footer(例如 pi-cursor-provider 的 fast 徽标)会被顶掉;它们的 status 行仍会显示。反过来它们抢占时 ccline 帧会消失,需要 `/pi-ccline on` 取回。
101
- - **provider 只能拼进模型段。** ccline 2.0.1 的输入 schema 没有 provider 字段,`model` 段只渲染 `display_name`,所以 provider 以 `名称 (provider)` 的形式追加在模型名后;名字里已经带同样的后缀时不重复追加。`showProvider: false` 可关掉。**窄宽度下 provider 可能看不到**:ccline 按 `COLUMNS` 自己折行/截断,实测宽度 40 时模型段只剩 `GPT-5.6`,这是上游布局行为,本扩展只保证把 provider 写进 `display_name`。
102
- - 输入 JSON **省略 `version` 字段**:ccline 2.0.1 会把它当 Claude Code 版本校验,传 pi 的版本会被直接拒绝。
103
- - 没有运行时依赖,只用 `node:child_process` / `node:fs` / `node:os` / `node:path` 和 pi 提供的 peer 包 `@earendil-works/pi-tui`。
104
- - 非 TUI 模式(`-p`、json、rpc)不安装 footer、不 spawn。
105
- - 扩展自己的配置写在 `~/.pi/agent/pi-ccline/config.json`,不碰 pi 的 `settings.json`。
106
-
107
- ## 验证
85
+ | `enabled` | `true` | Whether to install the footer when a session starts |
86
+ | `binary` | `null` | Explicit ccline path; `null` follows the resolution order below. A path that doesn't exist falls back silently |
87
+ | `timeoutMs` | `5000` | Hard timeout per invocation (200–60000) |
88
+ | `refreshThrottleMs` | `1000` | Minimum interval between refreshes; anything below 1000 is raised to 1000 |
89
+ | `liveRefreshMs` | `1000` | Interval of the timer that refreshes while the agent runs (so `duration` ticks every second); `0` disables the timer and leaves only event-driven refreshes. When non-zero, anything below 250 is raised to 250 |
90
+ | `showProvider` | `true` | Whether to append the provider to the model segment, e.g. `Sonnet 4.5 (anthropic)`; `false` shows just the model name. If the terminal is narrow enough for ccline to truncate the model segment, the provider may not be visible |
91
+ | `env` | `{}` | Extra environment variables for ccline; same-named variables override the values inherited from pi (except `COLUMNS`, which the extension writes from the current width) |
92
+
93
+ Binary resolution order: `config.binary` → `~/.claude/ccline/ccline` → `ccline` on `PATH`.
94
+
95
+ ## Implementation notes
96
+
97
+ - `render(width)` is a synchronous hot path and **never spawns anything** from inside: it returns the cached frame; a width change is only recorded and one refresh request is dispatched.
98
+ - Refreshes are triggered by `session_start` / `turn_start` / `turn_end` / `model_select` / `thinking_level_select` / `session_info_changed` / a git branch change, and go through `RefreshScheduler` with single-flight + throttling + latest-wins: at most one child process at any moment, queued requests collapse into one, and it reads the freshest state as of the moment it starts.
99
+ - **Segments that move with time, like `duration`, are advanced by a timer**: a purely event-driven refresh can only compute "now − start" once, when the event fires, so while the agent runs (`agent_start` → `agent_end`) a `RefreshTicker` dispatches refresh requests at `liveRefreshMs`, which is what makes `duration` tick every second; the clock stops when the run ends, after which at most one already-queued refresh can land and no periodic refresh remains. The next `turn_start` restarts the clock; if the footer is installed or switched on mid-run, `attach()` reads `ctx.isIdle()` and starts it immediately.
100
+ - A timeout (5s, configurable) or a non-zero exit keeps the previous frame and appends ` ·stale` at the end of the line (only if it fits): no flooding, no throwing.
101
+ - Binary not found: the footer shows a one-line install hint.
102
+ - Widths are always computed with `visibleWidth` / `truncateToWidth`, never with `.length`.
103
+ - **Other extensions' `ctx.ui.setStatus` stays visible**: `render(width)` reads `footerData.getExtensionStatuses()`, sorts by key, joins them with spaces into one line, truncates with a dim `…` when too wide, and appends it below the ccline frame, matching pi's built-in footer. It reads the live map and `setStatus` itself triggers a repaint, so status changes are not limited by the 1Hz throttle.
104
+ - Because there is only one footer slot globally, other extensions' custom footers (e.g. pi-cursor-provider's fast badge) get displaced once this extension is installed; their status lines still show. Conversely, when they take over, the ccline frame disappears and `/pi-ccline on` takes it back.
105
+ - **The provider can only be folded into the model segment.** ccline 2.0.1's input schema has no provider field and the `model` segment renders only `display_name`, so the provider is appended after the model name as `name (provider)`; it isn't appended twice when the name already carries the same suffix. `showProvider: false` turns it off. **At narrow widths the provider may be invisible**: ccline wraps/truncates by `COLUMNS` itself, and in practice at width 40 the model segment is left with just `GPT-5.6` — that's upstream layout behaviour, and this extension only guarantees the provider is written into `display_name`.
106
+ - The input JSON **omits the `version` field**: ccline 2.0.1 validates it as a Claude Code version and rejects pi's version outright.
107
+ - **Cache hit share:** `prompt_cache` is sent only from the second assistant message on. That is ccline's own display gate (`requests > 1`), whose rationale is that a single request usually only writes the cache — an initial request can still hit a prefix cached by an earlier session, so the gate is display behaviour, not a claim about request #1. Sending `hit_ratio` before that gate would also make ccline fall back to its "cold" wording, which pi cannot assert. `current_usage` deliberately still carries only `input_tokens`: pi's context estimate already includes the cached tokens, and ccline sums the `current_usage` fields when `total_input_tokens` is absent, so adding the cache counts there would double-count them in the context bar.
108
+ - **Output speed goes through ccline's bridge, not stdin.** ccline's `timing.tps` / `timing.ttft` metrics read `bridge/<session>.json` under its data dir (`$CCLINE_HOME` or `~/.claude/ccline`), the file its companion mod writes inside Claude Code. The statusline payload has no per-request timing, so this extension measures it from `message_start` / `message_update` / `message_end` and writes the same documented `v: 1` shape, atomically, once per finished answer. ttft is stream start → first content block; pi emits `message_start` when the provider starts responding, so that number excludes the connect wait. tps divides the answer's output tokens by the time from the first content block to `message_end`, so the wait for the first token is not counted as generation. Durations use a monotonic clock (`performance.now()`); only the bridge's freshness stamp is wall-clock. The file is removed on `session_shutdown`, and ccline ignores bridges older than 6h.
109
+ - No runtime dependencies: only `node:child_process` / `node:fs` / `node:os` / `node:path` plus the pi peer package `@earendil-works/pi-tui`.
110
+ - Non-TUI modes (`-p`, json, rpc) install no footer and spawn nothing.
111
+ - The extension's own config lives at `~/.pi/agent/pi-ccline/config.json`; pi's `settings.json` is never touched.
112
+
113
+ ## Verification
108
114
 
109
115
  ```sh
110
- # 单元测试(输入映射、宽度截断、节流/latest-wins、二进制解析、超时、footer 生命周期)
116
+ # unit tests (input mapping, width truncation, throttle/latest-wins, binary resolution, timeout, footer lifecycle)
111
117
  npm test
112
118
 
113
- # 用 fixture 跑真实 ccline,宽 120 与 40 各一次(打印可见宽度,验证不越界)
119
+ # run the real ccline against the fixture at width 120 and 40 (prints visible widths, checks nothing overflows)
114
120
  node --experimental-strip-types scripts/render-once.ts --fixture fixtures/basic.json
115
121
 
116
- # 只跑某个宽度 / 指定二进制 / 隔离 HOME
122
+ # single width / pinned binary / raw ANSI
117
123
  node --experimental-strip-types scripts/render-once.ts --width 40 --binary ~/.claude/ccline/ccline --raw
118
124
 
119
- # 完全隔离:配置从该 HOME 读,子进程 HOME 也指向它(state 不会写进你的 ~/.claude)
125
+ # fully isolated: config is read from this HOME and the child's HOME points there too (state never lands in your ~/.claude)
120
126
  node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --binary ~/.claude/ccline/ccline
121
127
  ```
122
128
 
123
- `--home` 会把该目录同时用于三处:查 `~/.claude/ccline/ccline`、读 `<home>/.pi/agent/pi-ccline/config.json`、以及给子进程设置 `HOME`/`USERPROFILE`。如果解析到的是 npm 包装脚本(`@cometix/ccline/bin/ccline.js`),它可能自己往这个 HOME 里铺主题等资源,想得到干净结果就同时用 `--binary` 钉死真实二进制。
129
+ `--home` is used for three things at once: finding `~/.claude/ccline/ccline`, reading `<home>/.pi/agent/pi-ccline/config.json`, and setting the child's `HOME`/`USERPROFILE`. If what resolves is the npm wrapper script (`@cometix/ccline/bin/ccline.js`), it may populate that HOME with themes and other resources itself; pin the real binary with `--binary` as well when you want a clean result.
124
130
 
125
- `scripts/render-once.ts` 不启动 pi、不发模型请求,只验证「输入 → 二进制 → 行」这一段。
131
+ `scripts/render-once.ts` starts no pi and sends no model requests; it verifies only the "input → binary → line" stretch.
126
132
 
127
- ## 已知限制
133
+ ## Known limitations
128
134
 
129
- - **5h / 7d 配额段永远为空。** pi 不提供 `rate_limits`,字段整段省略,ccline 自然不显示。
130
- - **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock)。这是上游行为,扩展无法关闭。
131
- - **依赖外部二进制。** 没有 `ccline` 时 footer 只有一行安装提示;升级 `ccline` 后无需重启扩展,但二进制路径会被缓存到下一次解析(`/pi-ccline on` 或新会话会重算)。
132
- - **上游兼容性只覆盖已测版本。** ccline 2.0.1 的 `input.rs` 每个字段都是 `Option` + lenient 反序列化,同一大版本内字段换形状一般只会让对应段消失;但大版本之间已经发生过必填字段变化(1.1.x → 2.0.x),升级 ccline 后请用 `scripts/render-once.ts` 复跑一次。
133
- - **需要 Nerd Font v3+。** 图标字体缺失时会显示成方块;Windows 未测试。
134
- - **上下文 token 只有一套口径:当前上下文。** `context_window` 只发 `current_usage.input_tokens`(= `getContextUsage().tokens`)和 `used_percentage`,不发 `total_input_tokens` / `total_output_tokens`。原因是 ccline 优先取累计值,那样进度条百分比会和 token 数互相矛盾(长会话显示成 `15% · 512k/272k`)。代价:会话累计输入/输出 token 不出现在 footer 里;成本与时长仍来自 `cost` 段。pi 只给聚合估计值,没有 input/cache 拆分,所以 `current_usage` 里只填 `input_tokens`。
135
- - **分支视角。** 汇总只覆盖 `getBranch()` 可见的当前分支;compaction 之后仍按分支上现存记录计算,不等于「整个会话生命周期总量」。切分支后数字会变。
136
- - **不传 `transcript_path`。** pi 的 session jsonl 不是 Claude Code 的 transcript 格式,传过去只会让上游读到无法解析的内容;因此在那个位置上显示的信息来自 JSON 本身而不是重新解析会话文件。
137
- - **刷新频率上限 1Hz。** 流式输出期间上下文用量变化不会逐 token 刷新,只在节流窗口后更新一次。
138
- - **`耗时` 只在 agent 运行期间逐秒走。** 定时器随 run 起停,空闲时停在最后一刻的值,直到下一个事件(发消息、切模型、切分支等)再刷新;`liveRefreshMs` 只控制运行期间的刷新请求节奏,不开启空闲刷新。`liveRefreshMs` 与 `refreshThrottleMs` 都是 1000 时,稳态约 1s 一次,实际间隔受两者共同限制。若以后想让空闲也逐秒走,需要改 `agent_end` 的停表逻辑,属于待评估的耗电取舍。
139
- - **每次定时刷新都是一次进程创建。** 本机实测 spawn ccline 约 24ms(二进制自身 4.4ms),所以运行期间约每秒一次;`liveRefreshMs: 0` 可以彻底关掉。
140
- - **首次刷新可能用 100/120 列。** 首帧宽度优先取 `process.stdout.columns`,拿不到时用 120,pi 第一次绘制后会按真实宽度重刷一次(多一次 4ms 级 spawn)。
135
+ - **The 5h / 7d quota segment is always empty.** pi provides no `rate_limits`, the field is omitted entirely, and ccline shows nothing.
136
+ - **ccline writes `~/.claude/ccline/state/`** (session state, model cache, update.lock). That's upstream behaviour and cannot be disabled here. The extension additionally writes `~/.claude/ccline/bridge/<session>.json` for the `timing.tps` segment, and deletes it when the session shuts down.
137
+ - **The cache segment shows only the hit share.** Warmth, TTL countdown, miss counts and miss causes need data pi does not have (the provider's cache expiry and rebuild reasons), so those fields are omitted — with a config `format` of `{hit}% hit` the segment reduces to that one number, and before the second request it disappears entirely rather than claiming "cold".
138
+ - **`tok/s` describes the last finished answer, not a running average.** It is measured per request and only updates when an answer ends (a non-streaming provider, or a streamed answer of a few tokens, produces no sample and leaves the previous value on screen until the next one arrives). `duration` and `tok/s` are unrelated numbers: duration is session wall-clock, tps is streaming speed.
139
+ - **External binary required.** Without `ccline` the footer is a single install hint; upgrading `ccline` needs no extension restart, but the binary path stays cached until the next resolution (`/pi-ccline on` or a new session recomputes it).
140
+ - **Upstream compatibility only covers tested versions.** Every field in ccline 2.0.1's `input.rs` is `Option` with lenient deserialization, so a field changing shape within the same major version usually just makes that segment disappear; but required fields have changed across majors before (1.1.x → 2.0.x), so re-run `scripts/render-once.ts` after upgrading ccline.
141
+ - **Nerd Font v3+ required.** Missing icon fonts render as boxes; Windows is untested.
142
+ - **There is only one measure of context tokens: the current context.** `context_window` sends only `current_usage.input_tokens` (= `getContextUsage().tokens`) and `used_percentage`, never `total_input_tokens` / `total_output_tokens`. The reason is that ccline prefers the lifetime values, which makes the progress-bar percentage contradict the token count (a long session shows up as `15% · 512k/272k`). The cost: lifetime input/output tokens don't appear in the footer; cost and duration still come from the `cost` segment. pi provides only the aggregate estimate, with no input/cache split, so only `input_tokens` is filled in `current_usage`.
143
+ - **Branch perspective.** The aggregate covers only the current branch as seen by `getBranch()`; after compaction it is still computed from the records that remain on that branch, which is not the same as "total for the whole session lifetime". Switching branches changes the numbers.
144
+ - **`transcript_path` is not sent.** pi's session jsonl is not Claude Code's transcript format; passing it would only make upstream read something it cannot parse, so whatever appears in that spot comes from the JSON itself rather than a re-parse of the session file.
145
+ - **Refresh rate is capped at 1Hz.** During streaming, context usage is not refreshed token by token; it updates once after the throttle window.
146
+ - **`duration` only ticks while the agent runs.** The timer starts and stops with the run, so while idle it sits at the last value until the next event (sending a message, switching model, switching branch, …); `liveRefreshMs` only controls the cadence of refresh requests during a run, it does not enable idle refreshes. With both `liveRefreshMs` and `refreshThrottleMs` at 1000, the steady state is roughly once per second, with the actual interval bounded by both. Making idle time tick every second would mean changing the stop logic in `agent_end` — an untested power trade-off.
147
+ - **Every timer tick is a process creation.** On this machine spawning ccline measured about 24ms (4.4ms in the binary itself), hence roughly once per second during a run; `liveRefreshMs: 0` turns it off entirely.
148
+ - **The first refresh may use 100/120 columns.** The first frame prefers `process.stdout.columns` and falls back to 120; after pi's first paint it refreshes once at the real width (one extra ~4ms spawn).
141
149
 
142
- ## 目录
150
+ ## Layout
143
151
 
144
152
  ```
145
153
  extensions/pi-ccline/
146
- index.ts 生命周期、setFooter、命令、CclineFooter(缓存帧 + 单飞刷新)
147
- input.ts pi 状态 → ccline 输入 JSON(纯函数)
148
- binary.ts 路径解析/缓存、带超时与中止的 spawn、--version 探测
149
- render.ts 输出归一化:分行、按可见宽度截断、stale 标记
150
- refresh.ts 单飞 + 节流 + latest-wins 调度器,以及驱动 `耗时` 的定时器
151
- config.ts 配置读写
154
+ index.ts lifecycle, setFooter, commands, CclineFooter (cached frame + single-flight refresh)
155
+ input.ts pi state → ccline input JSON (pure functions)
156
+ binary.ts path resolution/caching, spawn with timeout and abort, --version probing
157
+ render.ts output normalization: line splitting, truncation by visible width, stale marker
158
+ refresh.ts single-flight + throttle + latest-wins scheduler, and the timer that drives `duration`
159
+ turn.ts per-request timing from the stream, plus the ccline bridge file for `timing.tps`
160
+ config.ts config read/write
152
161
  *.test.ts node --test
153
- scripts/render-once.ts 用 fixture 跑一次真实 ccline
154
- fixtures/basic.json 人工验收用输入
162
+ scripts/render-once.ts run the real ccline once against a fixture
163
+ fixtures/basic.json input used for manual acceptance
155
164
  ```
package/README_zh.md ADDED
@@ -0,0 +1,164 @@
1
+ # pi-ccline
2
+
3
+ [English](./README.md) | 简体中文
4
+
5
+ 把 pi 的 footer 交给原版 [CCometixLine](https://github.com/Haleclipse/CCometixLine)(`ccline`)渲染。
6
+
7
+ 这个扩展不做任何渲染:它从 pi 会话采集状态,拼成 Claude Code `statusLine` 形状的 JSON,喂给 `ccline` 二进制的 stdin,再把它的 stdout 当成 footer 显示。配色、主题、图标、折行全部由上游决定;上游升级或改主题,这里不需要跟着改。
8
+
9
+ ```
10
+ pi 会话状态 → buildCclineInput() → JSON → ccline(spawn,COLUMNS=当前宽度)→ ANSI 行 → footer
11
+ ```
12
+
13
+ ## 能力
14
+
15
+ | 显示内容 | 来源 |
16
+ |---|---|
17
+ | 模型名(含 provider) | `ctx.model`(`display_name` 取 `model.name`,回退 `model.id`;`showProvider` 为真时追加 ` (provider)`,如 `Sonnet 4.5 (anthropic)`) |
18
+ | 推理强度 | `ctx.thinkingLevel` → `effort.level` |
19
+ | 工作目录 | `ctx.cwd` → `cwd` / `workspace.current_dir` |
20
+ | 上下文百分比 / token 数 | `ctx.getContextUsage()` → 当前上下文(聚合估计值);**不发会话累计 token** |
21
+ | 缓存命中率 | assistant 消息的 `usage.cacheRead` 除以 `input + cacheRead + cacheWrite`,在分支上累加 → `prompt_cache.hit_ratio`;从第二次请求起才发送 |
22
+ | 输出速度(`tok/s`) | 由 `message_start` → 首个 `message_update` → `message_end` 实测,写入 ccline 的 `bridge/<session>.json` 供 `timing.tps` 读取 |
23
+ | 成本、会话时长 | assistant 消息 `usage.cost.total` 累加;时长 = 当前时间 − 分支最早 entry |
24
+ | 会话 id / 名称 | `ctx.sessionManager` |
25
+ | git 分支等 ccline 自行探测的信息 | ccline 自己读磁盘 |
26
+ | 其他扩展的 status | `footerData.getExtensionStatuses()`,按 key 排序拼成一行放在 ccline 帧下方 |
27
+
28
+ 不显示、也不会伪造的:5h / 7d 配额段(pi 没有 `rate_limits` 数据源,字段不传,该段自动消失);缓存冷热 / TTL 倒计时 / miss 次数与原因(pi 既不知道 provider 的缓存过期时间,也不知道缓存为何重建,所以只发命中率,其余字段交给 ccline 自己隐藏)。
29
+
30
+ ## 安装
31
+
32
+ 先准备外部二进制(扩展不自带渲染器,必须有它):
33
+
34
+ ```sh
35
+ npm i -g @cometix/ccline
36
+ # 或者只放一份到 ~/.claude/ccline/ccline
37
+ ```
38
+
39
+ **已在 ccline 2.0.1 上验证。** 更老的 1.1.x 会把 `model` / `workspace` / `transcript_path` 当必填字段,收到这里的输入会直接报
40
+ `missing field ...` 并非 0 退出(footer 会停在上一帧并标 stale),所以旧安装请先升级。
41
+
42
+ 再装 pi 包(任选其一):
43
+
44
+ ```sh
45
+ # 从 npm 正式安装(会写 ~/.pi/agent/settings.json)
46
+ pi install npm:@caph42/pi-ccline
47
+
48
+ # 从 npm 试用一次,不写 settings.json
49
+ pi -e npm:@caph42/pi-ccline
50
+
51
+ # 本地源码试用 / 正式安装(在包根目录执行)
52
+ pi -e .
53
+ pi install .
54
+ ```
55
+
56
+ 启动后 footer 直接变成 ccline 的渲染结果。终端需要 Nerd Font v3+ 才能显示图标;`style.mode = "plain"` 的 ccline 主题可以退化成 emoji。
57
+
58
+ ## 命令
59
+
60
+ | 命令 | 作用 |
61
+ |---|---|
62
+ | `/pi-ccline on` | 打开 footer(写入配置,立即生效) |
63
+ | `/pi-ccline off` | 关闭并恢复 pi 自带 footer(写入配置) |
64
+ | `/pi-ccline status` | 启用状态、模式、解析到的二进制、节流/超时/定时刷新、刷新次数、上一帧是否 stale |
65
+ | `/pi-ccline path` | 解析到的二进制路径、来源(config/home/path)与 `ccline --version` |
66
+
67
+ ## 配置
68
+
69
+ `~/.pi/agent/pi-ccline/config.json`(文件不存在、JSON 坏了、字段类型不对,一律回退默认值,不报错):
70
+
71
+ ```json
72
+ {
73
+ "enabled": true,
74
+ "binary": null,
75
+ "timeoutMs": 5000,
76
+ "refreshThrottleMs": 1000,
77
+ "liveRefreshMs": 1000,
78
+ "showProvider": true,
79
+ "env": {}
80
+ }
81
+ ```
82
+
83
+ | 字段 | 默认 | 说明 |
84
+ |---|---|---|
85
+ | `enabled` | `true` | 会话启动时是否安装 footer |
86
+ | `binary` | `null` | 显式指定 ccline 路径;`null` 走下面的解析顺序。路径不存在时静默回退 |
87
+ | `timeoutMs` | `5000` | 单次调用的硬超时(200–60000) |
88
+ | `refreshThrottleMs` | `1000` | 两次刷新最小间隔,低于 1000 会被抬到 1000 |
89
+ | `liveRefreshMs` | `1000` | agent 运行期间的定时刷新间隔(让 `耗时` 逐秒走);`0` 关闭定时器,只保留事件刷新。非 0 时低于 250 会被抬到 250 |
90
+ | `showProvider` | `true` | 是否在模型段追加 provider,如 `Sonnet 4.5 (anthropic)`;`false` 只显示模型名。终端窄到模型段被 ccline 截断时 provider 可能不可见 |
91
+ | `env` | `{}` | 追加给 ccline 的环境变量;同名变量覆盖 pi 继承的值(`COLUMNS` 除外,它由扩展按当前宽度写入) |
92
+
93
+ 二进制解析顺序:`config.binary` → `~/.claude/ccline/ccline` → `PATH` 中的 `ccline`。
94
+
95
+ ## 实现要点
96
+
97
+ - `render(width)` 是同步热路径,**绝不在里面 spawn**:只返回缓存帧;宽度变化只记录并投递一次刷新请求。
98
+ - 刷新由 `session_start` / `turn_start` / `turn_end` / `model_select` / `thinking_level_select` / `session_info_changed` / git 分支变化触发,经 `RefreshScheduler` 单飞 + 节流 + latest-wins:任何时刻最多一个子进程,排队请求合并为一次,读的是发起时刻的最新状态。
99
+ - **耗时这类随时间变化的段靠定时器推进**:纯事件刷新只能在事件发生时算一次「现在 − 起点」,所以 agent 运行期间(`agent_start` → `agent_end`)另有 `RefreshTicker` 按 `liveRefreshMs` 投递刷新请求,`耗时` 才会逐秒走;run 结束即停表,之后只有一次已排队的刷新可能落地,再没有周期刷新。下一轮的 `turn_start` 会重新起表;如果在 run 途中才装上/打开 footer,`attach()` 读 `ctx.isIdle()` 后直接起表。
100
+ - 超时 5s(可配)或非 0 退出:保留上一帧并在行尾加 ` ·stale`(放得下才加),不刷屏、不抛错。
101
+ - 找不到二进制:footer 显示一行安装提示。
102
+ - 行宽一律用 `visibleWidth` / `truncateToWidth` 计算,不用 `.length`。
103
+ - **其他扩展的 `ctx.ui.setStatus` 照样可见**:`render(width)` 读 `footerData.getExtensionStatuses()`,按 key 排序、用空格拼成一行、超宽时用 dim `…` 截断,追加在 ccline 帧下方——行为对齐 pi 内置 footer。读的是实时 map,`setStatus` 自己会触发重绘,所以状态变化不受 1Hz 节流限制。
104
+ - 因为 footer 槽位全局只有一个,本扩展装上后其他扩展的自定义 footer(例如 pi-cursor-provider 的 fast 徽标)会被顶掉;它们的 status 行仍会显示。反过来它们抢占时 ccline 帧会消失,需要 `/pi-ccline on` 取回。
105
+ - **provider 只能拼进模型段。** ccline 2.0.1 的输入 schema 没有 provider 字段,`model` 段只渲染 `display_name`,所以 provider 以 `名称 (provider)` 的形式追加在模型名后;名字里已经带同样的后缀时不重复追加。`showProvider: false` 可关掉。**窄宽度下 provider 可能看不到**:ccline 按 `COLUMNS` 自己折行/截断,实测宽度 40 时模型段只剩 `GPT-5.6`,这是上游布局行为,本扩展只保证把 provider 写进 `display_name`。
106
+ - 输入 JSON **省略 `version` 字段**:ccline 2.0.1 会把它当 Claude Code 版本校验,传 pi 的版本会被直接拒绝。
107
+ - **缓存命中率:** `prompt_cache` 从第二条 assistant 消息起才发。这是 ccline 自己的显示门槛(`requests > 1`),它的理由是一次请求通常只是在写缓存——但首个请求确实可能命中之前会话已缓存的 prefix,所以这是显示行为,不是对「第一次请求」的事实断言。若在门槛前就发 `hit_ratio`,ccline 还会回退到 `cold` 文案,而那是 pi 无法断言的。`current_usage` 仍然只填 `input_tokens`:pi 的上下文估计值本就包含缓存 token,而缺少 `total_input_tokens` 时 ccline 会把 `current_usage` 各项相加,把 cache 数填进去会让它们被重复计入上下文进度条。
108
+ - **输出速度走 ccline 的 bridge,不走 stdin。** ccline 的 `timing.tps` / `timing.ttft` 读的是其数据目录(`$CCLINE_HOME` 或 `~/.claude/ccline`)下的 `bridge/<session>.json`,也就是它家在 Claude Code 里的伴生 mod 写的文件。statusline 载荷里没有单次请求耗时,所以本扩展用 `message_start` / `message_update` / `message_end` 自己计时,并按同样的 `v: 1` 结构原子写入,每个答完的请求写一次。ttft = 流开始 → 首个内容块;pi 是在 provider 开始响应后才发 `message_start`,所以这个数不含建立连接的等待。tps = 该回答的 output token 除以「首个内容块 → `message_end`」的时长,所以等首字的耗时不算进生成速度。计时用单调时钟(`performance.now()`),只有 bridge 的新鲜度标记用墙钟。`session_shutdown` 时删除该文件;ccline 也会忽略超过 6 小时的 bridge。
109
+ - 没有运行时依赖,只用 `node:child_process` / `node:fs` / `node:os` / `node:path` 和 pi 提供的 peer 包 `@earendil-works/pi-tui`。
110
+ - 非 TUI 模式(`-p`、json、rpc)不安装 footer、不 spawn。
111
+ - 扩展自己的配置写在 `~/.pi/agent/pi-ccline/config.json`,不碰 pi 的 `settings.json`。
112
+
113
+ ## 验证
114
+
115
+ ```sh
116
+ # 单元测试(输入映射、宽度截断、节流/latest-wins、二进制解析、超时、footer 生命周期)
117
+ npm test
118
+
119
+ # 用 fixture 跑真实 ccline,宽 120 与 40 各一次(打印可见宽度,验证不越界)
120
+ node --experimental-strip-types scripts/render-once.ts --fixture fixtures/basic.json
121
+
122
+ # 只跑某个宽度 / 指定二进制 / 隔离 HOME
123
+ node --experimental-strip-types scripts/render-once.ts --width 40 --binary ~/.claude/ccline/ccline --raw
124
+
125
+ # 完全隔离:配置从该 HOME 读,子进程 HOME 也指向它(state 不会写进你的 ~/.claude)
126
+ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --binary ~/.claude/ccline/ccline
127
+ ```
128
+
129
+ `--home` 会把该目录同时用于三处:查 `~/.claude/ccline/ccline`、读 `<home>/.pi/agent/pi-ccline/config.json`、以及给子进程设置 `HOME`/`USERPROFILE`。如果解析到的是 npm 包装脚本(`@cometix/ccline/bin/ccline.js`),它可能自己往这个 HOME 里铺主题等资源,想得到干净结果就同时用 `--binary` 钉死真实二进制。
130
+
131
+ `scripts/render-once.ts` 不启动 pi、不发模型请求,只验证「输入 → 二进制 → 行」这一段。
132
+
133
+ ## 已知限制
134
+
135
+ - **5h / 7d 配额段永远为空。** pi 不提供 `rate_limits`,字段整段省略,ccline 自然不显示。
136
+ - **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock)。这是上游行为,扩展无法关闭。另外本扩展会写 `~/.claude/ccline/bridge/<session>.json` 供 `timing.tps` 段使用,会话关闭时删掉。
137
+ - **缓存段只显示命中率。** 冷热状态、TTL 倒计时、miss 次数与原因需要 pi 没有的数据(provider 的缓存过期时间、重建原因),这些字段一律不发;配置里用 `format = "{hit}% hit"` 就只剩这一个数字,而第二次请求之前整段直接消失,而不是谎称 `cold`。
138
+ - **`tok/s` 描述的是最后一次答完的请求,不是滚动平均。** 它按请求实测,只在回答结束时更新(非流式 provider、或只有几个 token 的回答不会产生样本,上一轮的数值会继续显示到下一次更新)。`duration` 和 `tok/s` 没有关系:前者是会话墙钟时长,后者是流式速度。
139
+ - **依赖外部二进制。** 没有 `ccline` 时 footer 只有一行安装提示;升级 `ccline` 后无需重启扩展,但二进制路径会被缓存到下一次解析(`/pi-ccline on` 或新会话会重算)。
140
+ - **上游兼容性只覆盖已测版本。** ccline 2.0.1 的 `input.rs` 每个字段都是 `Option` + lenient 反序列化,同一大版本内字段换形状一般只会让对应段消失;但大版本之间已经发生过必填字段变化(1.1.x → 2.0.x),升级 ccline 后请用 `scripts/render-once.ts` 复跑一次。
141
+ - **需要 Nerd Font v3+。** 图标字体缺失时会显示成方块;Windows 未测试。
142
+ - **上下文 token 只有一套口径:当前上下文。** `context_window` 只发 `current_usage.input_tokens`(= `getContextUsage().tokens`)和 `used_percentage`,不发 `total_input_tokens` / `total_output_tokens`。原因是 ccline 优先取累计值,那样进度条百分比会和 token 数互相矛盾(长会话显示成 `15% · 512k/272k`)。代价:会话累计输入/输出 token 不出现在 footer 里;成本与时长仍来自 `cost` 段。pi 只给聚合估计值,没有 input/cache 拆分,所以 `current_usage` 里只填 `input_tokens`。
143
+ - **分支视角。** 汇总只覆盖 `getBranch()` 可见的当前分支;compaction 之后仍按分支上现存记录计算,不等于「整个会话生命周期总量」。切分支后数字会变。
144
+ - **不传 `transcript_path`。** pi 的 session jsonl 不是 Claude Code 的 transcript 格式,传过去只会让上游读到无法解析的内容;因此在那个位置上显示的信息来自 JSON 本身而不是重新解析会话文件。
145
+ - **刷新频率上限 1Hz。** 流式输出期间上下文用量变化不会逐 token 刷新,只在节流窗口后更新一次。
146
+ - **`耗时` 只在 agent 运行期间逐秒走。** 定时器随 run 起停,空闲时停在最后一刻的值,直到下一个事件(发消息、切模型、切分支等)再刷新;`liveRefreshMs` 只控制运行期间的刷新请求节奏,不开启空闲刷新。`liveRefreshMs` 与 `refreshThrottleMs` 都是 1000 时,稳态约 1s 一次,实际间隔受两者共同限制。若以后想让空闲也逐秒走,需要改 `agent_end` 的停表逻辑,属于待评估的耗电取舍。
147
+ - **每次定时刷新都是一次进程创建。** 本机实测 spawn ccline 约 24ms(二进制自身 4.4ms),所以运行期间约每秒一次;`liveRefreshMs: 0` 可以彻底关掉。
148
+ - **首次刷新可能用 100/120 列。** 首帧宽度优先取 `process.stdout.columns`,拿不到时用 120,pi 第一次绘制后会按真实宽度重刷一次(多一次 4ms 级 spawn)。
149
+
150
+ ## 目录
151
+
152
+ ```
153
+ extensions/pi-ccline/
154
+ index.ts 生命周期、setFooter、命令、CclineFooter(缓存帧 + 单飞刷新)
155
+ input.ts pi 状态 → ccline 输入 JSON(纯函数)
156
+ binary.ts 路径解析/缓存、带超时与中止的 spawn、--version 探测
157
+ render.ts 输出归一化:分行、按可见宽度截断、stale 标记
158
+ refresh.ts 单飞 + 节流 + latest-wins 调度器,以及驱动 `耗时` 的定时器
159
+ turn.ts 从流事件实测单次请求耗时,并写 ccline 的 bridge 文件供 `timing.tps`
160
+ config.ts 配置读写
161
+ *.test.ts node --test
162
+ scripts/render-once.ts 用 fixture 跑一次真实 ccline
163
+ fixtures/basic.json 人工验收用输入
164
+ ```
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { afterEach, test } from "node:test";
@@ -318,7 +318,7 @@ function fakePi() {
318
318
  },
319
319
  registerCommand: () => {},
320
320
  };
321
- const dispatch = (name: string, ctx: unknown) => handlers.get(name)?.({ type: name }, ctx);
321
+ const dispatch = (name: string, ctx: unknown, event: Record<string, unknown> = {}) => handlers.get(name)?.({ type: name, ...event }, ctx);
322
322
  return { pi, handlers, dispatch };
323
323
  }
324
324
 
@@ -383,6 +383,70 @@ test("agent_start ticks the footer and agent_end leaves no periodic refresh behi
383
383
  await dispatch("session_shutdown", ctx);
384
384
  });
385
385
 
386
+ test("writes ccline's timing bridge from the streamed answer", async () => {
387
+ const cclineHome = tempDir("pi-ccline-bridge-");
388
+ const previousHome = process.env.CCLINE_HOME;
389
+ process.env.CCLINE_HOME = cclineHome;
390
+ try {
391
+ const binary = fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'");
392
+ const { pi, handlers, dispatch } = fakePi();
393
+ piCcline(pi as never, { loadConfig: () => ({ ...DEFAULT_CONFIG, binary }) });
394
+ for (const event of ["message_start", "message_update", "message_end"]) {
395
+ assert.ok(handlers.has(event), `${event} is wired`);
396
+ }
397
+
398
+ const { ctx } = fakeSession({ idle: true, binary });
399
+ await dispatch("session_start", ctx);
400
+
401
+ const assistant = { role: "assistant", model: "claude-sonnet-4-5", usage: { output: 500 } };
402
+ await dispatch("message_start", ctx, { message: assistant });
403
+ await sleep(120);
404
+ await dispatch("message_update", ctx, { message: assistant });
405
+ await sleep(220);
406
+ await dispatch("message_end", ctx, { message: assistant });
407
+
408
+ const bridge = JSON.parse(readFileSync(join(cclineHome, "bridge", "sess-1.json"), "utf8"));
409
+ assert.equal(bridge.v, 1);
410
+ assert.equal(bridge.session, "sess-1");
411
+ assert.equal(bridge.turn.model, "claude-sonnet-4-5");
412
+ assert.equal(bridge.turn.output_tokens, 500);
413
+ assert.ok(bridge.turn.ttft_ms >= 100, "ttft covers the wait before the first token");
414
+ assert.ok(bridge.turn.tps > 0 && Number.isFinite(bridge.turn.tps));
415
+
416
+ await dispatch("session_shutdown", ctx);
417
+ assert.equal(existsSync(join(cclineHome, "bridge", "sess-1.json")), false, "a closed session leaves no bridge");
418
+ } finally {
419
+ if (previousHome === undefined) delete process.env.CCLINE_HOME;
420
+ else process.env.CCLINE_HOME = previousHome;
421
+ }
422
+ });
423
+
424
+ test("ignores message events that are not the assistant's answer", async () => {
425
+ const cclineHome = tempDir("pi-ccline-bridge-role-");
426
+ const previousHome = process.env.CCLINE_HOME;
427
+ process.env.CCLINE_HOME = cclineHome;
428
+ try {
429
+ const binary = fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'");
430
+ const { pi, dispatch } = fakePi();
431
+ piCcline(pi as never, { loadConfig: () => ({ ...DEFAULT_CONFIG, binary }) });
432
+ const { ctx } = fakeSession({ idle: true, binary });
433
+ await dispatch("session_start", ctx);
434
+
435
+ // A user message or tool result carries no model timing.
436
+ for (const role of ["user", "toolResult"]) {
437
+ const message = { role, usage: { output: 500 } };
438
+ await dispatch("message_start", ctx, { message });
439
+ await dispatch("message_update", ctx, { message });
440
+ await dispatch("message_end", ctx, { message });
441
+ }
442
+ assert.equal(existsSync(join(cclineHome, "bridge")), false, "no timing is attributed to non-answers");
443
+ await dispatch("session_shutdown", ctx);
444
+ } finally {
445
+ if (previousHome === undefined) delete process.env.CCLINE_HOME;
446
+ else process.env.CCLINE_HOME = previousHome;
447
+ }
448
+ });
449
+
386
450
  test("a footer installed mid-run ticks without waiting for the next turn", async () => {
387
451
  const counter = join(tempDir("pi-ccline-lifecycle-midrun-"), "count");
388
452
  const binary = fakeBinary(
@@ -6,6 +6,7 @@ import { loadConfig, saveConfig, type PiCclineConfig } from "./config.ts";
6
6
  import { buildCclineInput, collectSnapshot } from "./input.ts";
7
7
  import { RefreshScheduler, RefreshTicker } from "./refresh.ts";
8
8
  import { extensionStatusLine, loadingNotice, missingBinaryNotice, normalizeFrame, noticeFrame, withStaleMarker } from "./render.ts";
9
+ import { removeTurnBridge, TurnMeter, writeTurnBridge } from "./turn.ts";
9
10
 
10
11
  /** Structural subsets so the footer does not depend on pi-tui internals. */
11
12
  type TuiLike = { requestRender(force?: boolean): void };
@@ -16,6 +17,11 @@ type FooterDataLike = {
16
17
  getExtensionStatuses?(): ReadonlyMap<string, string>;
17
18
  };
18
19
 
20
+ /** The slice of a pi message event this extension reads. */
21
+ type MessageEventLike = {
22
+ message?: { role?: string | undefined; model?: string | undefined; usage?: { output?: number | undefined } | undefined } | undefined;
23
+ };
24
+
19
25
  const DEFAULT_WIDTH = 120;
20
26
 
21
27
  /**
@@ -57,12 +63,16 @@ export class CclineFooter {
57
63
  private footerData: FooterDataLike | null = null;
58
64
  private requestRenderFn: (() => void) | null = null;
59
65
  private branchUnsubscribe: (() => void) | null = null;
66
+ /** Session whose ccline bridge this footer wrote, removed again on dispose. */
67
+ private bridgeSessionId: string | null = null;
68
+ private readonly meter = new TurnMeter();
60
69
  private disposed = false;
61
70
 
62
71
  readonly stats = {
63
72
  refreshes: 0,
64
73
  lastRefreshAt: null as number | null,
65
74
  lastDurationMs: null as number | null,
75
+ lastTps: null as number | null,
66
76
  };
67
77
  resolved: ResolvedBinary = { path: null, source: "missing" };
68
78
 
@@ -136,6 +146,56 @@ export class CclineFooter {
136
146
  return this.lastError;
137
147
  }
138
148
 
149
+ /** The session pi reports, or null when it cannot be read or is unusable. */
150
+ private sessionId(): string | null {
151
+ try {
152
+ const id = this.ctx.sessionManager.getSessionId?.();
153
+ return typeof id === "string" && id.trim() ? id.trim() : null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /** A model request went out: start timing until its answer finishes. */
160
+ onMessageStart(event: MessageEventLike): void {
161
+ const message = event.message;
162
+ if (!message || message.role !== "assistant") return;
163
+ // Stream start, not send time: pi begins the stream once the provider has
164
+ // answered, so this ttft stops short of the connect wait by design.
165
+ this.meter.start(message.model, performance.now());
166
+ }
167
+
168
+ /**
169
+ * A content block of that answer arrived (`text_start` / `*_delta` /
170
+ * `toolcall_*`; pi emits no metadata through this event), so the clock for
171
+ * the streaming rate starts at the first sign of content.
172
+ */
173
+ onMessageUpdate(event: MessageEventLike): void {
174
+ if (event.message?.role !== "assistant") return;
175
+ this.meter.firstDelta(performance.now());
176
+ }
177
+
178
+ /**
179
+ * The answer finished: hand ccline its timing through the bridge file it
180
+ * reads for `timing.tps`. No speed, no file.
181
+ */
182
+ onMessageEnd(event: MessageEventLike): void {
183
+ const message = event.message;
184
+ if (!message || message.role !== "assistant") return;
185
+ // Measurement uses the monotonic clock; only the bridge's freshness
186
+ // stamp is wall-clock, and the two are never mixed.
187
+ const timing = this.meter.finish(message.usage?.output, performance.now());
188
+ if (!timing) return;
189
+ const sessionId = this.sessionId();
190
+ if (!sessionId) return;
191
+ if (!writeTurnBridge({ sessionId, timing, nowMs: Date.now() })) return;
192
+ if (this.bridgeSessionId && this.bridgeSessionId !== sessionId) removeTurnBridge(this.bridgeSessionId);
193
+ this.bridgeSessionId = sessionId;
194
+ this.stats.lastTps = timing.tps;
195
+ // The new speed only shows up in the next frame; let the throttle coalesce it.
196
+ this.requestRefresh();
197
+ }
198
+
139
199
  get hasFrame(): boolean {
140
200
  return this.frame !== null;
141
201
  }
@@ -253,6 +313,8 @@ export class CclineFooter {
253
313
  this.branchUnsubscribe = null;
254
314
  this.footerData = null;
255
315
  this.requestRenderFn = null;
316
+ if (this.bridgeSessionId) removeTurnBridge(this.bridgeSessionId);
317
+ this.bridgeSessionId = null;
256
318
  }
257
319
  }
258
320
 
@@ -307,6 +369,11 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
307
369
  pi.on("model_select", () => requestRefresh());
308
370
  pi.on("thinking_level_select", () => requestRefresh());
309
371
  pi.on("session_info_changed", () => requestRefresh());
372
+ // Per-request timing is not in the statusline payload, so it is measured
373
+ // from the stream and written to ccline's bridge file when an answer ends.
374
+ pi.on("message_start", (event) => footer?.onMessageStart(event as MessageEventLike));
375
+ pi.on("message_update", (event) => footer?.onMessageUpdate(event as MessageEventLike));
376
+ pi.on("message_end", (event) => footer?.onMessageEnd(event as MessageEventLike));
310
377
  pi.on("session_shutdown", () => teardown());
311
378
 
312
379
  pi.registerCommand("pi-ccline", {
@@ -364,6 +431,7 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
364
431
  const { refreshes, lastRefreshAt, lastDurationMs } = footer.stats;
365
432
  lines.push(`refreshes: ${refreshes} · last: ${lastRefreshAt === null ? "never" : `${Math.round((Date.now() - lastRefreshAt) / 1000)}s ago`}`);
366
433
  lines.push(`duration: ${lastDurationMs === null ? "n/a" : `${lastDurationMs}ms`} · frame: ${footer.hasFrame ? "cached" : "none"}`);
434
+ lines.push(`last answer: ${footer.stats.lastTps === null ? "not measured yet" : `${footer.stats.lastTps.toFixed(1)} tok/s`}`);
367
435
  lines.push(`state: ${footer.error ? `stale — ${footer.error}` : "ok"}`);
368
436
  } else {
369
437
  lines.push("state: footer not installed");
@@ -185,9 +185,31 @@ test("collectSnapshot sums only assistant usage on the branch", () => {
185
185
  test("collectSnapshot reports no totals for an empty branch", () => {
186
186
  const snapshotValue = collectSnapshot(fakeContext([]), Date.now());
187
187
  assert.equal(snapshotValue.totals, undefined);
188
+ assert.equal(snapshotValue.cache, undefined);
188
189
  assert.equal(snapshotValue.session?.name, "named");
189
190
  });
190
191
 
192
+ test("collectSnapshot sums prompt-cache reuse over the branch", () => {
193
+ const branch: BranchEntryLike[] = [
194
+ entry({ type: "message", message: { role: "user" } }),
195
+ entry({ type: "message", message: { role: "assistant", usage: { input: 100, cacheRead: 300, cacheWrite: 100 } } }),
196
+ entry({ type: "message", message: { role: "assistant", usage: { input: 100, cacheRead: 0, cacheWrite: 0 } } }),
197
+ ];
198
+
199
+ // 300 of 600 billed input tokens came from the cache.
200
+ assert.deepEqual(collectSnapshot(fakeContext(branch), Date.now()).cache, { requests: 2, hitRatio: 0.5 });
201
+ });
202
+
203
+ test("sends the cache hit share only once it means something", () => {
204
+ // ccline shows the share from the second request on and falls back to a
205
+ // misleading "cold" label when hit_ratio is missing, so nothing is sent yet.
206
+ assert.equal("prompt_cache" in buildCclineInput(snapshot({ cache: { requests: 1, hitRatio: 0 } })), false);
207
+ assert.deepEqual(buildCclineInput(snapshot({ cache: { requests: 41, hitRatio: 0.94123 } })).prompt_cache, {
208
+ requests: 41,
209
+ hit_ratio: 0.9412,
210
+ });
211
+ });
212
+
191
213
  test("collectSnapshot carries the provider from ctx.model", () => {
192
214
  const ctx = fakeContext([], { model: { id: "m", name: "M", provider: "anthropic", contextWindow: 200_000 } });
193
215
  const snapshotValue = collectSnapshot(ctx, Date.now());
@@ -28,6 +28,14 @@ export interface PiSnapshotTotals {
28
28
  durationMs?: number | undefined;
29
29
  }
30
30
 
31
+ /** Prompt-cache reuse summed over the visible branch. */
32
+ export interface PiSnapshotCache {
33
+ /** Assistant messages seen, each one a model request. */
34
+ requests: number;
35
+ /** cacheRead / (input + cacheRead + cacheWrite); the share read from cache. */
36
+ hitRatio: number;
37
+ }
38
+
31
39
  export interface PiSnapshotSession {
32
40
  id?: string | undefined;
33
41
  name?: string | undefined;
@@ -39,6 +47,7 @@ export interface PiSnapshot {
39
47
  thinkingLevel?: string | undefined;
40
48
  contextUsage?: PiSnapshotContextUsage | undefined;
41
49
  totals?: PiSnapshotTotals | undefined;
50
+ cache?: PiSnapshotCache | undefined;
42
51
  session?: PiSnapshotSession | undefined;
43
52
  }
44
53
 
@@ -95,6 +104,11 @@ function round1(value: number): number {
95
104
  return Math.round(value * 10) / 10;
96
105
  }
97
106
 
107
+ /** A ratio ccline renders as a whole percent, so four decimals are plenty. */
108
+ function round4(value: number): number {
109
+ return Math.round(value * 10_000) / 10_000;
110
+ }
111
+
98
112
  function trimmed(value: unknown): string | undefined {
99
113
  if (typeof value !== "string") return undefined;
100
114
  const text = value.trim();
@@ -120,6 +134,10 @@ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnap
120
134
  let costUsd = 0;
121
135
  let sawAssistantUsage = false;
122
136
  let firstTimestamp: number | undefined;
137
+ let requests = 0;
138
+ let inputTokens = 0;
139
+ let cacheReadTokens = 0;
140
+ let cacheWriteTokens = 0;
123
141
 
124
142
  for (const entry of branch) {
125
143
  const timestamp = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN;
@@ -131,6 +149,10 @@ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnap
131
149
  if (!entryUsage) continue;
132
150
  sawAssistantUsage = true;
133
151
  costUsd += num(entryUsage.cost?.total);
152
+ requests += 1;
153
+ inputTokens += num(entryUsage.input);
154
+ cacheReadTokens += num(entryUsage.cacheRead);
155
+ cacheWriteTokens += num(entryUsage.cacheWrite);
134
156
  }
135
157
 
136
158
  const totals: PiSnapshotTotals | undefined = sawAssistantUsage
@@ -142,6 +164,9 @@ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnap
142
164
 
143
165
  const sessionId = attempt(() => ctx.sessionManager.getSessionId?.());
144
166
  const sessionName = attempt(() => ctx.sessionManager.getSessionName?.());
167
+ // The denominator is every input token the branch was billed for; without
168
+ // one there is no ratio to report (a session that never sent a prompt yet).
169
+ const seenInput = inputTokens + cacheReadTokens + cacheWriteTokens;
145
170
 
146
171
  return {
147
172
  cwd: ctx.cwd,
@@ -155,6 +180,7 @@ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnap
155
180
  }
156
181
  : undefined,
157
182
  totals,
183
+ cache: requests > 0 && seenInput > 0 ? { requests, hitRatio: cacheReadTokens / seenInput } : undefined,
158
184
  session: { id: trimmed(sessionId), name: trimmed(sessionName) },
159
185
  };
160
186
  }
@@ -236,6 +262,14 @@ export function buildCclineInput(snapshot: PiSnapshot, options: BuildCclineInput
236
262
  const sessionName = trimmed(snapshot.session?.name);
237
263
  if (sessionName) input.session_name = sessionName;
238
264
 
265
+ // ccline only renders the hit share from the second request on, and falls
266
+ // back to its "cold" wording whenever `hit_ratio` is missing. So the block
267
+ // is sent only once it can say something pi actually measured.
268
+ const cache = snapshot.cache;
269
+ if (cache && cache.requests >= 2) {
270
+ input.prompt_cache = { requests: cache.requests, hit_ratio: round4(cache.hitRatio) };
271
+ }
272
+
239
273
  // rate_limits has no pi data source; omitting it removes the quota segment.
240
274
  return input;
241
275
  }
@@ -0,0 +1,135 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, test } from "node:test";
6
+
7
+ import { bridgeDir, bridgeFileName, removeTurnBridge, TurnMeter, writeTurnBridge } from "./turn.ts";
8
+
9
+ const createdDirs: string[] = [];
10
+ afterEach(() => {
11
+ for (const dir of createdDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
12
+ });
13
+
14
+ function tempDir(): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "pi-ccline-turn-"));
16
+ createdDirs.push(dir);
17
+ return dir;
18
+ }
19
+
20
+ test("measures streaming speed without counting the wait for the first token", () => {
21
+ const meter = new TurnMeter();
22
+ meter.start("claude-sonnet-4-5", 1_000);
23
+ meter.firstDelta(1_800);
24
+ assert.deepEqual(meter.finish(500, 6_800), {
25
+ model: "claude-sonnet-4-5",
26
+ ttftMs: 800,
27
+ tps: 100,
28
+ durationMs: 5_800,
29
+ outputTokens: 500,
30
+ });
31
+ });
32
+
33
+ test("keeps only the first delta of an answer", () => {
34
+ const meter = new TurnMeter();
35
+ meter.start("m", 0);
36
+ meter.firstDelta(500);
37
+ meter.firstDelta(900);
38
+ meter.firstDelta(1_200);
39
+ // 200 tokens over the 1500ms after the first token, not over 1000ms.
40
+ assert.equal(meter.finish(200, 2_000)?.tps, 200 / 1.5);
41
+ });
42
+
43
+ test("reports nothing without a first delta, output or a measurable span", () => {
44
+ const neverStreamed = new TurnMeter();
45
+ neverStreamed.start("m", 0);
46
+ assert.equal(neverStreamed.finish(100, 5_000), null, "a non-streaming answer has no speed");
47
+
48
+ const empty = new TurnMeter();
49
+ empty.start("m", 0);
50
+ empty.firstDelta(100);
51
+ assert.equal(empty.finish(0, 5_000), null, "no output tokens means no rate");
52
+
53
+ const tooShort = new TurnMeter();
54
+ tooShort.start("m", 0);
55
+ tooShort.firstDelta(100);
56
+ assert.equal(tooShort.finish(5, 150), null, "a sub-100ms span is jitter, not speed");
57
+
58
+ const unmeasured = new TurnMeter();
59
+ unmeasured.firstDelta(100);
60
+ assert.equal(unmeasured.finish(100, 5_000), null, "finish before start is ignored");
61
+ });
62
+
63
+ test("a finished sample does not leak into the next request", () => {
64
+ const meter = new TurnMeter();
65
+ meter.start("first", 0);
66
+ meter.firstDelta(100);
67
+ meter.finish(10, 1_100);
68
+
69
+ meter.start("second", 2_000);
70
+ meter.firstDelta(2_400);
71
+ const second = meter.finish(80, 3_400);
72
+ assert.equal(second?.model, "second");
73
+ assert.equal(second?.ttftMs, 400);
74
+ assert.equal(second?.tps, 80);
75
+ });
76
+
77
+ test("writes the bridge shape ccline reads", () => {
78
+ const dir = tempDir();
79
+ assert.equal(
80
+ writeTurnBridge({
81
+ sessionId: "sess-1",
82
+ timing: { model: "m", ttftMs: 820, tps: 74.234, durationMs: 41_000, outputTokens: 1_400 },
83
+ nowMs: 1_790_808_780_000,
84
+ dir,
85
+ }),
86
+ true,
87
+ );
88
+ assert.deepEqual(JSON.parse(readFileSync(join(dir, "sess-1.json"), "utf8")), {
89
+ v: 1,
90
+ session: "sess-1",
91
+ updated_at: 1_790_808_780_000,
92
+ turn: { model: "m", ttft_ms: 820, tps: 74.234, duration_ms: 41_000, output_tokens: 1_400 },
93
+ });
94
+ });
95
+
96
+ test("omits timing fields it did not measure", () => {
97
+ const dir = tempDir();
98
+ writeTurnBridge({ sessionId: "s2", timing: { tps: 12, durationMs: 1_000, outputTokens: 12 }, nowMs: 1, dir });
99
+ const bridge = JSON.parse(readFileSync(join(dir, "s2.json"), "utf8"));
100
+ assert.deepEqual(Object.keys(bridge.turn).sort(), ["duration_ms", "output_tokens", "tps"]);
101
+ });
102
+
103
+ test("refuses session ids that are not plain file names", () => {
104
+ const dir = tempDir();
105
+ assert.equal(bridgeFileName("../escape"), null);
106
+ assert.equal(bridgeFileName("a/b"), null);
107
+ assert.equal(bridgeFileName(""), null);
108
+ assert.equal(writeTurnBridge({ sessionId: "../escape", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
109
+ assert.deepEqual(readdirSync(dir), [], "nothing escaped or landed in the bridge dir");
110
+ });
111
+
112
+ test("a failed write leaves no temp file behind", () => {
113
+ const dir = tempDir();
114
+ // A directory in the target's place makes the rename fail after the temp
115
+ // file was already written.
116
+ mkdirSync(join(dir, "blocked.json"));
117
+ assert.equal(writeTurnBridge({ sessionId: "blocked", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
118
+ assert.deepEqual(readdirSync(dir), ["blocked.json"]);
119
+ });
120
+
121
+ test("removes a session bridge and tolerates a missing one", () => {
122
+ const dir = tempDir();
123
+ writeTurnBridge({ sessionId: "s3", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir });
124
+ assert.equal(existsSync(join(dir, "s3.json")), true);
125
+ removeTurnBridge("s3", dir);
126
+ assert.equal(existsSync(join(dir, "s3.json")), false);
127
+ removeTurnBridge("s3", dir);
128
+ removeTurnBridge("../escape", dir);
129
+ });
130
+
131
+ test("follows ccline's own data directory resolution", () => {
132
+ assert.equal(bridgeDir({ CCLINE_HOME: "/tmp/ccline-home" }, "/home/u"), "/tmp/ccline-home/bridge");
133
+ assert.equal(bridgeDir({ CCLINE_HOME: " " }, "/home/u"), "/home/u/.claude/ccline/bridge");
134
+ assert.equal(bridgeDir({}, "/home/u"), "/home/u/.claude/ccline/bridge");
135
+ });
@@ -0,0 +1,148 @@
1
+ // Measures the main loop's last model request and hands it to ccline through
2
+ // the bridge file its companion mod normally writes.
3
+ //
4
+ // ccline's `timing.ttft` / `timing.tps` metrics read `bridge/<session>.json`
5
+ // under ccline's data dir, not the statusline JSON: per-request timing is the
6
+ // one thing the Claude Code payload leaves out. pi has the same gap, so the
7
+ // numbers are measured here from streaming events and written in the same
8
+ // documented v1 shape (the file is rewritten on every finished request, which
9
+ // also keeps it inside ccline's 6h freshness window).
10
+
11
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ /** One finished model request, in the units ccline's bridge expects. */
16
+ export interface TurnTiming {
17
+ model?: string | undefined;
18
+ /** Stream start to the first text, thinking or tool-call delta. */
19
+ ttftMs?: number | undefined;
20
+ /** Output tokens per second while the answer streamed (ttft excluded). */
21
+ tps: number;
22
+ durationMs: number;
23
+ outputTokens: number;
24
+ }
25
+
26
+ /** Below this the sample says more about jitter than about speed. */
27
+ const MIN_STREAM_MS = 100;
28
+
29
+ /**
30
+ * ccline resolves its data dir from `CCLINE_HOME`, falling back to
31
+ * `~/.claude/ccline`; the bridge lives in a `bridge` subdirectory of either.
32
+ */
33
+ export function bridgeDir(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
34
+ const override = env.CCLINE_HOME?.trim();
35
+ return join(override ? override : join(home, ".claude", "ccline"), "bridge");
36
+ }
37
+
38
+ /**
39
+ * ccline's own name check rejects anything that could climb out of the bridge
40
+ * directory; pi session ids are UUIDs, so a strict allowlist costs nothing.
41
+ */
42
+ export function bridgeFileName(sessionId: string): string | null {
43
+ return /^[A-Za-z0-9._-]+$/.test(sessionId) ? `${sessionId}.json` : null;
44
+ }
45
+
46
+ export interface TurnBridgeOptions {
47
+ sessionId: string;
48
+ timing: TurnTiming;
49
+ /** Unix epoch milliseconds of the write; ccline drops bridges older than 6h. */
50
+ nowMs: number;
51
+ dir?: string;
52
+ }
53
+
54
+ /** Write the bridge for one session. Returns false instead of throwing. */
55
+ export function writeTurnBridge(options: TurnBridgeOptions): boolean {
56
+ const name = bridgeFileName(options.sessionId);
57
+ if (!name) return false;
58
+ const dir = options.dir ?? bridgeDir();
59
+ const path = join(dir, name);
60
+ const { model, ttftMs, tps, durationMs, outputTokens } = options.timing;
61
+ const turn: Record<string, unknown> = {
62
+ tps,
63
+ duration_ms: Math.round(durationMs),
64
+ output_tokens: Math.round(outputTokens),
65
+ };
66
+ if (model) turn.model = model;
67
+ if (ttftMs !== undefined) turn.ttft_ms = Math.round(ttftMs);
68
+ const payload = {
69
+ v: 1,
70
+ session: options.sessionId,
71
+ updated_at: Math.round(options.nowMs),
72
+ turn,
73
+ };
74
+
75
+ try {
76
+ mkdirSync(dir, { recursive: true });
77
+ // ccline tolerates a half-written bridge by retrying once, but an atomic
78
+ // rename means it never has to.
79
+ const temp = `${path}.${process.pid}.tmp`;
80
+ writeFileSync(temp, `${JSON.stringify(payload)}\n`, "utf8");
81
+ renameSync(temp, path);
82
+ return true;
83
+ } catch {
84
+ // A failed write must not leave a temp file behind next to the bridges
85
+ // ccline scans (and must never throw into the session).
86
+ try {
87
+ rmSync(`${path}.${process.pid}.tmp`, { force: true });
88
+ } catch {
89
+ // nothing left to do; ccline prunes the directory itself
90
+ }
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /** Drop a session's bridge, so a closed session leaves no stale speed behind. */
96
+ export function removeTurnBridge(sessionId: string, dir: string = bridgeDir()): void {
97
+ const name = bridgeFileName(sessionId);
98
+ if (!name) return;
99
+ try {
100
+ rmSync(join(dir, name), { force: true });
101
+ } catch {
102
+ // A bridge that cannot be removed is pruned by ccline after a week.
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Times one request from three streaming events: `message_start` (the answer
108
+ * began), the first `message_update` (first token) and `message_end`.
109
+ * A request without a first delta (a non-streaming provider) yields no speed.
110
+ */
111
+ export class TurnMeter {
112
+ private startedAt: number | null = null;
113
+ private firstDeltaAt: number | null = null;
114
+ private model: string | undefined;
115
+
116
+ start(model: string | undefined, nowMs: number): void {
117
+ this.startedAt = nowMs;
118
+ this.firstDeltaAt = null;
119
+ this.model = model;
120
+ }
121
+
122
+ firstDelta(nowMs: number): void {
123
+ if (this.startedAt === null || this.firstDeltaAt !== null) return;
124
+ this.firstDeltaAt = nowMs;
125
+ }
126
+
127
+ /** The answer finished; null when the sample cannot describe a speed. */
128
+ finish(outputTokens: unknown, nowMs: number): TurnTiming | null {
129
+ const startedAt = this.startedAt;
130
+ const firstDeltaAt = this.firstDeltaAt;
131
+ const model = this.model;
132
+ this.startedAt = null;
133
+ this.firstDeltaAt = null;
134
+ this.model = undefined;
135
+ if (startedAt === null || firstDeltaAt === null) return null;
136
+
137
+ const output = typeof outputTokens === "number" && Number.isFinite(outputTokens) ? outputTokens : 0;
138
+ const streamMs = nowMs - firstDeltaAt;
139
+ if (output <= 0 || streamMs < MIN_STREAM_MS) return null;
140
+
141
+ const tps = output / (streamMs / 1000);
142
+ if (!Number.isFinite(tps) || tps <= 0) return null;
143
+
144
+ const ttftMs = Math.max(0, firstDeltaAt - startedAt);
145
+ return { model, tps, durationMs: Math.max(0, nowMs - startedAt), outputTokens: output, ttftMs };
146
+ }
147
+ }
148
+
@@ -1,7 +1,7 @@
1
1
  {
2
- "cwd": "/Users/caph/Workspace/pi-packages/packages/pi-ccline",
2
+ "cwd": "/Users/caph/Workspace/pi-packages/pi-ccline",
3
3
  "workspace": {
4
- "current_dir": "/Users/caph/Workspace/pi-packages/packages/pi-ccline"
4
+ "current_dir": "/Users/caph/Workspace/pi-packages/pi-ccline"
5
5
  },
6
6
  "model": {
7
7
  "id": "gpt-5.6-codex",
@@ -18,9 +18,13 @@
18
18
  "used_percentage": 15.4
19
19
  },
20
20
  "cost": {
21
- "total_cost_usd": 1.2345,
22
- "total_duration_ms": 1830000
21
+ "total_cost_usd": 0.54,
22
+ "total_duration_ms": 1800000
23
23
  },
24
- "session_id": "4f8f2c1a-0000-4000-8000-000000000000",
25
- "session_name": "pi-ccline demo"
26
- }
24
+ "session_id": "sess-demo",
25
+ "session_name": "pi-ccline demo",
26
+ "prompt_cache": {
27
+ "requests": 2,
28
+ "hit_ratio": 0.9863
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caph42/pi-ccline",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension that renders the footer by delegating to the CCometixLine (ccline) statusline binary",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,7 +28,8 @@
28
28
  "extensions",
29
29
  "scripts",
30
30
  "fixtures",
31
- "README.md"
31
+ "README.md",
32
+ "README_zh.md"
32
33
  ],
33
34
  "pi": {
34
35
  "extensions": [