@caph42/pi-ccline 0.2.0 → 0.2.2
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 +8 -3
- package/README_zh.md +8 -3
- package/extensions/pi-ccline/config.test.ts +50 -1
- package/extensions/pi-ccline/config.ts +84 -0
- package/extensions/pi-ccline/footer.test.ts +76 -1
- package/extensions/pi-ccline/index.ts +81 -9
- package/extensions/pi-ccline/render.test.ts +57 -0
- package/extensions/pi-ccline/render.ts +38 -2
- package/extensions/pi-ccline/turn.test.ts +32 -6
- package/extensions/pi-ccline/turn.ts +37 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ pi session state → buildCclineInput() → JSON → ccline (spawn, COLUMNS=curr
|
|
|
20
20
|
| Context percentage / token count | `ctx.getContextUsage()` → current context (the aggregate estimate); **no lifetime session tokens are sent** |
|
|
21
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
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
|
+
| Running tools | `tool_execution_start` / `tool_execution_end` → the same bridge file's `tools` array, for ccline's `activity.tools` metric |
|
|
23
24
|
| Cost, session duration | assistant messages' `usage.cost.total` summed; duration = now − earliest entry on the branch |
|
|
24
25
|
| Session id / name | `ctx.sessionManager` |
|
|
25
26
|
| git branch and anything else ccline probes itself | ccline reads the disk itself |
|
|
@@ -76,7 +77,8 @@ After startup the footer is whatever ccline renders. Your terminal needs Nerd Fo
|
|
|
76
77
|
"refreshThrottleMs": 1000,
|
|
77
78
|
"liveRefreshMs": 1000,
|
|
78
79
|
"showProvider": true,
|
|
79
|
-
"env": {}
|
|
80
|
+
"env": {},
|
|
81
|
+
"statusBadges": {}
|
|
80
82
|
}
|
|
81
83
|
```
|
|
82
84
|
|
|
@@ -89,6 +91,7 @@ After startup the footer is whatever ccline renders. Your terminal needs Nerd Fo
|
|
|
89
91
|
| `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
92
|
| `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
93
|
| `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) |
|
|
94
|
+
| `statusBadges` | `{}` | Icons and colors for the status row other extensions set through `ctx.ui.setStatus`, shaped `{"<status key>": {"icon": "<glyph>", "color": "<theme token>"}}`. Built in: `pi-herdr-pstack` (its `poteto-mode` status) → the Nerd Font rocket `U+F135` in `accent`. Fields merge over the built-in entry, so `{"pi-herdr-pstack": {"icon": ""}}` drops the icon and keeps the color; `""` for both removes the badge. Status keys are matched verbatim against `setStatus` keys. A status whose text already carries ANSI colors is left untouched, and an unknown theme token falls back to uncolored text |
|
|
92
95
|
|
|
93
96
|
Binary resolution order: `config.binary` → `~/.claude/ccline/ccline` → `ccline` on `PATH`.
|
|
94
97
|
|
|
@@ -101,11 +104,12 @@ Binary resolution order: `config.binary` → `~/.claude/ccline/ccline` → `ccli
|
|
|
101
104
|
- Binary not found: the footer shows a one-line install hint.
|
|
102
105
|
- Widths are always computed with `visibleWidth` / `truncateToWidth`, never with `.length`.
|
|
103
106
|
- **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.
|
|
107
|
+
- **A status key can carry a badge**: `setStatus` hands over text, and whether it is styled depends on the extension that sends it (pi's own footer adds nothing). This footer therefore decorates only statuses that arrive unstyled — a Nerd Font icon and a theme color, per status key (`DEFAULT_STATUS_BADGES` in `config.ts` holds the built-in entry for pi-herdr-pstack's `poteto-mode`). The icon is counted with the rest of the row before truncating, a status whose text already contains ANSI is skipped to avoid mixing two palettes, and the color goes through one `theme.fg()` call per badge that degrades to plain text when the token is not in the theme.
|
|
104
108
|
- 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
109
|
- **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
110
|
- 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
111
|
- **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
|
|
112
|
+
- **Output speed and running tools go through ccline's bridge, not stdin.** ccline's `timing.tps` / `timing.ttft` and `activity.tools` 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 and no running-tool list, so this extension measures the first and tracks the second, and writes the same documented `v: 1` shape, atomically. Because both live in one file, every write carries everything known: the last finished answer's timing plus the tools running now, so a tool starting or ending never blanks `tok/s` and a new answer never leaves a finished tool behind. 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. `tools` skips calls carrying `parentToolCallId` (codemode scripts and tools calling tools), so the segment lists what the user actually issued. The file is removed on `session_shutdown`, and ccline ignores bridges older than 6h.
|
|
109
113
|
- No runtime dependencies: only `node:child_process` / `node:fs` / `node:os` / `node:path` plus the pi peer package `@earendil-works/pi-tui`.
|
|
110
114
|
- Non-TUI modes (`-p`, json, rpc) install no footer and spawn nothing.
|
|
111
115
|
- The extension's own config lives at `~/.pi/agent/pi-ccline/config.json`; pi's `settings.json` is never touched.
|
|
@@ -136,6 +140,7 @@ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --b
|
|
|
136
140
|
- **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
141
|
- **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
142
|
- **`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.
|
|
143
|
+
- **The tools segment shows only what is running right now.** ccline names the two oldest calls and folds the rest into `+N`, with the elapsed time of the oldest; there is no history, no per-tool duration and no output. Calls made through `parentToolCallId` (codemode scripts, tools invoking tools) are not listed, and a call originating inside a subagent appears only if pi hands that event to the main extension, which today it does not.
|
|
139
144
|
- **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
145
|
- **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
146
|
- **Nerd Font v3+ required.** Missing icon fonts render as boxes; Windows is untested.
|
|
@@ -156,7 +161,7 @@ extensions/pi-ccline/
|
|
|
156
161
|
binary.ts path resolution/caching, spawn with timeout and abort, --version probing
|
|
157
162
|
render.ts output normalization: line splitting, truncation by visible width, stale marker
|
|
158
163
|
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
|
|
164
|
+
turn.ts per-request timing from the stream and the running-tool list, plus the ccline bridge file
|
|
160
165
|
config.ts config read/write
|
|
161
166
|
*.test.ts node --test
|
|
162
167
|
scripts/render-once.ts run the real ccline once against a fixture
|
package/README_zh.md
CHANGED
|
@@ -20,6 +20,7 @@ pi 会话状态 → buildCclineInput() → JSON → ccline(spawn,COLUMNS=当
|
|
|
20
20
|
| 上下文百分比 / token 数 | `ctx.getContextUsage()` → 当前上下文(聚合估计值);**不发会话累计 token** |
|
|
21
21
|
| 缓存命中率 | assistant 消息的 `usage.cacheRead` 除以 `input + cacheRead + cacheWrite`,在分支上累加 → `prompt_cache.hit_ratio`;从第二次请求起才发送 |
|
|
22
22
|
| 输出速度(`tok/s`) | 由 `message_start` → 首个 `message_update` → `message_end` 实测,写入 ccline 的 `bridge/<session>.json` 供 `timing.tps` 读取 |
|
|
23
|
+
| 运行中的工具 | `tool_execution_start` / `tool_execution_end` → 同一个 bridge 文件的 `tools` 数组,供 `activity.tools` 读取 |
|
|
23
24
|
| 成本、会话时长 | assistant 消息 `usage.cost.total` 累加;时长 = 当前时间 − 分支最早 entry |
|
|
24
25
|
| 会话 id / 名称 | `ctx.sessionManager` |
|
|
25
26
|
| git 分支等 ccline 自行探测的信息 | ccline 自己读磁盘 |
|
|
@@ -76,7 +77,8 @@ pi install .
|
|
|
76
77
|
"refreshThrottleMs": 1000,
|
|
77
78
|
"liveRefreshMs": 1000,
|
|
78
79
|
"showProvider": true,
|
|
79
|
-
"env": {}
|
|
80
|
+
"env": {},
|
|
81
|
+
"statusBadges": {}
|
|
80
82
|
}
|
|
81
83
|
```
|
|
82
84
|
|
|
@@ -89,6 +91,7 @@ pi install .
|
|
|
89
91
|
| `liveRefreshMs` | `1000` | agent 运行期间的定时刷新间隔(让 `耗时` 逐秒走);`0` 关闭定时器,只保留事件刷新。非 0 时低于 250 会被抬到 250 |
|
|
90
92
|
| `showProvider` | `true` | 是否在模型段追加 provider,如 `Sonnet 4.5 (anthropic)`;`false` 只显示模型名。终端窄到模型段被 ccline 截断时 provider 可能不可见 |
|
|
91
93
|
| `env` | `{}` | 追加给 ccline 的环境变量;同名变量覆盖 pi 继承的值(`COLUMNS` 除外,它由扩展按当前宽度写入) |
|
|
94
|
+
| `statusBadges` | `{}` | 给其他扩展 `ctx.ui.setStatus` 那一行加图标和颜色,形状 `{"<status key>": {"icon": "<字形>", "color": "<主题 token>"}}`。内置一条:`pi-herdr-pstack`(它的 `poteto-mode` 状态)→ Nerd Font 火箭 `U+F135` + `accent`。字段按项合并到内置条目上,所以 `{"pi-herdr-pstack": {"icon": ""}}` 只去掉图标、保留颜色;两个字段都写 `""` 即去掉徽标。status key 与 `setStatus` 的 key 逐字匹配;文本本身已带 ANSI 颜色的状态原样保留;主题里不存在的 token 回退成不上色 |
|
|
92
95
|
|
|
93
96
|
二进制解析顺序:`config.binary` → `~/.claude/ccline/ccline` → `PATH` 中的 `ccline`。
|
|
94
97
|
|
|
@@ -101,11 +104,12 @@ pi install .
|
|
|
101
104
|
- 找不到二进制:footer 显示一行安装提示。
|
|
102
105
|
- 行宽一律用 `visibleWidth` / `truncateToWidth` 计算,不用 `.length`。
|
|
103
106
|
- **其他扩展的 `ctx.ui.setStatus` 照样可见**:`render(width)` 读 `footerData.getExtensionStatuses()`,按 key 排序、用空格拼成一行、超宽时用 dim `…` 截断,追加在 ccline 帧下方——行为对齐 pi 内置 footer。读的是实时 map,`setStatus` 自己会触发重绘,所以状态变化不受 1Hz 节流限制。
|
|
107
|
+
- **status 可以带徽标**:`setStatus` 交过来的是文本,有没有上色取决于发它的扩展(pi 自带 footer 不再加任何样式)。所以本扩展只装饰「没有自己上色」的状态:按 status key 加 Nerd Font 图标和主题色(内置条目在 `config.ts` 的 `DEFAULT_STATUS_BADGES`,目前是 pi-herdr-pstack 的 `poteto-mode`)。图标计入整行的可见宽度再截断;文本自带 ANSI 的状态跳过装饰,避免两套配色混在一起;颜色每帧走一次 `theme.fg()`,token 不在主题里就退成纯文本。
|
|
104
108
|
- 因为 footer 槽位全局只有一个,本扩展装上后其他扩展的自定义 footer(例如 pi-cursor-provider 的 fast 徽标)会被顶掉;它们的 status 行仍会显示。反过来它们抢占时 ccline 帧会消失,需要 `/pi-ccline on` 取回。
|
|
105
109
|
- **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
110
|
- 输入 JSON **省略 `version` 字段**:ccline 2.0.1 会把它当 Claude Code 版本校验,传 pi 的版本会被直接拒绝。
|
|
107
111
|
- **缓存命中率:** `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
|
-
-
|
|
112
|
+
- **输出速度与运行中的工具走 ccline 的 bridge,不走 stdin。** ccline 的 `timing.tps` / `timing.ttft` 与 `activity.tools` 读的是其数据目录(`$CCLINE_HOME` 或 `~/.claude/ccline`)下的 `bridge/<session>.json`,也就是它家在 Claude Code 里的伴生 mod 写的文件。statusline 载荷里既没有单次请求耗时,也没有运行中工具列表,所以本扩展自己实测、自己跟踪,并按同样的 `v: 1` 结构原子写入。因为两者共用一个文件,每次写入都带全量:最后答完那次请求的计时 + 当前运行中的工具,所以工具起止不会把 `tok/s` 清掉,新回答也不会把已结束的工具留在那里。ttft = 流开始 → 首个内容块;pi 是在 provider 开始响应后才发 `message_start`,所以这个数不含建立连接的等待。tps = 该回答的 output token 除以「首个内容块 → `message_end`」的时长,所以等首字的耗时不算进生成速度。计时用单调时钟(`performance.now()`),只有 bridge 的新鲜度标记用墙钟。`tools` 会跳过带 `parentToolCallId` 的调用(codemode 脚本、工具内部再调工具),所以这段只列用户真正发起的调用。`session_shutdown` 时删除该文件;ccline 也会忽略超过 6 小时的 bridge。
|
|
109
113
|
- 没有运行时依赖,只用 `node:child_process` / `node:fs` / `node:os` / `node:path` 和 pi 提供的 peer 包 `@earendil-works/pi-tui`。
|
|
110
114
|
- 非 TUI 模式(`-p`、json、rpc)不安装 footer、不 spawn。
|
|
111
115
|
- 扩展自己的配置写在 `~/.pi/agent/pi-ccline/config.json`,不碰 pi 的 `settings.json`。
|
|
@@ -136,6 +140,7 @@ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --b
|
|
|
136
140
|
- **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock)。这是上游行为,扩展无法关闭。另外本扩展会写 `~/.claude/ccline/bridge/<session>.json` 供 `timing.tps` 段使用,会话关闭时删掉。
|
|
137
141
|
- **缓存段只显示命中率。** 冷热状态、TTL 倒计时、miss 次数与原因需要 pi 没有的数据(provider 的缓存过期时间、重建原因),这些字段一律不发;配置里用 `format = "{hit}% hit"` 就只剩这一个数字,而第二次请求之前整段直接消失,而不是谎称 `cold`。
|
|
138
142
|
- **`tok/s` 描述的是最后一次答完的请求,不是滚动平均。** 它按请求实测,只在回答结束时更新(非流式 provider、或只有几个 token 的回答不会产生样本,上一轮的数值会继续显示到下一次更新)。`duration` 和 `tok/s` 没有关系:前者是会话墙钟时长,后者是流式速度。
|
|
143
|
+
- **工具段只显示「此刻在跑」的调用。** ccline 只点名最老的两个、其余的折成 `+N`,elapsed 取最老那个的耗时;没有历史、没有单工具耗时、没有输出。带 `parentToolCallId` 的调用(codemode 脚本、工具内部再调工具)不列;子 agent 内部的调用只有 pi 把它交给主扩展时才会出现,目前不会。
|
|
139
144
|
- **依赖外部二进制。** 没有 `ccline` 时 footer 只有一行安装提示;升级 `ccline` 后无需重启扩展,但二进制路径会被缓存到下一次解析(`/pi-ccline on` 或新会话会重算)。
|
|
140
145
|
- **上游兼容性只覆盖已测版本。** ccline 2.0.1 的 `input.rs` 每个字段都是 `Option` + lenient 反序列化,同一大版本内字段换形状一般只会让对应段消失;但大版本之间已经发生过必填字段变化(1.1.x → 2.0.x),升级 ccline 后请用 `scripts/render-once.ts` 复跑一次。
|
|
141
146
|
- **需要 Nerd Font v3+。** 图标字体缺失时会显示成方块;Windows 未测试。
|
|
@@ -156,7 +161,7 @@ extensions/pi-ccline/
|
|
|
156
161
|
binary.ts 路径解析/缓存、带超时与中止的 spawn、--version 探测
|
|
157
162
|
render.ts 输出归一化:分行、按可见宽度截断、stale 标记
|
|
158
163
|
refresh.ts 单飞 + 节流 + latest-wins 调度器,以及驱动 `耗时` 的定时器
|
|
159
|
-
turn.ts
|
|
164
|
+
turn.ts 从流事件实测单次请求耗时、跟踪运行中的工具,并写 ccline 的 bridge 文件
|
|
160
165
|
config.ts 配置读写
|
|
161
166
|
*.test.ts node --test
|
|
162
167
|
scripts/render-once.ts 用 fixture 跑一次真实 ccline
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
|
|
7
|
-
import { DEFAULT_CONFIG, MIN_LIVE_REFRESH_MS, MIN_THROTTLE_MS, configPath, loadConfig, normalizeConfig, saveConfig } from "./config.ts";
|
|
7
|
+
import { DEFAULT_CONFIG, DEFAULT_STATUS_BADGES, MIN_LIVE_REFRESH_MS, MIN_THROTTLE_MS, configPath, loadConfig, normalizeConfig, resolveStatusBadges, saveConfig } from "./config.ts";
|
|
8
8
|
|
|
9
9
|
function tempDir(): string {
|
|
10
10
|
return mkdtempSync(join(tmpdir(), "pi-ccline-config-"));
|
|
@@ -53,9 +53,58 @@ test("keeps valid overrides", () => {
|
|
|
53
53
|
liveRefreshMs: DEFAULT_CONFIG.liveRefreshMs,
|
|
54
54
|
showProvider: DEFAULT_CONFIG.showProvider,
|
|
55
55
|
env: {},
|
|
56
|
+
statusBadges: {},
|
|
56
57
|
});
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
test("ships a built-in poteto-mode badge and lets the config override it field by field", () => {
|
|
61
|
+
assert.deepEqual(DEFAULT_CONFIG.statusBadges, {});
|
|
62
|
+
assert.deepEqual(resolveStatusBadges(undefined), DEFAULT_STATUS_BADGES);
|
|
63
|
+
|
|
64
|
+
// Clearing only the icon keeps the built-in color.
|
|
65
|
+
const noIcon = normalizeConfig({ statusBadges: { "pi-herdr-pstack": { icon: "" } } });
|
|
66
|
+
assert.deepEqual(resolveStatusBadges(noIcon.statusBadges), { "pi-herdr-pstack": { icon: "", color: "accent" } });
|
|
67
|
+
|
|
68
|
+
// A color-only override reuses the built-in icon.
|
|
69
|
+
const recolored = normalizeConfig({ statusBadges: { "pi-herdr-pstack": { color: "warning" } } });
|
|
70
|
+
assert.deepEqual(resolveStatusBadges(recolored.statusBadges), { "pi-herdr-pstack": { icon: DEFAULT_STATUS_BADGES["pi-herdr-pstack"]?.icon, color: "warning" } });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("badges can be added for other status keys, and unusable entries are dropped", () => {
|
|
74
|
+
const config = normalizeConfig({
|
|
75
|
+
statusBadges: {
|
|
76
|
+
"ssh-remote": { color: "success" },
|
|
77
|
+
" ssh-remote ": { color: "error" },
|
|
78
|
+
["x".repeat(65)]: { icon: "y" },
|
|
79
|
+
mcp: { icon: "\n\t" },
|
|
80
|
+
broken: 5,
|
|
81
|
+
"": { icon: "x" },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
// Keys are matched verbatim against `setStatus` keys, so a padded or oversized
|
|
85
|
+
// key is dropped rather than rewritten into something that can only miss.
|
|
86
|
+
assert.deepEqual(config.statusBadges, { "ssh-remote": { color: "success" } });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("prototype-named keys stay own data properties", () => {
|
|
90
|
+
// Only JSON can carry an own `__proto__` key; an object literal would set the prototype.
|
|
91
|
+
const raw = JSON.parse('{"statusBadges":{"constructor":{"icon":"C"},"__proto__":{"color":"error"}}}');
|
|
92
|
+
const config = normalizeConfig(raw);
|
|
93
|
+
const resolved = resolveStatusBadges(config.statusBadges);
|
|
94
|
+
assert.equal(Object.hasOwn(resolved, "constructor"), true);
|
|
95
|
+
assert.equal(Object.hasOwn(resolved, "__proto__"), true);
|
|
96
|
+
assert.equal(Object.getPrototypeOf(resolved), Object.prototype);
|
|
97
|
+
assert.deepEqual(resolved.constructor, { icon: "C", color: "" });
|
|
98
|
+
assert.deepEqual(resolved["__proto__"], { icon: "", color: "error" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a badge icon is cleaned and capped instead of breaking the status row", () => {
|
|
102
|
+
const [icon = ""] = Object.values(normalizeConfig({ statusBadges: { k: { icon: " \u001b[31mabcde " } } }).statusBadges);
|
|
103
|
+
assert.deepEqual(icon, { icon: "abcd" });
|
|
104
|
+
assert.deepEqual(normalizeConfig({ statusBadges: [] }).statusBadges, {});
|
|
105
|
+
assert.deepEqual(normalizeConfig({ statusBadges: "none" }).statusBadges, {});
|
|
106
|
+
});
|
|
107
|
+
|
|
59
108
|
test("showProvider defaults on and ignores non-boolean values", () => {
|
|
60
109
|
assert.equal(DEFAULT_CONFIG.showProvider, true);
|
|
61
110
|
assert.equal(normalizeConfig({}).showProvider, true);
|
|
@@ -20,6 +20,12 @@ export interface PiCclineConfig {
|
|
|
20
20
|
showProvider: boolean;
|
|
21
21
|
/** Extra environment variables passed to the binary. */
|
|
22
22
|
env: Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Per status-key badges for other extensions' `ctx.ui.setStatus` rows, merged
|
|
25
|
+
* over `DEFAULT_STATUS_BADGES` at render time. Only the fields a user set are
|
|
26
|
+
* stored, so a newer built-in default still applies when a field is absent.
|
|
27
|
+
*/
|
|
28
|
+
statusBadges: Record<string, StatusBadgeOverride>;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export const DEFAULT_CONFIG: PiCclineConfig = {
|
|
@@ -30,8 +36,84 @@ export const DEFAULT_CONFIG: PiCclineConfig = {
|
|
|
30
36
|
liveRefreshMs: 1000,
|
|
31
37
|
showProvider: true,
|
|
32
38
|
env: {},
|
|
39
|
+
statusBadges: {},
|
|
33
40
|
};
|
|
34
41
|
|
|
42
|
+
/** A resolved badge: a Nerd Font glyph in front of the text and a theme color. */
|
|
43
|
+
export interface StatusBadge {
|
|
44
|
+
/** Glyph shown before the status text; empty for none. */
|
|
45
|
+
icon: string;
|
|
46
|
+
/** pi theme foreground token (`accent`, `success`, …); empty or unknown for no color. */
|
|
47
|
+
color: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What a user may write per key: either field may be omitted and inherited. */
|
|
51
|
+
export type StatusBadgeOverride = Partial<StatusBadge>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Built-in badges for statuses set by packages this footer knows about. The
|
|
55
|
+
* supplying extension sends text (and may style it itself); this footer adds an
|
|
56
|
+
* icon and a color when it left the text plain. Keys are pi's status keys,
|
|
57
|
+
* matched verbatim, never the package name.
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_STATUS_BADGES: Record<string, StatusBadge> = {
|
|
60
|
+
// pi-herdr-pstack keeps this key while the sticky poteto methodology is on.
|
|
61
|
+
"pi-herdr-pstack": { icon: "\uf135", color: "accent" },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** An icon longer than this is a typo or a whole path, not a glyph. */
|
|
65
|
+
const MAX_BADGE_ICON_CODEPOINTS = 4;
|
|
66
|
+
const MAX_BADGE_KEY_LENGTH = 64;
|
|
67
|
+
|
|
68
|
+
/** A badge is text: drop CSI/OSC escapes, then any remaining control character. */
|
|
69
|
+
const ESCAPE_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
|
|
70
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
71
|
+
|
|
72
|
+
function cleanBadgeText(value: string): string {
|
|
73
|
+
return value.replace(ESCAPE_SEQUENCE, "").replace(CONTROL_CHARACTERS, "").trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeBadges(raw: unknown): Record<string, StatusBadgeOverride> {
|
|
77
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
78
|
+
const badges: [string, StatusBadgeOverride][] = [];
|
|
79
|
+
for (const [key, rawBadge] of Object.entries(raw as Record<string, unknown>)) {
|
|
80
|
+
// Status keys are matched verbatim against `setStatus` keys: a padded or
|
|
81
|
+
// oversized key can only ever miss, so it is dropped instead of rewritten.
|
|
82
|
+
if (!key || key !== key.trim() || key.length > MAX_BADGE_KEY_LENGTH) continue;
|
|
83
|
+
if (!rawBadge || typeof rawBadge !== "object" || Array.isArray(rawBadge)) continue;
|
|
84
|
+
const entry = rawBadge as Record<string, unknown>;
|
|
85
|
+
const badge: StatusBadgeOverride = {};
|
|
86
|
+
if (typeof entry.icon === "string") {
|
|
87
|
+
badge.icon = Array.from(cleanBadgeText(entry.icon)).slice(0, MAX_BADGE_ICON_CODEPOINTS).join("");
|
|
88
|
+
}
|
|
89
|
+
if (typeof entry.color === "string") badge.color = cleanBadgeText(entry.color);
|
|
90
|
+
if (badge.icon === undefined && badge.color === undefined) continue;
|
|
91
|
+
// An entry that ends up empty is how a built-in badge is cleared; for an
|
|
92
|
+
// unknown key it would only add noise.
|
|
93
|
+
const emptied = !badge.icon && !badge.color;
|
|
94
|
+
if (emptied && !Object.hasOwn(DEFAULT_STATUS_BADGES, key)) continue;
|
|
95
|
+
badges.push([key, badge]);
|
|
96
|
+
}
|
|
97
|
+
// fromEntries keeps keys like `__proto__` as own data properties instead of
|
|
98
|
+
// letting the assignment rewrite the prototype.
|
|
99
|
+
return Object.fromEntries(badges);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Built-in badges with the user's per-field overrides applied. */
|
|
103
|
+
export function resolveStatusBadges(
|
|
104
|
+
overrides: Readonly<Record<string, StatusBadgeOverride>> | null | undefined,
|
|
105
|
+
): Record<string, StatusBadge> {
|
|
106
|
+
const resolved = new Map<string, StatusBadge>();
|
|
107
|
+
for (const [key, badge] of Object.entries(DEFAULT_STATUS_BADGES)) resolved.set(key, { ...badge });
|
|
108
|
+
for (const [key, override] of Object.entries(overrides ?? {})) {
|
|
109
|
+
const merged: StatusBadge = { ...(resolved.get(key) ?? { icon: "", color: "" }) };
|
|
110
|
+
if (override.icon !== undefined) merged.icon = override.icon;
|
|
111
|
+
if (override.color !== undefined) merged.color = override.color;
|
|
112
|
+
resolved.set(key, merged);
|
|
113
|
+
}
|
|
114
|
+
return Object.fromEntries(resolved);
|
|
115
|
+
}
|
|
116
|
+
|
|
35
117
|
export const MIN_TIMEOUT_MS = 200;
|
|
36
118
|
export const MAX_TIMEOUT_MS = 60_000;
|
|
37
119
|
/** The footer re-renders often; a faster refresh rate would spawn too many processes. */
|
|
@@ -69,6 +151,7 @@ function clampLiveRefresh(value: unknown, fallback: number): number {
|
|
|
69
151
|
}
|
|
70
152
|
|
|
71
153
|
const binary = typeof source.binary === "string" && source.binary.trim() ? source.binary.trim() : null;
|
|
154
|
+
const statusBadges = normalizeBadges(source.statusBadges);
|
|
72
155
|
|
|
73
156
|
return {
|
|
74
157
|
enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_CONFIG.enabled,
|
|
@@ -78,6 +161,7 @@ const binary = typeof source.binary === "string" && source.binary.trim() ? sourc
|
|
|
78
161
|
liveRefreshMs: clampLiveRefresh(source.liveRefreshMs, DEFAULT_CONFIG.liveRefreshMs),
|
|
79
162
|
showProvider: typeof source.showProvider === "boolean" ? source.showProvider : DEFAULT_CONFIG.showProvider,
|
|
80
163
|
env,
|
|
164
|
+
statusBadges,
|
|
81
165
|
};
|
|
82
166
|
}
|
|
83
167
|
|
|
@@ -7,7 +7,7 @@ import { afterEach, test } from "node:test";
|
|
|
7
7
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
|
|
9
9
|
import { clearBinaryCache } from "./binary.ts";
|
|
10
|
-
import { DEFAULT_CONFIG, type PiCclineConfig } from "./config.ts";
|
|
10
|
+
import { DEFAULT_CONFIG, DEFAULT_STATUS_BADGES, type PiCclineConfig } from "./config.ts";
|
|
11
11
|
import { CclineFooter, default as piCcline } from "./index.ts";
|
|
12
12
|
|
|
13
13
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -198,6 +198,33 @@ test("shows extension statuses even before ccline has produced a frame", () => {
|
|
|
198
198
|
harness.footer.dispose();
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
test("the built-in poteto-mode badge decorates the pi-herdr-pstack status", () => {
|
|
202
|
+
// The fake theme wraps everything in dim, so the marker proves the badge color
|
|
203
|
+
// path ran through theme.fg rather than being pasted on unconditionally.
|
|
204
|
+
const statuses = new Map([
|
|
205
|
+
["pi-herdr-pstack", "poteto-mode"],
|
|
206
|
+
["other-plugin", "Other status"],
|
|
207
|
+
]);
|
|
208
|
+
const harness = attach({ binary: fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'") }, undefined, statuses);
|
|
209
|
+
const [statusRow] = harness.render(80).slice(1);
|
|
210
|
+
assert.equal(statusRow?.replace(/\u001b\[[0-9;]*m/g, ""), `Other status ${DEFAULT_STATUS_BADGES["pi-herdr-pstack"]?.icon} poteto-mode`);
|
|
211
|
+
assert.match(statusRow ?? "", /\u001b\[2m/, "the badge is colored through the theme");
|
|
212
|
+
harness.footer.dispose();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a configured badge override reaches the rendered row", () => {
|
|
216
|
+
const statuses = new Map([["pi-herdr-pstack", "poteto-mode"]]);
|
|
217
|
+
const harness = attach(
|
|
218
|
+
{ binary: fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'"), statusBadges: { "pi-herdr-pstack": { icon: "" } } },
|
|
219
|
+
undefined,
|
|
220
|
+
statuses,
|
|
221
|
+
);
|
|
222
|
+
const row = harness.render(80)[1] ?? "";
|
|
223
|
+
assert.equal(row.replace(/\u001b\[[0-9;]*m/g, ""), "poteto-mode");
|
|
224
|
+
assert.match(row, /\u001b\[2m/, "clearing the icon keeps the configured color");
|
|
225
|
+
harness.footer.dispose();
|
|
226
|
+
});
|
|
227
|
+
|
|
201
228
|
test("live ticking advances time-based segments and stops with the agent run", async () => {
|
|
202
229
|
const counter = join(tempDir("pi-ccline-live-"), "count");
|
|
203
230
|
const binary = fakeBinary(
|
|
@@ -421,6 +448,54 @@ test("writes ccline's timing bridge from the streamed answer", async () => {
|
|
|
421
448
|
}
|
|
422
449
|
});
|
|
423
450
|
|
|
451
|
+
test("publishes running tools in the same bridge as the timing", async () => {
|
|
452
|
+
const cclineHome = tempDir("pi-ccline-bridge-tools-");
|
|
453
|
+
const previousHome = process.env.CCLINE_HOME;
|
|
454
|
+
process.env.CCLINE_HOME = cclineHome;
|
|
455
|
+
try {
|
|
456
|
+
const binary = fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'");
|
|
457
|
+
const { pi, dispatch } = fakePi();
|
|
458
|
+
piCcline(pi as never, { loadConfig: () => ({ ...DEFAULT_CONFIG, binary }) });
|
|
459
|
+
const { ctx } = fakeSession({ idle: true, binary });
|
|
460
|
+
await dispatch("session_start", ctx);
|
|
461
|
+
|
|
462
|
+
// One finished answer, so the bridge already carries a turn.
|
|
463
|
+
const assistant = { role: "assistant", model: "m", usage: { output: 400 } };
|
|
464
|
+
await dispatch("message_start", ctx, { message: assistant });
|
|
465
|
+
await sleep(120);
|
|
466
|
+
await dispatch("message_update", ctx, { message: assistant });
|
|
467
|
+
await sleep(220);
|
|
468
|
+
await dispatch("message_end", ctx, { message: assistant });
|
|
469
|
+
|
|
470
|
+
const read = () => JSON.parse(readFileSync(join(cclineHome, "bridge", "sess-1.json"), "utf8"));
|
|
471
|
+
await dispatch("tool_execution_start", ctx, { toolCallId: "t1", toolName: "Bash" });
|
|
472
|
+
const running = read();
|
|
473
|
+
assert.deepEqual(running.tools.map((tool: { name: string }) => tool.name), ["Bash"]);
|
|
474
|
+
assert.ok(running.tools[0].since > 0);
|
|
475
|
+
assert.ok(running.turn.tps > 0, "a tools update must not blank the timing segment");
|
|
476
|
+
|
|
477
|
+
// A nested call from another tool is not a tool the user issued.
|
|
478
|
+
await dispatch("tool_execution_start", ctx, { toolCallId: "t2", toolName: "Read", parentToolCallId: "t1" });
|
|
479
|
+
assert.equal(read().tools.length, 1);
|
|
480
|
+
|
|
481
|
+
await dispatch("tool_execution_end", ctx, { toolCallId: "t1" });
|
|
482
|
+
const finished = read();
|
|
483
|
+
assert.equal("tools" in finished, false, "a finished tool leaves no row behind");
|
|
484
|
+
assert.ok(finished.turn.tps > 0);
|
|
485
|
+
|
|
486
|
+
// An aborted run may never send the end event; the run boundary clears it.
|
|
487
|
+
await dispatch("tool_execution_start", ctx, { toolCallId: "t3", toolName: "Grep" });
|
|
488
|
+
assert.equal(read().tools.length, 1);
|
|
489
|
+
await dispatch("agent_end", ctx);
|
|
490
|
+
assert.equal("tools" in read(), false, "no tool survives the run that owned it");
|
|
491
|
+
|
|
492
|
+
await dispatch("session_shutdown", ctx);
|
|
493
|
+
} finally {
|
|
494
|
+
if (previousHome === undefined) delete process.env.CCLINE_HOME;
|
|
495
|
+
else process.env.CCLINE_HOME = previousHome;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
424
499
|
test("ignores message events that are not the assistant's answer", async () => {
|
|
425
500
|
const cclineHome = tempDir("pi-ccline-bridge-role-");
|
|
426
501
|
const previousHome = process.env.CCLINE_HOME;
|
|
@@ -2,11 +2,11 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
4
|
import { clearBinaryCache, probeVersion, resolveBinary, spawnCcline, type ResolvedBinary } from "./binary.ts";
|
|
5
|
-
import { loadConfig, saveConfig, type PiCclineConfig } from "./config.ts";
|
|
5
|
+
import { loadConfig, resolveStatusBadges, saveConfig, type PiCclineConfig, type StatusBadge } 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,
|
|
9
|
+
import { removeTurnBridge, TurnMeter, writeBridge, type BridgeTool, type TurnTiming } from "./turn.ts";
|
|
10
10
|
|
|
11
11
|
/** Structural subsets so the footer does not depend on pi-tui internals. */
|
|
12
12
|
type TuiLike = { requestRender(force?: boolean): void };
|
|
@@ -22,6 +22,14 @@ type MessageEventLike = {
|
|
|
22
22
|
message?: { role?: string | undefined; model?: string | undefined; usage?: { output?: number | undefined } | undefined } | undefined;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/** The slice of a pi tool event this extension reads. */
|
|
26
|
+
type ToolEventLike = {
|
|
27
|
+
toolCallId?: string | undefined;
|
|
28
|
+
toolName?: string | undefined;
|
|
29
|
+
/** Set when another tool (a codemode script, an agent) made the call. */
|
|
30
|
+
parentToolCallId?: string | undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
25
33
|
const DEFAULT_WIDTH = 120;
|
|
26
34
|
|
|
27
35
|
/**
|
|
@@ -49,6 +57,8 @@ function firstLine(text: string): string {
|
|
|
49
57
|
export class CclineFooter {
|
|
50
58
|
private readonly ctx: ExtensionContext;
|
|
51
59
|
private readonly config: PiCclineConfig;
|
|
60
|
+
/** Built-in badges with the user's overrides already merged, resolved once. */
|
|
61
|
+
private readonly badges: Record<string, StatusBadge>;
|
|
52
62
|
private readonly scheduler: RefreshScheduler;
|
|
53
63
|
/** Only runs while the agent is active so an idle session spawns nothing. */
|
|
54
64
|
private readonly ticker: RefreshTicker;
|
|
@@ -65,6 +75,10 @@ export class CclineFooter {
|
|
|
65
75
|
private branchUnsubscribe: (() => void) | null = null;
|
|
66
76
|
/** Session whose ccline bridge this footer wrote, removed again on dispose. */
|
|
67
77
|
private bridgeSessionId: string | null = null;
|
|
78
|
+
/** The last finished answer, re-sent with every tools update (one file). */
|
|
79
|
+
private lastTiming: TurnTiming | null = null;
|
|
80
|
+
/** Tool calls running now, keyed by pi's tool call id. */
|
|
81
|
+
private readonly runningTools = new Map<string, BridgeTool>();
|
|
68
82
|
private readonly meter = new TurnMeter();
|
|
69
83
|
private disposed = false;
|
|
70
84
|
|
|
@@ -79,6 +93,7 @@ export class CclineFooter {
|
|
|
79
93
|
constructor(ctx: ExtensionContext, config: PiCclineConfig) {
|
|
80
94
|
this.ctx = ctx;
|
|
81
95
|
this.config = config;
|
|
96
|
+
this.badges = resolveStatusBadges(config.statusBadges);
|
|
82
97
|
this.scheduler = new RefreshScheduler({
|
|
83
98
|
throttleMs: config.refreshThrottleMs,
|
|
84
99
|
task: () => this.refresh(),
|
|
@@ -175,6 +190,21 @@ export class CclineFooter {
|
|
|
175
190
|
this.meter.firstDelta(performance.now());
|
|
176
191
|
}
|
|
177
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Rewrite the bridge with everything it has to carry: the last answer's
|
|
195
|
+
* timing and the tools running now (ccline reads both from one file, so a
|
|
196
|
+
* writer that sent only one of them would blank the other segment).
|
|
197
|
+
*/
|
|
198
|
+
private flushBridge(): void {
|
|
199
|
+
const sessionId = this.sessionId();
|
|
200
|
+
if (!sessionId) return;
|
|
201
|
+
const tools = [...this.runningTools.values()];
|
|
202
|
+
if (!this.lastTiming && tools.length === 0) return;
|
|
203
|
+
if (!writeBridge({ sessionId, timing: this.lastTiming ?? undefined, tools, nowMs: Date.now() })) return;
|
|
204
|
+
if (this.bridgeSessionId && this.bridgeSessionId !== sessionId) removeTurnBridge(this.bridgeSessionId);
|
|
205
|
+
this.bridgeSessionId = sessionId;
|
|
206
|
+
}
|
|
207
|
+
|
|
178
208
|
/**
|
|
179
209
|
* The answer finished: hand ccline its timing through the bridge file it
|
|
180
210
|
* reads for `timing.tps`. No speed, no file.
|
|
@@ -186,16 +216,45 @@ export class CclineFooter {
|
|
|
186
216
|
// stamp is wall-clock, and the two are never mixed.
|
|
187
217
|
const timing = this.meter.finish(message.usage?.output, performance.now());
|
|
188
218
|
if (!timing) return;
|
|
189
|
-
|
|
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;
|
|
219
|
+
this.lastTiming = timing;
|
|
194
220
|
this.stats.lastTps = timing.tps;
|
|
221
|
+
this.flushBridge();
|
|
195
222
|
// The new speed only shows up in the next frame; let the throttle coalesce it.
|
|
196
223
|
this.requestRefresh();
|
|
197
224
|
}
|
|
198
225
|
|
|
226
|
+
/**
|
|
227
|
+
* A tool started: `activity.tools` shows it until `onToolEnd`. Nested calls
|
|
228
|
+
* (codemode, tools calling tools) are skipped — a script would otherwise
|
|
229
|
+
* flood the segment with names the user never issued.
|
|
230
|
+
*/
|
|
231
|
+
onToolStart(event: ToolEventLike): void {
|
|
232
|
+
const id = event.toolCallId;
|
|
233
|
+
const name = event.toolName;
|
|
234
|
+
if (!id || !name || event.parentToolCallId) return;
|
|
235
|
+
this.runningTools.set(id, { name, since: Date.now() });
|
|
236
|
+
this.flushBridge();
|
|
237
|
+
this.requestRefresh();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
onToolEnd(event: ToolEventLike): void {
|
|
241
|
+
const id = event.toolCallId;
|
|
242
|
+
if (!id || !this.runningTools.delete(id)) return;
|
|
243
|
+
this.flushBridge();
|
|
244
|
+
this.requestRefresh();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* An aborted run can end without a matching `tool_execution_end`, and no
|
|
249
|
+
* tool can outlive the run, so drop the rest rather than show a tool that
|
|
250
|
+
* keeps counting up forever.
|
|
251
|
+
*/
|
|
252
|
+
clearRunningTools(): void {
|
|
253
|
+
if (this.runningTools.size === 0) return;
|
|
254
|
+
this.runningTools.clear();
|
|
255
|
+
this.flushBridge();
|
|
256
|
+
}
|
|
257
|
+
|
|
199
258
|
get hasFrame(): boolean {
|
|
200
259
|
return this.frame !== null;
|
|
201
260
|
}
|
|
@@ -227,7 +286,11 @@ export class CclineFooter {
|
|
|
227
286
|
return null;
|
|
228
287
|
}
|
|
229
288
|
})();
|
|
230
|
-
const status = extensionStatusLine(statuses, safeWidth, this.theme ? this.theme.fg("dim", "…") : "…"
|
|
289
|
+
const status = extensionStatusLine(statuses, safeWidth, this.theme ? this.theme.fg("dim", "…") : "…", {
|
|
290
|
+
badges: this.badges,
|
|
291
|
+
// render.ts catches a throwing token, so a stale theme entry costs only its color.
|
|
292
|
+
colorize: this.theme ? (color, text) => this.theme?.fg(color, text) ?? text : null,
|
|
293
|
+
});
|
|
231
294
|
return status ? [...lines, status] : lines;
|
|
232
295
|
}
|
|
233
296
|
|
|
@@ -313,6 +376,7 @@ export class CclineFooter {
|
|
|
313
376
|
this.branchUnsubscribe = null;
|
|
314
377
|
this.footerData = null;
|
|
315
378
|
this.requestRenderFn = null;
|
|
379
|
+
this.runningTools.clear();
|
|
316
380
|
if (this.bridgeSessionId) removeTurnBridge(this.bridgeSessionId);
|
|
317
381
|
this.bridgeSessionId = null;
|
|
318
382
|
}
|
|
@@ -364,8 +428,13 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
|
|
|
364
428
|
pi.on("agent_end", () => {
|
|
365
429
|
requestRefresh();
|
|
366
430
|
footer?.stopLive();
|
|
431
|
+
// No tool can survive the run, even when an abort skipped its end event.
|
|
432
|
+
footer?.clearRunningTools();
|
|
433
|
+
});
|
|
434
|
+
pi.on("agent_settled", () => {
|
|
435
|
+
footer?.stopLive();
|
|
436
|
+
footer?.clearRunningTools();
|
|
367
437
|
});
|
|
368
|
-
pi.on("agent_settled", () => footer?.stopLive());
|
|
369
438
|
pi.on("model_select", () => requestRefresh());
|
|
370
439
|
pi.on("thinking_level_select", () => requestRefresh());
|
|
371
440
|
pi.on("session_info_changed", () => requestRefresh());
|
|
@@ -374,6 +443,9 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
|
|
|
374
443
|
pi.on("message_start", (event) => footer?.onMessageStart(event as MessageEventLike));
|
|
375
444
|
pi.on("message_update", (event) => footer?.onMessageUpdate(event as MessageEventLike));
|
|
376
445
|
pi.on("message_end", (event) => footer?.onMessageEnd(event as MessageEventLike));
|
|
446
|
+
// Same bridge: `activity.tools` lists what is still running.
|
|
447
|
+
pi.on("tool_execution_start", (event) => footer?.onToolStart(event as ToolEventLike));
|
|
448
|
+
pi.on("tool_execution_end", (event) => footer?.onToolEnd(event as ToolEventLike));
|
|
377
449
|
pi.on("session_shutdown", () => teardown());
|
|
378
450
|
|
|
379
451
|
pi.registerCommand("pi-ccline", {
|
|
@@ -89,3 +89,60 @@ test("a long status row is truncated by visible width with the dim ellipsis", ()
|
|
|
89
89
|
assert.ok(visibleWidth(row) <= 10, `visible width was ${visibleWidth(row ?? "")}`);
|
|
90
90
|
assert.ok((row ?? "").replace(/\u001b\[[0-9;]*m/g, "").endsWith("..."));
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
test("a badge adds its icon and color to the matching status key only", () => {
|
|
94
|
+
const statuses = new Map([
|
|
95
|
+
["mcp", "connected"],
|
|
96
|
+
["pi-herdr-pstack", "poteto-mode"],
|
|
97
|
+
]);
|
|
98
|
+
const line = extensionStatusLine(statuses, 80, "…", {
|
|
99
|
+
badges: { "pi-herdr-pstack": { icon: "R", color: "accent" } },
|
|
100
|
+
colorize: (color, text) => `[${color}]${text}`,
|
|
101
|
+
});
|
|
102
|
+
assert.equal(line, "connected [accent]R poteto-mode");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a status that colors itself keeps its own styling", () => {
|
|
106
|
+
const statuses = new Map([["pi-herdr-pstack", `${RED}poteto-mode${RESET}`]]);
|
|
107
|
+
const line = extensionStatusLine(statuses, 80, "…", {
|
|
108
|
+
badges: { "pi-herdr-pstack": { icon: "R", color: "accent" } },
|
|
109
|
+
colorize: (color, text) => `[${color}]${text}`,
|
|
110
|
+
});
|
|
111
|
+
assert.equal(line, `${RED}poteto-mode${RESET}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("an unknown theme token falls back to the plain badge label", () => {
|
|
115
|
+
const line = extensionStatusLine(new Map([["k", "v"]]), 80, "…", {
|
|
116
|
+
badges: { k: { icon: "R", color: "nope" } },
|
|
117
|
+
colorize: () => {
|
|
118
|
+
throw new Error("Unknown theme color: nope");
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
assert.equal(line, "R v");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an icon with no color still shows, and an empty icon adds no gap", () => {
|
|
125
|
+
const badges = { a: { icon: "R" }, b: { icon: "", color: "accent" } };
|
|
126
|
+
const line = extensionStatusLine(new Map([["a", "one"], ["b", "two"]]), 80, "…", {
|
|
127
|
+
badges,
|
|
128
|
+
colorize: (color, text) => `[${color}]${text}`,
|
|
129
|
+
});
|
|
130
|
+
assert.equal(line, "R one [accent]two");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("only own badge entries are applied, never inherited object members", () => {
|
|
134
|
+
const line = extensionStatusLine(new Map([["toString", "v"]]), 80, "…", {
|
|
135
|
+
badges: {},
|
|
136
|
+
colorize: (color, text) => `[${color}]${text}`,
|
|
137
|
+
});
|
|
138
|
+
assert.equal(line, "v");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("the badge counts toward the truncated width", () => {
|
|
142
|
+
const row = extensionStatusLine(new Map([["k", "日".repeat(10)]]), 10, "...", {
|
|
143
|
+
badges: { k: { icon: "R", color: "accent" } },
|
|
144
|
+
colorize: (color, text) => `\u001b[35m${text}\u001b[39m`,
|
|
145
|
+
});
|
|
146
|
+
assert.ok(row !== null);
|
|
147
|
+
assert.ok(visibleWidth(row) <= 10, `visible width was ${visibleWidth(row ?? "")}`);
|
|
148
|
+
});
|
|
@@ -35,6 +35,40 @@ export function sanitizeStatusText(text: string): string {
|
|
|
35
35
|
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** The badge fields this module needs; config.ts owns the validated shape. */
|
|
39
|
+
export type StatusBadgeLike = { icon?: string | undefined; color?: string | undefined };
|
|
40
|
+
|
|
41
|
+
export interface StatusLineOptions {
|
|
42
|
+
/** Badges per status key; a key without an entry stays plain text. */
|
|
43
|
+
badges?: Readonly<Record<string, StatusBadgeLike>> | null | undefined;
|
|
44
|
+
/** Theme colorizer for the badge color token; missing or throwing means plain text. */
|
|
45
|
+
colorize?: ((color: string, text: string) => string) | null | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A status arrives from another extension as text, and that extension may or
|
|
50
|
+
* may not have colored it. A badge adds an icon and a color — but only to a
|
|
51
|
+
* status that did not style itself, so an extension that already uses
|
|
52
|
+
* `theme.fg` keeps its own palette instead of ending up half-colored.
|
|
53
|
+
*/
|
|
54
|
+
function withBadge(key: string, text: string, options: StatusLineOptions): string {
|
|
55
|
+
const badges = options.badges;
|
|
56
|
+
// Own properties only: a status key like `toString` must not resolve to the
|
|
57
|
+
// object's inherited members.
|
|
58
|
+
const badge = badges && Object.hasOwn(badges, key) ? badges[key] : undefined;
|
|
59
|
+
if (!badge) return text;
|
|
60
|
+
if (text.includes("\u001b[")) return text;
|
|
61
|
+
const label = badge.icon ? `${badge.icon} ${text}` : text;
|
|
62
|
+
const colorize = options.colorize;
|
|
63
|
+
if (!badge.color || !colorize) return label;
|
|
64
|
+
try {
|
|
65
|
+
return colorize(badge.color, label);
|
|
66
|
+
} catch {
|
|
67
|
+
// An unknown theme token must not cost the row its text.
|
|
68
|
+
return label;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
/**
|
|
39
73
|
* pi's built-in footer puts every extension status on one extra row, sorted by
|
|
40
74
|
* key and joined by a space. Mirror that so `ctx.ui.setStatus` keeps working
|
|
@@ -44,12 +78,14 @@ export function extensionStatusLine(
|
|
|
44
78
|
statuses: Iterable<[string, string]> | null | undefined,
|
|
45
79
|
width: number,
|
|
46
80
|
ellipsis = "…",
|
|
81
|
+
options: StatusLineOptions = {},
|
|
47
82
|
): string | null {
|
|
48
83
|
if (!statuses) return null;
|
|
49
84
|
const text = [...statuses]
|
|
50
85
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
51
|
-
.map(([, value]) => sanitizeStatusText(value))
|
|
52
|
-
.filter((
|
|
86
|
+
.map(([key, value]) => ({ key, text: sanitizeStatusText(value) }))
|
|
87
|
+
.filter((status) => status.text.length > 0)
|
|
88
|
+
.map((status) => withBadge(status.key, status.text, options))
|
|
53
89
|
.join(" ");
|
|
54
90
|
if (!text) return null;
|
|
55
91
|
const safeWidth = Math.max(1, Math.floor(width));
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { afterEach, test } from "node:test";
|
|
6
6
|
|
|
7
|
-
import { bridgeDir, bridgeFileName, removeTurnBridge, TurnMeter,
|
|
7
|
+
import { bridgeDir, bridgeFileName, removeTurnBridge, TurnMeter, writeBridge } from "./turn.ts";
|
|
8
8
|
|
|
9
9
|
const createdDirs: string[] = [];
|
|
10
10
|
afterEach(() => {
|
|
@@ -77,9 +77,10 @@ test("a finished sample does not leak into the next request", () => {
|
|
|
77
77
|
test("writes the bridge shape ccline reads", () => {
|
|
78
78
|
const dir = tempDir();
|
|
79
79
|
assert.equal(
|
|
80
|
-
|
|
80
|
+
writeBridge({
|
|
81
81
|
sessionId: "sess-1",
|
|
82
82
|
timing: { model: "m", ttftMs: 820, tps: 74.234, durationMs: 41_000, outputTokens: 1_400 },
|
|
83
|
+
tools: [{ name: "Bash", since: 1_790_808_755_000 }],
|
|
83
84
|
nowMs: 1_790_808_780_000,
|
|
84
85
|
dir,
|
|
85
86
|
}),
|
|
@@ -90,12 +91,37 @@ test("writes the bridge shape ccline reads", () => {
|
|
|
90
91
|
session: "sess-1",
|
|
91
92
|
updated_at: 1_790_808_780_000,
|
|
92
93
|
turn: { model: "m", ttft_ms: 820, tps: 74.234, duration_ms: 41_000, output_tokens: 1_400 },
|
|
94
|
+
tools: [{ name: "Bash", since: 1_790_808_755_000 }],
|
|
93
95
|
});
|
|
94
96
|
});
|
|
95
97
|
|
|
98
|
+
test("keeps the turn when tools come and go, and vice versa", () => {
|
|
99
|
+
const dir = tempDir();
|
|
100
|
+
const timing = { tps: 60, durationMs: 2_000, outputTokens: 120 };
|
|
101
|
+
|
|
102
|
+
// A tools-only write must not blank timing.tps, and a timing-only write must
|
|
103
|
+
// not leave a stale tool behind: both live in the same file.
|
|
104
|
+
writeBridge({ sessionId: "s4", timing, nowMs: 1, dir });
|
|
105
|
+
writeBridge({ sessionId: "s4", timing, tools: [{ name: "Read", since: 5 }], nowMs: 2, dir });
|
|
106
|
+
const withTool = JSON.parse(readFileSync(join(dir, "s4.json"), "utf8"));
|
|
107
|
+
assert.equal(withTool.turn.tps, 60);
|
|
108
|
+
assert.deepEqual(withTool.tools, [{ name: "Read", since: 5 }]);
|
|
109
|
+
|
|
110
|
+
writeBridge({ sessionId: "s4", timing, tools: [], nowMs: 3, dir });
|
|
111
|
+
const afterTool = JSON.parse(readFileSync(join(dir, "s4.json"), "utf8"));
|
|
112
|
+
assert.deepEqual(Object.keys(afterTool).sort(), ["session", "turn", "updated_at", "v"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("writes nothing when there is neither timing nor a running tool", () => {
|
|
116
|
+
const dir = tempDir();
|
|
117
|
+
assert.equal(writeBridge({ sessionId: "s5", nowMs: 1, dir }), false);
|
|
118
|
+
assert.equal(writeBridge({ sessionId: "s5", tools: [], nowMs: 1, dir }), false);
|
|
119
|
+
assert.deepEqual(readdirSync(dir), []);
|
|
120
|
+
});
|
|
121
|
+
|
|
96
122
|
test("omits timing fields it did not measure", () => {
|
|
97
123
|
const dir = tempDir();
|
|
98
|
-
|
|
124
|
+
writeBridge({ sessionId: "s2", timing: { tps: 12, durationMs: 1_000, outputTokens: 12 }, nowMs: 1, dir });
|
|
99
125
|
const bridge = JSON.parse(readFileSync(join(dir, "s2.json"), "utf8"));
|
|
100
126
|
assert.deepEqual(Object.keys(bridge.turn).sort(), ["duration_ms", "output_tokens", "tps"]);
|
|
101
127
|
});
|
|
@@ -105,7 +131,7 @@ test("refuses session ids that are not plain file names", () => {
|
|
|
105
131
|
assert.equal(bridgeFileName("../escape"), null);
|
|
106
132
|
assert.equal(bridgeFileName("a/b"), null);
|
|
107
133
|
assert.equal(bridgeFileName(""), null);
|
|
108
|
-
assert.equal(
|
|
134
|
+
assert.equal(writeBridge({ sessionId: "../escape", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
|
|
109
135
|
assert.deepEqual(readdirSync(dir), [], "nothing escaped or landed in the bridge dir");
|
|
110
136
|
});
|
|
111
137
|
|
|
@@ -114,13 +140,13 @@ test("a failed write leaves no temp file behind", () => {
|
|
|
114
140
|
// A directory in the target's place makes the rename fail after the temp
|
|
115
141
|
// file was already written.
|
|
116
142
|
mkdirSync(join(dir, "blocked.json"));
|
|
117
|
-
assert.equal(
|
|
143
|
+
assert.equal(writeBridge({ sessionId: "blocked", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
|
|
118
144
|
assert.deepEqual(readdirSync(dir), ["blocked.json"]);
|
|
119
145
|
});
|
|
120
146
|
|
|
121
147
|
test("removes a session bridge and tolerates a missing one", () => {
|
|
122
148
|
const dir = tempDir();
|
|
123
|
-
|
|
149
|
+
writeBridge({ sessionId: "s3", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir });
|
|
124
150
|
assert.equal(existsSync(join(dir, "s3.json")), true);
|
|
125
151
|
removeTurnBridge("s3", dir);
|
|
126
152
|
assert.equal(existsSync(join(dir, "s3.json")), false);
|
|
@@ -43,35 +43,58 @@ export function bridgeFileName(sessionId: string): string | null {
|
|
|
43
43
|
return /^[A-Za-z0-9._-]+$/.test(sessionId) ? `${sessionId}.json` : null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/** A tool call that is still running, as ccline's `activity.tools` reads it. */
|
|
47
|
+
export interface BridgeTool {
|
|
48
|
+
name: string;
|
|
49
|
+
/** Unix epoch milliseconds it started; ccline derives the elapsed time. */
|
|
50
|
+
since: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BridgeOptions {
|
|
47
54
|
sessionId: string;
|
|
48
|
-
|
|
55
|
+
/** The last finished answer, kept while tools come and go (same file). */
|
|
56
|
+
timing?: TurnTiming | undefined;
|
|
57
|
+
/** Tool calls running right now, oldest first is not required: ccline sorts. */
|
|
58
|
+
tools?: readonly BridgeTool[] | undefined;
|
|
49
59
|
/** Unix epoch milliseconds of the write; ccline drops bridges older than 6h. */
|
|
50
60
|
nowMs: number;
|
|
51
61
|
dir?: string;
|
|
52
62
|
}
|
|
53
63
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Write the bridge for one session. `turn` and `tools` share one file, so every
|
|
66
|
+
* writer passes both: dropping `turn` would make `timing.tps` disappear.
|
|
67
|
+
* Returns false instead of throwing.
|
|
68
|
+
*/
|
|
69
|
+
export function writeBridge(options: BridgeOptions): boolean {
|
|
56
70
|
const name = bridgeFileName(options.sessionId);
|
|
57
71
|
if (!name) return false;
|
|
72
|
+
const tools = options.tools ?? [];
|
|
73
|
+
if (!options.timing && tools.length === 0) return false;
|
|
58
74
|
const dir = options.dir ?? bridgeDir();
|
|
59
75
|
const path = join(dir, name);
|
|
60
|
-
const
|
|
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 = {
|
|
76
|
+
const payload: Record<string, unknown> = {
|
|
69
77
|
v: 1,
|
|
70
78
|
session: options.sessionId,
|
|
71
79
|
updated_at: Math.round(options.nowMs),
|
|
72
|
-
turn,
|
|
73
80
|
};
|
|
74
81
|
|
|
82
|
+
const timing = options.timing;
|
|
83
|
+
if (timing) {
|
|
84
|
+
const { model, ttftMs, tps, durationMs, outputTokens } = timing;
|
|
85
|
+
const turn: Record<string, unknown> = {
|
|
86
|
+
tps,
|
|
87
|
+
duration_ms: Math.round(durationMs),
|
|
88
|
+
output_tokens: Math.round(outputTokens),
|
|
89
|
+
};
|
|
90
|
+
if (model) turn.model = model;
|
|
91
|
+
if (ttftMs !== undefined) turn.ttft_ms = Math.round(ttftMs);
|
|
92
|
+
payload.turn = turn;
|
|
93
|
+
}
|
|
94
|
+
if (tools.length > 0) {
|
|
95
|
+
payload.tools = tools.map((tool) => ({ name: tool.name, since: Math.round(tool.since) }));
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
try {
|
|
76
99
|
mkdirSync(dir, { recursive: true });
|
|
77
100
|
// ccline tolerates a half-written bridge by retrying once, but an atomic
|