pi-ui-extension 1.1.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.
Files changed (39) hide show
  1. package/README.md +43 -0
  2. package/extensions/ctx/README.md +35 -0
  3. package/extensions/ctx/activity.ts +226 -0
  4. package/extensions/ctx/data.test.ts +65 -0
  5. package/extensions/ctx/data.ts +230 -0
  6. package/extensions/ctx/panel.test.ts +103 -0
  7. package/extensions/ctx/panel.ts +185 -0
  8. package/extensions/ctx/tools.test.ts +64 -0
  9. package/extensions/fabric-detection.test.ts +15 -0
  10. package/extensions/fabric-detection.ts +7 -0
  11. package/extensions/footer/index.ts +62 -0
  12. package/extensions/index.ts +577 -0
  13. package/extensions/mcp/integration.test.ts +64 -0
  14. package/extensions/mcp/resources.test.ts +27 -0
  15. package/extensions/mcp/resources.ts +31 -0
  16. package/extensions/resource-dialog.test.ts +45 -0
  17. package/extensions/session/mutations.test.ts +30 -0
  18. package/extensions/session/mutations.ts +17 -0
  19. package/extensions/session/panel.test.ts +238 -0
  20. package/extensions/session/panel.ts +461 -0
  21. package/extensions/sidebar/default-panels.test.ts +97 -0
  22. package/extensions/sidebar/default-panels.ts +155 -0
  23. package/extensions/sidebar/index.ts +212 -0
  24. package/extensions/sidebar/layout.test.ts +68 -0
  25. package/extensions/sidebar/layout.ts +75 -0
  26. package/extensions/sidebar/panel.ts +49 -0
  27. package/extensions/sidebar/registry.ts +35 -0
  28. package/extensions/sidebar/resize.test.ts +71 -0
  29. package/extensions/sidebar/resize.ts +154 -0
  30. package/extensions/tool-view/controller.test.ts +57 -0
  31. package/extensions/tool-view/controller.ts +519 -0
  32. package/extensions/tool-view/panel.test.ts +73 -0
  33. package/extensions/tool-view/panel.ts +221 -0
  34. package/extensions/tool-view/trust.test.ts +41 -0
  35. package/extensions/tool-view/types.ts +6 -0
  36. package/extensions/tool-view/usage.test.ts +231 -0
  37. package/extensions/tool-view/usage.ts +303 -0
  38. package/extensions/tool-view/wheel.test.ts +32 -0
  39. package/package.json +53 -0
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # pi-ui-extension
2
+
3
+ A Pi package that extends the terminal UI with a footer, split-view sidebar, context/activity panels, session controls, resource views, and configurable tool transcript rendering.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 24+
8
+ - A compatible Pi Coding Agent installation
9
+
10
+ ## Development
11
+
12
+ Install dependencies:
13
+
14
+ ```bash
15
+ npm ci
16
+ ```
17
+
18
+ Run the full validation suite:
19
+
20
+ ```bash
21
+ npm run check
22
+ ```
23
+
24
+ Or run checks separately:
25
+
26
+ ```bash
27
+ npm run typecheck
28
+ npm test
29
+ ```
30
+
31
+ ## Package entry
32
+
33
+ Pi loads the extension from:
34
+
35
+ ```text
36
+ ./extensions/index.ts
37
+ ```
38
+
39
+ The package metadata is declared in `package.json` under the `pi.extensions` field.
40
+
41
+ ## Notes
42
+
43
+ This repository contains only the extension source and development metadata. Local Pi runtime configuration, credentials, sessions, caches, and browser profiles are intentionally excluded.
@@ -0,0 +1,35 @@
1
+ # CTX 第一版
2
+
3
+ 保留 model/thinking、pi usage/contextWindow 与进度条。context-mode 的 Events captured 和 Compactions 显示在进度条下方,Composition 内容直接展示,以横线与估算统计分隔,不再显示折叠标题或注册点击监听。内容超出分配高度可滚轮滚动,CTX 最大高度为终端的 35%(至少 10 行),沿用 sidebar 的滚动布局,不获取编辑器键盘焦点。
4
+
5
+ ## 估算口径
6
+
7
+ - approx = 本地可观测文本 UTF-16 长度 / 4,逐消息向上取整;不是模型 tokenizer。已知消息先经公开 SDK `convertToLlm` 转换,再提取文本,因此包含摘要包装、bash 无输出/退出/取消/截断提示;仍按原始角色分类。未知角色安全降级为外层 content 文本,`excludeFromContext` 的 bash 不计入。
8
+ - 六类为 System、Tools、Messages、Tool Results、Summary、Other。百分比分母是已覆盖分类的估算 token 合计,不是模型窗口;pi usage 单独展示,不校准分类。
9
+ - System 使用 ctx.getSystemPrompt() 的可观测字符串;无法取得时 unknown。Tools 通过 pi.getActiveTools()/getAllTools() 仅选取当前启用的工具,逐工具序列化 `{name, description, parameters}`,按 UTF-16 长度 / 4 向上取整后合计,并纳入分类百分比分母。不计 sourceInfo、执行函数及已归入 System 的 promptGuidelines;Fabric 捕获但未直接启用的工具不重复计入。无启用工具为 0;API 不可用、启用工具缺少定义或 schema 无法序列化时保持 unknown,避免显示不完整合计。工具定义在每次现有快照事件刷新时重新读取(包括动态启用/禁用),不在 render 中读取;空闲时注册工具将在下一次快照事件反映。
10
+ - Tools 在 `before_provider_request` 事件后优先统计 provider-specific payload 的 `tools` 字段序列化文本(仍按 UTF-16 长度 / 4 估算);请求发出前未观察到 payload 时,回退到当前启用工具 schema 的本地估算。它仍不是模型 tokenizer、HTTP 字节数或计费 token,也不包含 headers;如果后续扩展继续改写 payload,面板值可能是本扩展处理时观察到的版本。Images 仅统计图片块数量,不序列化请求或计算未展示的字节数;在 `before_provider_request` 可取得最终 provider payload 时优先统计该 payload,否则统计当前 context 消息;未能观测时显示 `not observed`。
11
+ - context 事件提供调用前的消息快照;session_start/tree/compact、turn_end、agent_settled 使用 buildContextEntries() 重建 compaction-applied active branch。后者显示 active branch,前者显示 context hook。事件之外只读取缓存,不在 render 中扫描历史。
12
+ - Messages 包括 user/assistant 文本、thinking 和 tool call 名称/参数;Tool Results 仅计 content;Summary 包括 compaction/branch summary;Other 包括可发送 custom message 和非排除的用户 bash。
13
+ - Tool Results 按当前快照工具结果文本估算。Fabric 外层 content 只计一次,不遍历内部操作;不读 details、全量 JSONL、沙箱、截断前原始输出或被压缩结果。已返回 content 内的截断提示本身仍属于可发送文本。
14
+
15
+ Composition 的 token 估算使用 `~` 前缀及 K/M 紧凑缩写(四舍五入),百分比保留一位小数。宽度不足时标签与数值拆行;支持的最小侧栏宽 18 可完整显示如 `~100K 100.0%`。
16
+
17
+ ## 本次提问增量
18
+
19
+ 以一次提问(`before_agent_start` 到 `agent_settled`)为单位:基线是上一次提问结束后的分类估算总量,`turn_end` 只重算快照、不推进基线,因此增量在提问进行中实时增长,`agent_settled` 后定格。数值是净增长,压缩造成的下降显示为负。`session_start`、`session_tree`、`session_compact` 会把基线重置为当前总量,此时不显示增量。
20
+
21
+ 进度条最右侧的 accent 段是增量在窗口中的份额,宽度为 `round(内容宽 × 增量 / contextWindow)`,向上至少 1 格,且不会超出已用段,所以条的总长度不变。增量文字显示在 `已用%/窗口` 之后:正增量用 accent,负增量用 warning。增量为 0、基准未建立或窗口未知时,既不留文字也不画段。增量与分类估算同源(本地启发式),不含 `~` 前缀。
22
+
23
+ ## 活动指标(原 ACTIVITY 面板)
24
+
25
+ 独立 ACTIVITY 面板已移除,其内容移到进度条下方,以横线与分类统计分隔:状态 + 平均性能、最新 TTFT。
26
+
27
+ - 第一行:`N turns · 时长 · AVG TTFT · AVG TPS`。运行中时长按秒刷新(CTX 面板每秒 requestRender 一次),settled 后显示 `done 时长`,空闲显示 `Ready`。窄屏依次回退为 `状态`、`NT done 时长`、仅平均值,保证窗口内可见。
28
+ - 第二行:最新一次观测到的 TTFT(含 fast/normal/slow 标签),不是平均值——平均值只在第一行。进行中且尚无 TTFT 时显示 `pending`。
29
+ - 追踪器(TTFT/TPS 采样、turn 与工具记录)位于 `ctx/activity.ts`,由 `ctx/panel.ts` 渲染时读取;活动快照只用于这两行,面板不再注册独立的 layout 条目。
30
+
31
+ ## 限制
32
+
33
+ 这是明确标记的本地估算,不宣称最终请求精确统计。后加载扩展仍可能改写 context 或 provider payload;不同 provider 的转换、剔除、包装开销未知。恢复/回合结束时 active branch 可能不同于上次请求经过 context 扩展裁剪后的快照;下一次 context 事件会替换它。模型生成过程中不逐 token 更新分类,最终回合/settled 时更新。只含图片的消息可见文本为零,不代表图片成本为零。
34
+
35
+ 未修改 pi core、Fabric 或全局配置。全屏鼠标/极短终端还应在用户真实终端手工确认。
@@ -0,0 +1,226 @@
1
+ import nodePath from "node:path";
2
+
3
+ export type ToolActivityStatus = "running" | "done" | "failed";
4
+ export type ToolActivity = {
5
+ readonly id: string;
6
+ readonly name: string;
7
+ readonly summary: string;
8
+ readonly status: ToolActivityStatus;
9
+ readonly startedAt: number;
10
+ readonly durationMs?: number;
11
+ };
12
+ export type ResponsePerformance = {
13
+ readonly ttftMs?: number;
14
+ readonly tokensPerSecond?: number;
15
+ readonly estimated?: boolean;
16
+ };
17
+ export type TurnActivity = {
18
+ readonly number: number;
19
+ readonly startedAt: number;
20
+ readonly durationMs?: number;
21
+ readonly performance?: ResponsePerformance;
22
+ readonly tools: readonly ToolActivity[];
23
+ };
24
+ export type RunActivitySnapshot = {
25
+ readonly phase: "idle" | "running" | "settled";
26
+ readonly turnNumber?: number;
27
+ readonly startedAt?: number;
28
+ readonly durationMs?: number;
29
+ readonly performance?: ResponsePerformance;
30
+ readonly averagePerformance?: ResponsePerformance;
31
+ readonly turns: readonly TurnActivity[];
32
+ readonly activeTools: readonly ToolActivity[];
33
+ readonly recentTools: readonly ToolActivity[];
34
+ readonly completedCount: number;
35
+ readonly failedCount: number;
36
+ };
37
+ export type ToolStart = { readonly toolCallId: string; readonly toolName: string; readonly args: unknown };
38
+ export type ToolEnd = { readonly toolCallId: string; readonly isError: boolean };
39
+ export type ActivityTracker = {
40
+ startRun(now?: number): void;
41
+ startTurn(turnIndex: number, now?: number): void;
42
+ finishTurn(turnIndex: number, now?: number): void;
43
+ startResponse(now?: number): void;
44
+ updateResponseEstimate(estimatedOutputTokens: number, now?: number): void;
45
+ finishResponse(outputTokens: number, now?: number): void;
46
+ startTool(event: ToolStart, now?: number): void;
47
+ finishTool(event: ToolEnd, now?: number): void;
48
+ settle(now?: number): void;
49
+ reset(): void;
50
+ getSnapshot(): RunActivitySnapshot;
51
+ };
52
+
53
+ const MAX_SUMMARY_LENGTH = 80;
54
+ export const EMPTY_ACTIVITY: RunActivitySnapshot = Object.freeze({
55
+ phase: "idle", turns: Object.freeze([]), activeTools: Object.freeze([]), recentTools: Object.freeze([]), completedCount: 0, failedCount: 0,
56
+ });
57
+ type SpeedLabel = "fast" | "normal" | "slow";
58
+ function ttftSpeed(ms: number): SpeedLabel { return ms < 2_000 ? "fast" : ms < 5_000 ? "normal" : "slow"; }
59
+ function tpsSpeed(tps: number): SpeedLabel { return tps >= 50 ? "fast" : tps >= 20 ? "normal" : "slow"; }
60
+ export function formatTtft(value: number | undefined): string {
61
+ return value === undefined || !Number.isFinite(value) ? "~"
62
+ : `${value < 1_000 ? "1s" : `${Math.max(0, value / 1_000).toFixed(1)}s`}[${ttftSpeed(value)}]`;
63
+ }
64
+ export function formatTps(value: number | undefined, estimated = false): string {
65
+ return value === undefined || !Number.isFinite(value) ? "~"
66
+ : `${estimated ? "~" : ""}${Math.max(0, value).toFixed(1)}[${tpsSpeed(value)}]`;
67
+ }
68
+ export function formatResponsePerformance(performance?: ResponsePerformance): string {
69
+ return `TTFT ${formatTtft(performance?.ttftMs)} · TPS ${formatTps(performance?.tokensPerSecond, performance?.estimated)}`;
70
+ }
71
+ export function formatAveragePerformance(performance?: ResponsePerformance): string {
72
+ return `TTFT avg ${formatTtft(performance?.ttftMs)} · TPS avg ${formatTps(performance?.tokensPerSecond)}`;
73
+ }
74
+ export function formatDuration(ms: number): string {
75
+ const seconds = Math.floor(Math.max(0, ms) / 1_000);
76
+ if (seconds < 1) return "<1s";
77
+ if (seconds < 60) return `${seconds}s`;
78
+ const minutes = Math.floor(seconds / 60);
79
+ if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
80
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
81
+ }
82
+ function clean(value: unknown): string {
83
+ return typeof value === "string" ? value.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
84
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim() : "";
85
+ }
86
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
87
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
88
+ }
89
+ function shortenPath(value: unknown, cwd: string): string {
90
+ const input = clean(value); if (!input) return "";
91
+ const absolute = nodePath.isAbsolute(input) ? nodePath.normalize(input) : nodePath.resolve(cwd, input);
92
+ const relative = nodePath.relative(cwd, absolute);
93
+ if (relative === "") return ".";
94
+ if (relative !== ".." && !relative.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(relative)) return relative;
95
+ const home = clean(process.env.HOME);
96
+ if (home) {
97
+ const homeRelative = nodePath.relative(home, absolute);
98
+ if (homeRelative === "") return "~";
99
+ if (homeRelative !== ".." && !homeRelative.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(homeRelative)) return `~/${homeRelative}`;
100
+ }
101
+ return absolute;
102
+ }
103
+ function truncateSummary(value: string): string { return value.length > MAX_SUMMARY_LENGTH ? `${value.slice(0, MAX_SUMMARY_LENGTH - 1)}…` : value; }
104
+ export function summarizeTool(toolName: string, args: unknown, cwd: string): string {
105
+ const input = asRecord(args); if (!input) return "";
106
+ const path = () => shortenPath(input.path, cwd);
107
+ switch (toolName) {
108
+ case "bash": return truncateSummary(clean(input.command));
109
+ case "read": case "edit": case "write": case "ls": return truncateSummary(path());
110
+ case "grep": case "find": {
111
+ const pattern = clean(input.pattern), target = path();
112
+ return truncateSummary(pattern && target ? `${pattern} in ${target}` : pattern || target);
113
+ }
114
+ default: return "";
115
+ }
116
+ }
117
+
118
+ export function createActivityTracker(cwd: string, onChange: () => void): ActivityTracker {
119
+ let phase: RunActivitySnapshot["phase"] = "idle";
120
+ let turnNumber: number | undefined, startedAt: number | undefined, durationMs: number | undefined;
121
+ let requestStartedAt: number | undefined, firstTokenAt: number | undefined, performance: ResponsePerformance | undefined;
122
+ let turns: TurnActivity[] = [];
123
+ let activeTools = new Map<string, ToolActivity>();
124
+ let recentTools: ToolActivity[] = [];
125
+ let completedCount = 0, failedCount = 0;
126
+ const notify = (): void => onChange();
127
+ const at = (now?: number): number => Math.max(0, Number.isFinite(now) ? Math.trunc(now as number) : Date.now());
128
+ const turnNo = (index: number): number => Math.max(0, Number.isFinite(index) ? Math.trunc(index) : 0) + 1;
129
+ const updateCurrentTurn = (update: (turn: TurnActivity) => TurnActivity): void => {
130
+ turns = turns.map((turn) => turn.number === turnNumber ? Object.freeze(update(turn)) : turn);
131
+ };
132
+ const snapshotPerformance = (): ResponsePerformance | undefined => performance ? Object.freeze({ ...performance }) : undefined;
133
+ const averagePerformance = (): ResponsePerformance | undefined => {
134
+ const samples = turns.map((turn) => turn.performance).filter((sample): sample is ResponsePerformance => Boolean(sample));
135
+ const ttftSamples = samples.map((sample) => sample.ttftMs).filter((value): value is number => value !== undefined && Number.isFinite(value));
136
+ const tpsSamples = samples.map((sample) => sample.tokensPerSecond).filter((value): value is number => value !== undefined && Number.isFinite(value));
137
+ if (ttftSamples.length === 0 && tpsSamples.length === 0) return undefined;
138
+ return Object.freeze({
139
+ ...(ttftSamples.length > 0 ? { ttftMs: ttftSamples.reduce((sum, value) => sum + value, 0) / ttftSamples.length } : {}),
140
+ ...(tpsSamples.length > 0 ? { tokensPerSecond: tpsSamples.reduce((sum, value) => sum + value, 0) / tpsSamples.length } : {}),
141
+ });
142
+ };
143
+
144
+ return {
145
+ startRun(now): void {
146
+ phase = "running"; turnNumber = undefined; startedAt = at(now); durationMs = undefined;
147
+ requestStartedAt = undefined; firstTokenAt = undefined; performance = undefined; turns = [];
148
+ activeTools = new Map(); recentTools = []; completedCount = 0; failedCount = 0; notify();
149
+ },
150
+ startTurn(index, now): void {
151
+ const number = turnNo(index);
152
+ if (turnNumber === number && phase === "running") return;
153
+ phase = "running"; turnNumber = number; durationMs = undefined; performance = undefined;
154
+ turns = [...turns, Object.freeze({ number, startedAt: at(now), tools: Object.freeze([]) })]; notify();
155
+ },
156
+ finishTurn(index, now): void {
157
+ const number = turnNo(index), endedAt = at(now);
158
+ turns = turns.map((turn) => turn.number === number && turn.durationMs === undefined
159
+ ? Object.freeze({ ...turn, durationMs: Math.max(0, endedAt - turn.startedAt), ...(snapshotPerformance() ? { performance: snapshotPerformance() } : {}) })
160
+ : turn);
161
+ notify();
162
+ },
163
+ startResponse(now): void {
164
+ requestStartedAt = at(now); firstTokenAt = undefined; performance = undefined; notify();
165
+ },
166
+ updateResponseEstimate(tokens, now): void {
167
+ if (requestStartedAt === undefined || !Number.isFinite(tokens) || tokens <= 0) return;
168
+ const observedAt = at(now);
169
+ if (firstTokenAt === undefined) {
170
+ firstTokenAt = observedAt; performance = Object.freeze({ ttftMs: Math.max(0, observedAt - requestStartedAt) }); notify(); return;
171
+ }
172
+ const generationMs = Math.max(0, observedAt - firstTokenAt);
173
+ if (generationMs <= 0 || !performance) return;
174
+ performance = Object.freeze({ ...performance, tokensPerSecond: tokens / (generationMs / 1_000), estimated: true }); notify();
175
+ },
176
+ finishResponse(tokens, now): void {
177
+ const first = firstTokenAt; requestStartedAt = undefined; firstTokenAt = undefined;
178
+ if (first === undefined || !performance) return;
179
+ const generationMs = Math.max(0, at(now) - first);
180
+ if (!Number.isFinite(tokens) || tokens <= 0 || generationMs <= 0) return;
181
+ performance = Object.freeze({ ttftMs: performance.ttftMs, tokensPerSecond: tokens / (generationMs / 1_000) });
182
+ updateCurrentTurn((turn) => ({ ...turn, performance: snapshotPerformance() })); notify();
183
+ },
184
+ startTool(event, now): void {
185
+ const id = clean(event.toolCallId); if (!id) return;
186
+ activeTools.set(id, Object.freeze({ id, name: clean(event.toolName) || "tool", summary: summarizeTool(event.toolName, event.args, cwd), status: "running", startedAt: at(now) }));
187
+ phase = "running"; durationMs = undefined; notify();
188
+ },
189
+ finishTool(event, now): void {
190
+ const id = clean(event.toolCallId), active = activeTools.get(id); if (!active) return;
191
+ activeTools.delete(id);
192
+ const status: ToolActivityStatus = event.isError ? "failed" : "done";
193
+ const completed = Object.freeze({ ...active, status, durationMs: Math.max(0, at(now) - active.startedAt) });
194
+ if (status === "failed") failedCount += 1; else completedCount += 1;
195
+ updateCurrentTurn((turn) => ({ ...turn, tools: Object.freeze([...turn.tools, completed]) }));
196
+ recentTools = [completed, ...recentTools].slice(0, 3); notify();
197
+ },
198
+ settle(now): void {
199
+ if ((phase === "idle" || phase === "settled") && activeTools.size === 0) return;
200
+ const endedAt = at(now);
201
+ for (const active of activeTools.values()) {
202
+ const failed = Object.freeze({ ...active, status: "failed" as const, durationMs: Math.max(0, endedAt - active.startedAt) });
203
+ updateCurrentTurn((turn) => ({ ...turn, tools: Object.freeze([...turn.tools, failed]) }));
204
+ recentTools.unshift(failed); failedCount += 1;
205
+ }
206
+ activeTools = new Map(); recentTools = recentTools.slice(0, 3);
207
+ if (turnNumber !== undefined) {
208
+ turns = turns.map((turn) => turn.number === turnNumber && turn.durationMs === undefined
209
+ ? Object.freeze({ ...turn, durationMs: Math.max(0, endedAt - turn.startedAt), ...(snapshotPerformance() ? { performance: snapshotPerformance() } : {}) }) : turn);
210
+ }
211
+ phase = "settled"; durationMs = Math.max(0, endedAt - (startedAt ?? endedAt)); notify();
212
+ },
213
+ reset(): void {
214
+ phase = "idle"; turnNumber = undefined; startedAt = undefined; durationMs = undefined;
215
+ requestStartedAt = undefined; firstTokenAt = undefined; performance = undefined; turns = [];
216
+ activeTools = new Map(); recentTools = []; completedCount = 0; failedCount = 0; notify();
217
+ },
218
+ getSnapshot(): RunActivitySnapshot {
219
+ return Object.freeze({ phase, ...(turnNumber === undefined ? {} : { turnNumber }), ...(startedAt === undefined ? {} : { startedAt }),
220
+ ...(durationMs === undefined ? {} : { durationMs }), ...(performance ? { performance: snapshotPerformance() } : {}),
221
+ ...(averagePerformance() ? { averagePerformance: averagePerformance() } : {}),
222
+ turns: Object.freeze(turns.map((turn) => Object.freeze({ ...turn, tools: Object.freeze([...turn.tools]) }))),
223
+ activeTools: Object.freeze([...activeTools.values()]), recentTools: Object.freeze([...recentTools]), completedCount, failedCount });
224
+ },
225
+ };
226
+ }
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createReadTool, SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { estimateContext, installContextEstimates, messagesFromContextEntries } from "./data.ts";
5
+
6
+ const result = (n: number) => ({ role: "toolResult", toolName: "fabric_exec", toolCallId: String(n), content: [{ type: "text", text: "x".repeat(n * 4) }], details: { fullOutput: "x".repeat(100000), operations: [{ toolName: "read" }] } });
7
+ const tool = { name: "fabric_exec", description: "Execute TypeScript", parameters: createReadTool(process.cwd()).parameters };
8
+
9
+ test("context estimate keeps tool schemas, summaries, images and active branch semantics", () => {
10
+ const snapshot = estimateContext([{role:"user",content:"abcd"}, result(4), {role:"compactionSummary",summary:"abcd"}], "abcd", "context hook", [tool]);
11
+ assert.equal(snapshot.categories.find(c => c.name === "Tools")?.tokens, Math.ceil(JSON.stringify(tool).length / 4));
12
+ assert.equal(snapshot.categories.find(c => c.name === "Summary")?.tokens, 28);
13
+ assert.ok(snapshot.total > 0);
14
+ assert.equal(estimateContext([{role:"user",content:[{type:"image",data:"x".repeat(10000)}]}]).total, 0);
15
+ });
16
+
17
+ test("tracker filters active tools, refreshes schemas, caches snapshots and resets", () => {
18
+ const handlers = new Map<string, Function>();
19
+ let active = ["fabric_exec"];
20
+ let all = [tool];
21
+ const tracker = installContextEstimates({
22
+ on: (name: string, fn: Function) => handlers.set(name, fn),
23
+ getActiveTools: () => active,
24
+ getAllTools: () => all,
25
+ } as any, () => {});
26
+ const ctx = {sessionManager: SessionManager.inMemory(), getSystemPrompt: () => ""};
27
+ handlers.get("session_start")!({}, ctx);
28
+ assert.equal(tracker.getSnapshot().source, "active branch");
29
+ const cached = tracker.getSnapshot();
30
+ assert.strictEqual(tracker.getSnapshot(), cached);
31
+ active = [];
32
+ handlers.get("agent_settled")!({}, ctx);
33
+ assert.equal(tracker.getSnapshot().categories.find(c => c.name === "Tools")?.tokens, 0);
34
+ handlers.get("session_shutdown")!();
35
+ assert.equal(tracker.getSnapshot().source, "unavailable");
36
+ });
37
+
38
+ test("delta tracks the previous settled run and resets", () => {
39
+ const handlers = new Map<string, Function>();
40
+ const tracker = installContextEstimates({
41
+ on: (name: string, fn: Function) => handlers.set(name, fn),
42
+ getActiveTools: () => [],
43
+ getAllTools: () => [],
44
+ } as any, () => {});
45
+ const session = SessionManager.inMemory();
46
+ const ctx = { sessionManager: session, getSystemPrompt: () => "" };
47
+ handlers.get("session_start")!({}, ctx);
48
+ assert.equal(tracker.getSnapshot().total, 0);
49
+ session.appendMessage({ role: "user", content: [{ type: "text", text: "x".repeat(4000) }] } as any);
50
+ handlers.get("turn_end")!({}, ctx);
51
+ assert.equal(tracker.getDelta(), 1000);
52
+ handlers.get("agent_settled")!({}, ctx);
53
+ assert.equal(tracker.getDelta(), 1000);
54
+ handlers.get("turn_end")!({}, ctx);
55
+ assert.equal(tracker.getDelta(), 0);
56
+ session.appendMessage({ role: "user", content: [{ type: "text", text: "x".repeat(400) }] } as any);
57
+ handlers.get("agent_settled")!({}, ctx);
58
+ assert.equal(tracker.getDelta(), 100);
59
+ handlers.get("session_compact")!({}, ctx);
60
+ assert.equal(tracker.getDelta(), undefined);
61
+ assert.equal(tracker.getSnapshot().total, 1100);
62
+ });
63
+ test("converts custom context entries", () => {
64
+ assert.equal(messagesFromContextEntries([{type:"custom_message",content:"ok"}]).length, 1);
65
+ });
@@ -0,0 +1,230 @@
1
+ import { convertToLlm, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+
4
+ const knownRoles = new Set(["user", "assistant", "toolResult", "custom", "bashExecution", "compactionSummary", "branchSummary"]);
5
+
6
+ export const CONTEXT_CATEGORIES = ["System", "Tools", "Messages", "Tool Results", "Summary", "Other"] as const;
7
+ export type ContextCategory = typeof CONTEXT_CATEGORIES[number];
8
+ export type ImagesWireEstimate = {
9
+ readonly images: number;
10
+ readonly source: "message" | "provider payload" | "unavailable";
11
+ };
12
+ export type ContextEstimate = {
13
+ readonly categories: readonly { name: ContextCategory; tokens: number | undefined; percent: number | undefined }[];
14
+ readonly total: number;
15
+ readonly imagesWire: ImagesWireEstimate;
16
+ readonly source: "context hook" | "active branch" | "unavailable";
17
+ };
18
+ type RecordValue = Record<string, unknown>;
19
+ const record = (value: unknown): RecordValue => value !== null && typeof value === "object" ? value as RecordValue : {};
20
+ const text = (value: unknown): string => typeof value === "string" ? value : "";
21
+
22
+ function isImageNode(value: RecordValue): boolean {
23
+ return value.type === "image" || value.type === "image_url" || value.type === "input_image";
24
+ }
25
+
26
+ function countImages(value: unknown, source: "message" | "provider payload"): ImagesWireEstimate {
27
+ if (value === undefined || value === null) return { images: 0, source: "unavailable" };
28
+ let images = 0;
29
+ const seen = new WeakSet<object>();
30
+ const visit = (node: unknown): void => {
31
+ if (node === null || typeof node !== "object" || seen.has(node)) return;
32
+ seen.add(node);
33
+ if (isImageNode(record(node))) images++;
34
+ else for (const child of Object.values(node)) visit(child);
35
+ seen.delete(node);
36
+ };
37
+ visit(value);
38
+ return { images, source };
39
+ }
40
+
41
+ /** Local text heuristic, not a model tokenizer or provider usage measurement. */
42
+ export const estimateTextTokens = (value: string): number => Math.ceil(value.length / 4);
43
+
44
+ function contentText(value: unknown): string {
45
+ if (typeof value === "string") return value;
46
+ if (!Array.isArray(value)) return "";
47
+ return value.map((item) => {
48
+ const block = record(item);
49
+ if (block.type === "text") return text(block.text);
50
+ if (block.type === "thinking") return text(block.thinking);
51
+ if (block.type === "toolCall") return text(block.name) + JSON.stringify(block.arguments ?? {});
52
+ // Images, signatures and provider-specific blocks have unknown cost.
53
+ return "";
54
+ }).join("");
55
+ }
56
+
57
+ type ToolDefinition = Pick<ReturnType<ExtensionAPI["getAllTools"]>[number], "name" | "description" | "parameters">;
58
+
59
+ /** Estimate the active schema fallback when no provider payload has been observed. */
60
+ function estimateToolTokens(tools: readonly ToolDefinition[] | undefined): number | undefined {
61
+ if (tools === undefined) return undefined;
62
+ try {
63
+ return tools.reduce((sum, { name, description, parameters }) => {
64
+ if (typeof name !== "string" || typeof description !== "string" || !parameters) {
65
+ throw new Error("Incomplete tool definition");
66
+ }
67
+ return sum + estimateTextTokens(JSON.stringify({ name, description, parameters }));
68
+ }, 0);
69
+ } catch {
70
+ // Unserializable schemas are unknown, never a misleading partial total or zero.
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ /** Estimate the serialized tools field from the provider-specific request payload. */
76
+ export function estimateProviderToolsTokens(payload: unknown): number | undefined {
77
+ if (payload === null || typeof payload !== "object") return undefined;
78
+ const tools = (payload as Record<string, unknown>).tools;
79
+ if (tools === undefined) return 0;
80
+ try {
81
+ const serialized = JSON.stringify(tools);
82
+ return serialized === undefined ? undefined : estimateTextTokens(serialized);
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ function activeToolDefinitions(pi: ExtensionAPI): readonly ToolDefinition[] | undefined {
89
+ try {
90
+ const active = [...new Set(pi.getActiveTools())];
91
+ if (active.length === 0) return [];
92
+ const all = new Map(pi.getAllTools().map(tool => [tool.name, tool]));
93
+ const tools = active.map(name => all.get(name));
94
+ if (tools.some(tool => tool === undefined)) return undefined;
95
+ return tools as ToolDefinition[];
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ export function estimateContext(
102
+ messages: readonly unknown[] | undefined,
103
+ systemPrompt?: string,
104
+ source: ContextEstimate["source"] = "unavailable",
105
+ tools?: readonly ToolDefinition[],
106
+ imagesWire?: ImagesWireEstimate,
107
+ ): ContextEstimate {
108
+ const counts: Record<ContextCategory, number | undefined> = {
109
+ System: systemPrompt === undefined ? undefined : estimateTextTokens(systemPrompt),
110
+ Tools: estimateToolTokens(tools), Messages: messages ? 0 : undefined,
111
+ "Tool Results": messages ? 0 : undefined, Summary: messages ? 0 : undefined, Other: messages ? 0 : undefined,
112
+ };
113
+ for (const value of messages ?? []) {
114
+ const message = record(value);
115
+ if (message.role === "bashExecution" && message.excludeFromContext) continue;
116
+ const category: ContextCategory = message.role === "toolResult" ? "Tool Results"
117
+ : message.role === "compactionSummary" || message.role === "branchSummary" ? "Summary"
118
+ : message.role === "user" || message.role === "assistant" ? "Messages" : "Other";
119
+ // Let the public SDK supply summary/bash wrappers; retain the original role for attribution.
120
+ // Unknown extension roles safely fall back to observable outer content only.
121
+ const body = knownRoles.has(text(message.role))
122
+ ? convertToLlm([value as Parameters<typeof convertToLlm>[0][number]])
123
+ .map(converted => contentText(converted.content)).join("")
124
+ : contentText(message.content);
125
+ const tokens = estimateTextTokens(body);
126
+ counts[category] = (counts[category] ?? 0) + tokens;
127
+ }
128
+ const total = Object.values(counts).reduce<number>((sum, value) => sum + (value ?? 0), 0);
129
+ const percent = (tokens: number | undefined): number | undefined => tokens === undefined || total === 0 ? undefined : tokens / total * 100;
130
+ return {
131
+ total, source,
132
+ imagesWire: imagesWire ?? countImages(messages, "message"),
133
+ categories: CONTEXT_CATEGORIES.map(name => ({ name, tokens: counts[name], percent: percent(counts[name]) })),
134
+ };
135
+ }
136
+
137
+ /** Only compaction-applied active entries; never reads JSONL or tool details. */
138
+ export function messagesFromContextEntries(entries: readonly unknown[]): unknown[] {
139
+ return entries.flatMap((value): unknown[] => {
140
+ const entry = record(value);
141
+ if (entry.type === "message") return [entry.message];
142
+ if (entry.type === "compaction") return [{ role: "compactionSummary", summary: entry.summary }];
143
+ if (entry.type === "branch_summary") return [{ role: "branchSummary", summary: entry.summary }];
144
+ if (entry.type === "custom_message") return [{ role: "custom", content: entry.content }];
145
+ return [];
146
+ });
147
+ }
148
+
149
+ export function installContextEstimates(pi: ExtensionAPI, onChange: () => void): {
150
+ getSnapshot(): ContextEstimate;
151
+ getDelta(): number | undefined;
152
+ resetDelta(): void;
153
+ } {
154
+ let snapshot = estimateContext(undefined);
155
+ let settledTotal: number | undefined;
156
+ let delta: number | undefined;
157
+ let latestProviderImagesWire: ImagesWireEstimate | undefined;
158
+ let latestProviderToolsTokens: number | undefined;
159
+ const restore = (ctx: ExtensionContext, keepProviderMeasurement = false): void => {
160
+ if (!keepProviderMeasurement) {
161
+ latestProviderImagesWire = undefined;
162
+ latestProviderToolsTokens = undefined;
163
+ }
164
+ const messages = messagesFromContextEntries(ctx.sessionManager.buildContextEntries());
165
+ snapshot = estimateContext(messages, ctx.getSystemPrompt(), "active branch", activeToolDefinitions(pi), latestProviderImagesWire);
166
+ if (keepProviderMeasurement && latestProviderToolsTokens !== undefined) {
167
+ const categories = snapshot.categories.map((category) =>
168
+ category.name === "Tools" ? { ...category, tokens: latestProviderToolsTokens } : category,
169
+ );
170
+ const total = categories.reduce((sum, category) => sum + (category.tokens ?? 0), 0);
171
+ const percent = (tokens: number | undefined): number | undefined =>
172
+ tokens === undefined || total === 0 ? undefined : tokens / total * 100;
173
+ snapshot = {
174
+ ...snapshot,
175
+ total,
176
+ categories: categories.map((category) => ({ ...category, percent: percent(category.tokens) })),
177
+ };
178
+ }
179
+ onChange();
180
+ };
181
+ // A run is one user question, measured against the total left by the previous
182
+ // settled run, so compaction losses surface as a negative delta.
183
+ const measure = (ctx: ExtensionContext, settled: boolean): void => {
184
+ restore(ctx, true);
185
+ delta = settledTotal === undefined ? undefined : snapshot.total - settledTotal;
186
+ if (settled) settledTotal = snapshot.total;
187
+ };
188
+ const resetDelta = (ctx: ExtensionContext): void => {
189
+ restore(ctx);
190
+ settledTotal = snapshot.total;
191
+ delta = undefined;
192
+ };
193
+ pi.on("context", (event, ctx) => {
194
+ latestProviderImagesWire = undefined;
195
+ latestProviderToolsTokens = undefined;
196
+ snapshot = estimateContext(event.messages, ctx.getSystemPrompt(), "context hook", activeToolDefinitions(pi));
197
+ onChange();
198
+ });
199
+ pi.on("before_provider_request", (event) => {
200
+ latestProviderImagesWire = countImages(event.payload, "provider payload");
201
+ const tools = estimateProviderToolsTokens(event.payload);
202
+ latestProviderToolsTokens = tools;
203
+ const categories = snapshot.categories.map((category) =>
204
+ category.name === "Tools" ? { ...category, tokens: tools } : category,
205
+ );
206
+ const total = categories.reduce((sum, category) => sum + (category.tokens ?? 0), 0);
207
+ const percent = (tokens: number | undefined): number | undefined =>
208
+ tokens === undefined || total === 0 ? undefined : tokens / total * 100;
209
+ snapshot = {
210
+ ...snapshot,
211
+ total,
212
+ categories: categories.map((category) => ({ ...category, percent: percent(category.tokens) })),
213
+ imagesWire: latestProviderImagesWire,
214
+ };
215
+ onChange();
216
+ });
217
+ pi.on("session_start", (_event, ctx) => resetDelta(ctx));
218
+ pi.on("session_tree", (_event, ctx) => resetDelta(ctx));
219
+ pi.on("session_compact", (_event, ctx) => resetDelta(ctx));
220
+ pi.on("agent_settled", (_event, ctx) => measure(ctx, true));
221
+ pi.on("turn_end", (_event, ctx) => measure(ctx, false));
222
+ pi.on("session_shutdown", () => {
223
+ latestProviderImagesWire = undefined;
224
+ latestProviderToolsTokens = undefined;
225
+ snapshot = estimateContext(undefined);
226
+ settledTotal = undefined;
227
+ delta = undefined;
228
+ });
229
+ return { getSnapshot: () => snapshot, getDelta: () => delta, resetDelta: () => settledTotal = snapshot.total };
230
+ }