@caph42/pi-ccline 0.2.0 → 0.2.1

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
@@ -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 |
@@ -105,7 +106,7 @@ Binary resolution order: `config.binary` → `~/.claude/ccline/ccline` → `ccli
105
106
  - **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
107
  - 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
108
  - **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
+ - **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
110
  - No runtime dependencies: only `node:child_process` / `node:fs` / `node:os` / `node:path` plus the pi peer package `@earendil-works/pi-tui`.
110
111
  - Non-TUI modes (`-p`, json, rpc) install no footer and spawn nothing.
111
112
  - The extension's own config lives at `~/.pi/agent/pi-ccline/config.json`; pi's `settings.json` is never touched.
@@ -136,6 +137,7 @@ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --b
136
137
  - **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
138
  - **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
139
  - **`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.
140
+ - **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
141
  - **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
142
  - **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
143
  - **Nerd Font v3+ required.** Missing icon fonts render as boxes; Windows is untested.
@@ -156,7 +158,7 @@ extensions/pi-ccline/
156
158
  binary.ts path resolution/caching, spawn with timeout and abort, --version probing
157
159
  render.ts output normalization: line splitting, truncation by visible width, stale marker
158
160
  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`
161
+ turn.ts per-request timing from the stream and the running-tool list, plus the ccline bridge file
160
162
  config.ts config read/write
161
163
  *.test.ts node --test
162
164
  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 自己读磁盘 |
@@ -105,7 +106,7 @@ pi install .
105
106
  - **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
107
  - 输入 JSON **省略 `version` 字段**:ccline 2.0.1 会把它当 Claude Code 版本校验,传 pi 的版本会被直接拒绝。
107
108
  - **缓存命中率:** `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
+ - **输出速度与运行中的工具走 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
110
  - 没有运行时依赖,只用 `node:child_process` / `node:fs` / `node:os` / `node:path` 和 pi 提供的 peer 包 `@earendil-works/pi-tui`。
110
111
  - 非 TUI 模式(`-p`、json、rpc)不安装 footer、不 spawn。
111
112
  - 扩展自己的配置写在 `~/.pi/agent/pi-ccline/config.json`,不碰 pi 的 `settings.json`。
@@ -136,6 +137,7 @@ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --b
136
137
  - **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock)。这是上游行为,扩展无法关闭。另外本扩展会写 `~/.claude/ccline/bridge/<session>.json` 供 `timing.tps` 段使用,会话关闭时删掉。
137
138
  - **缓存段只显示命中率。** 冷热状态、TTL 倒计时、miss 次数与原因需要 pi 没有的数据(provider 的缓存过期时间、重建原因),这些字段一律不发;配置里用 `format = "{hit}% hit"` 就只剩这一个数字,而第二次请求之前整段直接消失,而不是谎称 `cold`。
138
139
  - **`tok/s` 描述的是最后一次答完的请求,不是滚动平均。** 它按请求实测,只在回答结束时更新(非流式 provider、或只有几个 token 的回答不会产生样本,上一轮的数值会继续显示到下一次更新)。`duration` 和 `tok/s` 没有关系:前者是会话墙钟时长,后者是流式速度。
140
+ - **工具段只显示「此刻在跑」的调用。** ccline 只点名最老的两个、其余的折成 `+N`,elapsed 取最老那个的耗时;没有历史、没有单工具耗时、没有输出。带 `parentToolCallId` 的调用(codemode 脚本、工具内部再调工具)不列;子 agent 内部的调用只有 pi 把它交给主扩展时才会出现,目前不会。
139
141
  - **依赖外部二进制。** 没有 `ccline` 时 footer 只有一行安装提示;升级 `ccline` 后无需重启扩展,但二进制路径会被缓存到下一次解析(`/pi-ccline on` 或新会话会重算)。
140
142
  - **上游兼容性只覆盖已测版本。** ccline 2.0.1 的 `input.rs` 每个字段都是 `Option` + lenient 反序列化,同一大版本内字段换形状一般只会让对应段消失;但大版本之间已经发生过必填字段变化(1.1.x → 2.0.x),升级 ccline 后请用 `scripts/render-once.ts` 复跑一次。
141
143
  - **需要 Nerd Font v3+。** 图标字体缺失时会显示成方块;Windows 未测试。
@@ -156,7 +158,7 @@ extensions/pi-ccline/
156
158
  binary.ts 路径解析/缓存、带超时与中止的 spawn、--version 探测
157
159
  render.ts 输出归一化:分行、按可见宽度截断、stale 标记
158
160
  refresh.ts 单飞 + 节流 + latest-wins 调度器,以及驱动 `耗时` 的定时器
159
- turn.ts 从流事件实测单次请求耗时,并写 ccline 的 bridge 文件供 `timing.tps`
161
+ turn.ts 从流事件实测单次请求耗时、跟踪运行中的工具,并写 ccline 的 bridge 文件
160
162
  config.ts 配置读写
161
163
  *.test.ts node --test
162
164
  scripts/render-once.ts 用 fixture 跑一次真实 ccline
@@ -421,6 +421,54 @@ test("writes ccline's timing bridge from the streamed answer", async () => {
421
421
  }
422
422
  });
423
423
 
424
+ test("publishes running tools in the same bridge as the timing", async () => {
425
+ const cclineHome = tempDir("pi-ccline-bridge-tools-");
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
+ // One finished answer, so the bridge already carries a turn.
436
+ const assistant = { role: "assistant", model: "m", usage: { output: 400 } };
437
+ await dispatch("message_start", ctx, { message: assistant });
438
+ await sleep(120);
439
+ await dispatch("message_update", ctx, { message: assistant });
440
+ await sleep(220);
441
+ await dispatch("message_end", ctx, { message: assistant });
442
+
443
+ const read = () => JSON.parse(readFileSync(join(cclineHome, "bridge", "sess-1.json"), "utf8"));
444
+ await dispatch("tool_execution_start", ctx, { toolCallId: "t1", toolName: "Bash" });
445
+ const running = read();
446
+ assert.deepEqual(running.tools.map((tool: { name: string }) => tool.name), ["Bash"]);
447
+ assert.ok(running.tools[0].since > 0);
448
+ assert.ok(running.turn.tps > 0, "a tools update must not blank the timing segment");
449
+
450
+ // A nested call from another tool is not a tool the user issued.
451
+ await dispatch("tool_execution_start", ctx, { toolCallId: "t2", toolName: "Read", parentToolCallId: "t1" });
452
+ assert.equal(read().tools.length, 1);
453
+
454
+ await dispatch("tool_execution_end", ctx, { toolCallId: "t1" });
455
+ const finished = read();
456
+ assert.equal("tools" in finished, false, "a finished tool leaves no row behind");
457
+ assert.ok(finished.turn.tps > 0);
458
+
459
+ // An aborted run may never send the end event; the run boundary clears it.
460
+ await dispatch("tool_execution_start", ctx, { toolCallId: "t3", toolName: "Grep" });
461
+ assert.equal(read().tools.length, 1);
462
+ await dispatch("agent_end", ctx);
463
+ assert.equal("tools" in read(), false, "no tool survives the run that owned it");
464
+
465
+ await dispatch("session_shutdown", ctx);
466
+ } finally {
467
+ if (previousHome === undefined) delete process.env.CCLINE_HOME;
468
+ else process.env.CCLINE_HOME = previousHome;
469
+ }
470
+ });
471
+
424
472
  test("ignores message events that are not the assistant's answer", async () => {
425
473
  const cclineHome = tempDir("pi-ccline-bridge-role-");
426
474
  const previousHome = process.env.CCLINE_HOME;
@@ -6,7 +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
+ 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
  /**
@@ -65,6 +73,10 @@ export class CclineFooter {
65
73
  private branchUnsubscribe: (() => void) | null = null;
66
74
  /** Session whose ccline bridge this footer wrote, removed again on dispose. */
67
75
  private bridgeSessionId: string | null = null;
76
+ /** The last finished answer, re-sent with every tools update (one file). */
77
+ private lastTiming: TurnTiming | null = null;
78
+ /** Tool calls running now, keyed by pi's tool call id. */
79
+ private readonly runningTools = new Map<string, BridgeTool>();
68
80
  private readonly meter = new TurnMeter();
69
81
  private disposed = false;
70
82
 
@@ -175,6 +187,21 @@ export class CclineFooter {
175
187
  this.meter.firstDelta(performance.now());
176
188
  }
177
189
 
190
+ /**
191
+ * Rewrite the bridge with everything it has to carry: the last answer's
192
+ * timing and the tools running now (ccline reads both from one file, so a
193
+ * writer that sent only one of them would blank the other segment).
194
+ */
195
+ private flushBridge(): void {
196
+ const sessionId = this.sessionId();
197
+ if (!sessionId) return;
198
+ const tools = [...this.runningTools.values()];
199
+ if (!this.lastTiming && tools.length === 0) return;
200
+ if (!writeBridge({ sessionId, timing: this.lastTiming ?? undefined, tools, nowMs: Date.now() })) return;
201
+ if (this.bridgeSessionId && this.bridgeSessionId !== sessionId) removeTurnBridge(this.bridgeSessionId);
202
+ this.bridgeSessionId = sessionId;
203
+ }
204
+
178
205
  /**
179
206
  * The answer finished: hand ccline its timing through the bridge file it
180
207
  * reads for `timing.tps`. No speed, no file.
@@ -186,16 +213,45 @@ export class CclineFooter {
186
213
  // stamp is wall-clock, and the two are never mixed.
187
214
  const timing = this.meter.finish(message.usage?.output, performance.now());
188
215
  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;
216
+ this.lastTiming = timing;
194
217
  this.stats.lastTps = timing.tps;
218
+ this.flushBridge();
195
219
  // The new speed only shows up in the next frame; let the throttle coalesce it.
196
220
  this.requestRefresh();
197
221
  }
198
222
 
223
+ /**
224
+ * A tool started: `activity.tools` shows it until `onToolEnd`. Nested calls
225
+ * (codemode, tools calling tools) are skipped — a script would otherwise
226
+ * flood the segment with names the user never issued.
227
+ */
228
+ onToolStart(event: ToolEventLike): void {
229
+ const id = event.toolCallId;
230
+ const name = event.toolName;
231
+ if (!id || !name || event.parentToolCallId) return;
232
+ this.runningTools.set(id, { name, since: Date.now() });
233
+ this.flushBridge();
234
+ this.requestRefresh();
235
+ }
236
+
237
+ onToolEnd(event: ToolEventLike): void {
238
+ const id = event.toolCallId;
239
+ if (!id || !this.runningTools.delete(id)) return;
240
+ this.flushBridge();
241
+ this.requestRefresh();
242
+ }
243
+
244
+ /**
245
+ * An aborted run can end without a matching `tool_execution_end`, and no
246
+ * tool can outlive the run, so drop the rest rather than show a tool that
247
+ * keeps counting up forever.
248
+ */
249
+ clearRunningTools(): void {
250
+ if (this.runningTools.size === 0) return;
251
+ this.runningTools.clear();
252
+ this.flushBridge();
253
+ }
254
+
199
255
  get hasFrame(): boolean {
200
256
  return this.frame !== null;
201
257
  }
@@ -313,6 +369,7 @@ export class CclineFooter {
313
369
  this.branchUnsubscribe = null;
314
370
  this.footerData = null;
315
371
  this.requestRenderFn = null;
372
+ this.runningTools.clear();
316
373
  if (this.bridgeSessionId) removeTurnBridge(this.bridgeSessionId);
317
374
  this.bridgeSessionId = null;
318
375
  }
@@ -364,8 +421,13 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
364
421
  pi.on("agent_end", () => {
365
422
  requestRefresh();
366
423
  footer?.stopLive();
424
+ // No tool can survive the run, even when an abort skipped its end event.
425
+ footer?.clearRunningTools();
426
+ });
427
+ pi.on("agent_settled", () => {
428
+ footer?.stopLive();
429
+ footer?.clearRunningTools();
367
430
  });
368
- pi.on("agent_settled", () => footer?.stopLive());
369
431
  pi.on("model_select", () => requestRefresh());
370
432
  pi.on("thinking_level_select", () => requestRefresh());
371
433
  pi.on("session_info_changed", () => requestRefresh());
@@ -374,6 +436,9 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
374
436
  pi.on("message_start", (event) => footer?.onMessageStart(event as MessageEventLike));
375
437
  pi.on("message_update", (event) => footer?.onMessageUpdate(event as MessageEventLike));
376
438
  pi.on("message_end", (event) => footer?.onMessageEnd(event as MessageEventLike));
439
+ // Same bridge: `activity.tools` lists what is still running.
440
+ pi.on("tool_execution_start", (event) => footer?.onToolStart(event as ToolEventLike));
441
+ pi.on("tool_execution_end", (event) => footer?.onToolEnd(event as ToolEventLike));
377
442
  pi.on("session_shutdown", () => teardown());
378
443
 
379
444
  pi.registerCommand("pi-ccline", {
@@ -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, writeTurnBridge } from "./turn.ts";
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
- writeTurnBridge({
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
- writeTurnBridge({ sessionId: "s2", timing: { tps: 12, durationMs: 1_000, outputTokens: 12 }, nowMs: 1, dir });
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(writeTurnBridge({ sessionId: "../escape", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
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(writeTurnBridge({ sessionId: "blocked", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
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
- writeTurnBridge({ sessionId: "s3", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir });
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
- export interface TurnBridgeOptions {
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
- timing: TurnTiming;
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
- /** Write the bridge for one session. Returns false instead of throwing. */
55
- export function writeTurnBridge(options: TurnBridgeOptions): boolean {
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 { 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 = {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caph42/pi-ccline",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pi extension that renders the footer by delegating to the CCometixLine (ccline) statusline binary",
5
5
  "keywords": [
6
6
  "pi-package",