@xprnvd/pi-context-view 0.0.0-stage → 0.8.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -2
  3. package/package.json +55 -5
  4. package/src/capture/adjustments.ts +649 -0
  5. package/src/capture/attribution.ts +25 -0
  6. package/src/capture/builder.ts +128 -0
  7. package/src/capture/diff.ts +514 -0
  8. package/src/capture/dispatch.ts +36 -0
  9. package/src/capture/guard.ts +287 -0
  10. package/src/capture/messages.ts +246 -0
  11. package/src/capture/payload.ts +584 -0
  12. package/src/capture/redact.ts +58 -0
  13. package/src/capture/register.ts +129 -0
  14. package/src/capture/request.ts +251 -0
  15. package/src/capture/tools.ts +69 -0
  16. package/src/capture/tracker.ts +71 -0
  17. package/src/command.ts +164 -0
  18. package/src/compaction.ts +57 -0
  19. package/src/config.ts +408 -0
  20. package/src/index.ts +191 -0
  21. package/src/injections.ts +670 -0
  22. package/src/measure.ts +887 -0
  23. package/src/message-preview.ts +72 -0
  24. package/src/model-identity.ts +48 -0
  25. package/src/model.ts +548 -0
  26. package/src/pi-version.ts +29 -0
  27. package/src/probe/failure.ts +34 -0
  28. package/src/probe/filter.ts +126 -0
  29. package/src/probe/silent-probe.ts +349 -0
  30. package/src/probe/token.ts +28 -0
  31. package/src/probe/trigger.ts +178 -0
  32. package/src/probe/view.ts +26 -0
  33. package/src/projection.ts +122 -0
  34. package/src/prompt-additions.ts +226 -0
  35. package/src/prompt-blocks.ts +80 -0
  36. package/src/replay.ts +234 -0
  37. package/src/settings.ts +62 -0
  38. package/src/snapshot.ts +188 -0
  39. package/src/system-prompt-rows.ts +311 -0
  40. package/src/text.ts +46 -0
  41. package/src/token-diff.ts +211 -0
  42. package/src/transcript.ts +19 -0
  43. package/src/ui/color.ts +52 -0
  44. package/src/ui/diff-preview.ts +181 -0
  45. package/src/ui/injections-model.ts +487 -0
  46. package/src/ui/injections-view.ts +1023 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +161 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/search.ts +427 -0
  52. package/src/ui/section-preview.ts +267 -0
  53. package/src/ui/skill-preview.ts +83 -0
  54. package/src/ui/status-bar.ts +94 -0
  55. package/src/ui/usage-map.ts +157 -0
  56. package/src/ui/usage-preview.ts +202 -0
  57. package/src/ui/usage-view.ts +1428 -0
  58. package/src/ui/wheel.ts +65 -0
  59. package/src/usage.ts +461 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Mouse-wheel input shared by the Usage and Injections views.
3
+ *
4
+ * Pi enables mouse reporting only in fullscreen (alt-screen) mode, where its
5
+ * viewport forwards wheel reports to the focused overlay instead of consuming
6
+ * them. In regular mode the terminal keeps the wheel for its own scrollback and
7
+ * no report ever reaches a view, so the parser simply never matches there.
8
+ */
9
+ import type { TUI } from "@earendil-works/pi-tui";
10
+
11
+ /** Lines one notch scrolls when the TUI exposes no step of its own. */
12
+ export const DEFAULT_WHEEL_SCROLL_LINES = 3;
13
+
14
+ /** SGR wheel report: `ESC [ < button ; column ; row` closed by `M` or `m`. */
15
+ const SGR_WHEEL_PATTERN = /^\x1b\[<(\d+);\d+;\d+[Mm]$/;
16
+
17
+ /** Legacy X10 wheel report: `ESC [ M` plus offset-encoded button, column, and row bytes. */
18
+ const X10_PREFIX = "\x1b[M";
19
+ const X10_LENGTH = 6;
20
+ const X10_BUTTON_INDEX = 3;
21
+ const X10_BYTE_OFFSET = 32;
22
+
23
+ /** Wheel reports set this button bit; the low two bits then carry axis and direction. */
24
+ const WHEEL_BUTTON_BIT = 64;
25
+ const DIRECTION_MASK = 3;
26
+
27
+ /** One notch backwards (up) or forwards (down). */
28
+ export type WheelDirection = -1 | 1;
29
+
30
+ /**
31
+ * Vertical wheel direction encoded in one input chunk, or undefined for every
32
+ * other input, including horizontal notches. Modifier bits are ignored, so a
33
+ * modified notch scrolls like a plain one, as it does in pi's own viewport.
34
+ */
35
+ export function parseWheelDirection(data: string): WheelDirection | undefined {
36
+ const sgrButton = SGR_WHEEL_PATTERN.exec(data)?.[1];
37
+ if (sgrButton !== undefined) return decodeWheelButton(Number.parseInt(sgrButton, 10));
38
+ if (data.length === X10_LENGTH && data.startsWith(X10_PREFIX)) {
39
+ return decodeWheelButton(data.charCodeAt(X10_BUTTON_INDEX) - X10_BYTE_OFFSET);
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * Lines one notch scrolls in the surrounding TUI.
46
+ *
47
+ * `TuiAltScreen` keeps its `wheelScrollLines` option private and pi never
48
+ * surfaces it, so read it defensively: honoring the real step keeps previews
49
+ * consistent with the transcript viewport, while any pi that hides, renames, or
50
+ * never had the field falls back to the default instead of a contradicting step.
51
+ */
52
+ export function readWheelScrollLines(tui: TUI): number {
53
+ const lines = (tui as { readonly wheelScrollLines?: unknown }).wheelScrollLines;
54
+ if (typeof lines !== "number" || !Number.isFinite(lines)) return DEFAULT_WHEEL_SCROLL_LINES;
55
+ return Math.max(1, Math.floor(lines));
56
+ }
57
+
58
+ /** Decode one button code, rejecting non-wheel buttons and the horizontal axis. */
59
+ function decodeWheelButton(button: number): WheelDirection | undefined {
60
+ if ((button & WHEEL_BUTTON_BIT) === 0) return undefined;
61
+ const direction = button & DIRECTION_MASK;
62
+ if (direction === 0) return -1;
63
+ if (direction === 1) return 1;
64
+ return undefined;
65
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Pure context-usage classification: combine the current branch's measured
3
+ * prompt/tool decomposition with the live session messages into estimated
4
+ * category totals. No pi API access — unit-testable.
5
+ */
6
+ import { type ContextEvent, type ContextUsage, convertToLlm, estimateTokens } from "@earendil-works/pi-coding-agent";
7
+
8
+ import {
9
+ BUILT_IN_TOOLS_LABEL,
10
+ type ContextUsageSnapshot,
11
+ INSTRUCTION_FILES_LABEL,
12
+ type InjectionItem,
13
+ type InjectionSnapshot,
14
+ type InvisibleReasoningEstimate,
15
+ type ReportedContextUsage,
16
+ SKILLS_LABEL,
17
+ SYSTEM_PROMPT_LABEL,
18
+ type UsageCategory,
19
+ type UsagePreviewEntry,
20
+ } from "./model.ts";
21
+ import { isSameModel, type ModelIdentity } from "./model-identity.ts";
22
+
23
+ /**
24
+ * Everything computeUsage needs; messages must already be synthetic-filtered and
25
+ * carry the latest request's conversation changes.
26
+ */
27
+ export interface UsageInputs {
28
+ snapshot: InjectionSnapshot;
29
+ messages: ContextEvent["messages"];
30
+ reported?: ReportedContextUsage;
31
+ modelLabel?: string;
32
+ /**
33
+ * The next request's model; thinking of other models is left out, as Pi drops it.
34
+ * Omit to count all thinking.
35
+ */
36
+ nextRequestModel?: ModelIdentity;
37
+ computedAt?: Date;
38
+ /** Auto-compaction reserve (settings `reserveTokens`); omit when auto-compaction is disabled. */
39
+ autoCompactReserveTokens?: number;
40
+ }
41
+
42
+ /**
43
+ * Estimate the current/next-request context composition. Prompt and tool
44
+ * categories come from the caller's current-state snapshot; message categories are
45
+ * classified from the session messages with the latest request's changes. System messages have already been
46
+ * replayed into that snapshot and must not count again. Empty categories are dropped and
47
+ * every aggregate equals the exact sum of its children.
48
+ */
49
+ export function computeUsage(inputs: UsageInputs): ContextUsageSnapshot {
50
+ const categories = [
51
+ ...classifyPromptCategories(inputs.snapshot),
52
+ ...classifyMessages(inputs.messages, inputs.nextRequestModel),
53
+ ].filter((category) => category.tokens > 0);
54
+ return {
55
+ computedAt: inputs.computedAt ?? new Date(),
56
+ modelLabel: inputs.modelLabel,
57
+ reported: inputs.reported,
58
+ categories,
59
+ estimatedTokens: categories.reduce((sum, category) => sum + category.tokens, 0),
60
+ autoCompactReserveTokens: inputs.autoCompactReserveTokens,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Flatten one category's preview entries across its breakdown, chronologically
66
+ * when every entry is message-backed. Raw entry text is process-local; the
67
+ * caller must sanitize before rendering and never log or persist it.
68
+ */
69
+ export function collectPreviewEntries(category: UsageCategory): UsagePreviewEntry[] {
70
+ const entries: UsagePreviewEntry[] = [];
71
+ const visit = (node: UsageCategory): void => {
72
+ entries.push(...(node.entries ?? []));
73
+ for (const child of node.children ?? []) visit(child);
74
+ };
75
+ visit(category);
76
+ if (entries.length > 1 && entries.every((entry) => entry.timestamp !== undefined)) {
77
+ entries.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
78
+ }
79
+ return entries;
80
+ }
81
+
82
+ /** Convert pi's nullable ContextUsage to the undefined-based model shape. */
83
+ export function toReportedUsage(usage: ContextUsage | undefined): ReportedContextUsage | undefined {
84
+ if (usage === undefined) return undefined;
85
+ return {
86
+ tokens: usage.tokens ?? undefined,
87
+ contextWindow: usage.contextWindow,
88
+ percent: usage.percent ?? undefined,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Map frozen snapshot items to prompt/tool/instruction/skill categories. System
94
+ * Prompt counts all of its text, including what extensions added to it.
95
+ */
96
+ function classifyPromptCategories(snapshot: InjectionSnapshot): UsageCategory[] {
97
+ const systemPrompt: UsageCategory[] = [];
98
+ const builtInTools: UsageCategory[] = [];
99
+ const customTools: UsageCategory[] = [];
100
+ const mcpTools: UsageCategory[] = [];
101
+ const contextFiles: UsageCategory[] = [];
102
+ const skills: UsageCategory[] = [];
103
+ for (const group of snapshot.groups) {
104
+ for (const item of group.items) {
105
+ switch (item.kind) {
106
+ case "base-prompt":
107
+ case "append-prompt":
108
+ systemPrompt.push(leafFromItem(item));
109
+ break;
110
+ case "tool":
111
+ if (item.source.native) builtInTools.push(...breakdownFromItem(item));
112
+ else if (isMcpTool(item)) mcpTools.push(leafFromItem(item));
113
+ else customTools.push(leafFromItem(item));
114
+ break;
115
+ case "context-file":
116
+ contextFiles.push(...breakdownFromItem(item));
117
+ break;
118
+ case "skills":
119
+ skills.push(...breakdownFromItem(item));
120
+ break;
121
+ case "message":
122
+ // Custom messages live in the session; classifyMessages counts them.
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ // Categories follow the order pi assembles them into a request: prompt text,
128
+ // context files, and skills first, then the tool definitions sent alongside.
129
+ return withoutEmpty([
130
+ aggregate("system-prompt", SYSTEM_PROMPT_LABEL, systemPrompt),
131
+ aggregate("context-files", INSTRUCTION_FILES_LABEL, contextFiles),
132
+ aggregate("skills", SKILLS_LABEL, skills),
133
+ aggregate("built-in-tools", BUILT_IN_TOOLS_LABEL, builtInTools),
134
+ aggregate("custom-tools", "Custom Tools", customTools),
135
+ aggregate("mcp-tools", "MCP Tools", mcpTools),
136
+ ]);
137
+ }
138
+
139
+ /** Best-effort MCP attribution from the only public provenance field available. */
140
+ function isMcpTool(item: InjectionItem): boolean {
141
+ return /(^|[^a-z])mcp([^a-z]|$)/i.test(`${item.source.id} ${item.source.label}`);
142
+ }
143
+
144
+ /** Tool-result shape narrowed out of the context event union. */
145
+ type ToolResultContextMessage = Extract<ContextEvent["messages"][number], { role: "toolResult" }>;
146
+
147
+ /** Custom-message shape narrowed out of the context event union. */
148
+ type CustomContextMessage = Extract<ContextEvent["messages"][number], { role: "custom" }>;
149
+
150
+ /** Classify session and request messages with preview entries; `nextRequestModel` keeps only its own thinking. */
151
+ function classifyMessages(
152
+ messages: ContextEvent["messages"],
153
+ nextRequestModel: ModelIdentity | undefined,
154
+ ): UsageCategory[] {
155
+ const user: UsagePreviewEntry[] = [];
156
+ const assistantText: UsagePreviewEntry[] = [];
157
+ const assistantThinking: UsagePreviewEntry[] = [];
158
+ const assistantToolCalls: UsagePreviewEntry[] = [];
159
+ const bashExecutions: UsagePreviewEntry[] = [];
160
+ const compacted: UsagePreviewEntry[] = [];
161
+ const toolResults: ToolResultContextMessage[] = [];
162
+ const customMessages: CustomContextMessage[] = [];
163
+
164
+ for (const message of messages) {
165
+ switch (message.role) {
166
+ case "system":
167
+ // Sections and tool deltas already contribute through the replayed snapshot.
168
+ break;
169
+ case "user":
170
+ user.push({
171
+ timestamp: message.timestamp,
172
+ breadcrumb: ["user"],
173
+ tokens: estimateTokens(message),
174
+ text: contentToText(message.content),
175
+ });
176
+ break;
177
+ case "assistant": {
178
+ const texts = message.content.flatMap((block) => (block.type === "text" ? [block.text] : []));
179
+ assistantText.push(...blockEntries(message.timestamp, "text", texts));
180
+ // Pi drops another model's thinking and tool-call signatures; like deleted content, it gets no preview
181
+ if (nextRequestModel === undefined || isSameModel(message, nextRequestModel)) {
182
+ assistantThinking.push(...thinkingEntries(message));
183
+ }
184
+ for (const block of message.content) {
185
+ if (block.type !== "toolCall") continue;
186
+ const args = JSON.stringify(block.arguments);
187
+ assistantToolCalls.push({
188
+ timestamp: message.timestamp,
189
+ breadcrumb: ["assistant", block.name],
190
+ tokens: textTokens(block.name.length + args.length),
191
+ text: `${block.name}(${args})`,
192
+ // The arguments sit between the call parentheses this entry adds around them.
193
+ jsonSpan: { start: block.name.length + 1, end: block.name.length + 1 + args.length },
194
+ });
195
+ }
196
+ break;
197
+ }
198
+ case "toolResult":
199
+ toolResults.push(message);
200
+ break;
201
+ case "custom":
202
+ customMessages.push(message);
203
+ break;
204
+ case "bashExecution": {
205
+ const tokens = transformedTokens(message);
206
+ // The transform drops `!!` bash executions: they never reach the provider.
207
+ if (tokens !== undefined) {
208
+ bashExecutions.push({
209
+ timestamp: message.timestamp,
210
+ breadcrumb: ["bash"],
211
+ tokens,
212
+ text: `$ ${message.command}\n${message.output}`,
213
+ });
214
+ }
215
+ break;
216
+ }
217
+ case "branchSummary":
218
+ compacted.push({
219
+ timestamp: message.timestamp,
220
+ breadcrumb: ["branch"],
221
+ tokens: transformedTokens(message) ?? 0,
222
+ text: message.summary,
223
+ });
224
+ break;
225
+ case "compactionSummary":
226
+ compacted.push({
227
+ timestamp: message.timestamp,
228
+ breadcrumb: ["compaction"],
229
+ tokens: transformedTokens(message) ?? 0,
230
+ text: message.summary,
231
+ });
232
+ break;
233
+ }
234
+ }
235
+
236
+ const toolOutput = aggregate("tool-output", "Tool Output", withoutEmpty([
237
+ ...leavesFromGroups("tool-result", Map.groupBy(toolResults, (message) => message.toolName)),
238
+ leaf("bash-executions", "Bash Executions", bashExecutions),
239
+ ]));
240
+ return withoutEmpty([
241
+ leaf("user-messages", "User Messages", user),
242
+ leaf("assistant-messages", "Assistant Messages", assistantText),
243
+ leaf("assistant-thinking", "Assistant Thinking", assistantThinking),
244
+ leaf("tool-calls", "Tool Calls", assistantToolCalls),
245
+ toolOutput,
246
+ // One category per injected customType
247
+ aggregate("extensions", "Extensions",
248
+ leavesFromGroups("custom-message", Map.groupBy(customMessages, (message) => message.customType))),
249
+ leaf("compacted-data", "Compacted Data", compacted),
250
+ ]);
251
+ }
252
+
253
+ /** One category whose estimate is the exact sum of its preview entries. */
254
+ function leaf(id: string, label: string, entries: readonly UsagePreviewEntry[]): UsageCategory {
255
+ return {
256
+ id,
257
+ label,
258
+ tokens: entries.reduce((sum, entry) => sum + entry.tokens, 0),
259
+ entries,
260
+ };
261
+ }
262
+
263
+ /** Category carrying a snapshot item's label, estimate, and timeless content entry. */
264
+ function leafFromItem(item: InjectionItem): UsageCategory {
265
+ return {
266
+ id: `item:${item.id}`,
267
+ label: item.label,
268
+ tokens: item.tokens,
269
+ entries: [{
270
+ breadcrumb: [item.label],
271
+ tokens: item.tokens,
272
+ text: item.text,
273
+ jsonSpan: item.jsonSpan,
274
+ // Tool parts stay a preview breakdown of the same estimate, never separate entries.
275
+ sections: item.sections,
276
+ change: item.change,
277
+ }],
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Tokens for a message as pi's LLM transform actually sends it, so wrapper text
283
+ * `estimateTokens` alone cannot see (summary tags, `Ran ...` bash framing) is
284
+ * counted. Returns undefined for messages the transform excludes from context.
285
+ */
286
+ function transformedTokens(message: ContextEvent["messages"][number]): number | undefined {
287
+ const converted = convertToLlm([message])[0];
288
+ return converted === undefined ? undefined : estimateTokens(converted);
289
+ }
290
+
291
+ /** Assistant-message shape narrowed out of the context event union. */
292
+ type AssistantContextMessage = Extract<ContextEvent["messages"][number], { role: "assistant" }>;
293
+
294
+ /**
295
+ * Build thinking previews while accounting once per assistant message. Provider-reported
296
+ * reasoning replaces a smaller visible-text estimate; signature-only estimates stay approximate metadata.
297
+ */
298
+ function thinkingEntries(message: AssistantContextMessage): UsagePreviewEntry[] {
299
+ const texts = message.content.flatMap((block) => (block.type === "thinking" ? [block.thinking] : []));
300
+ const visibleTokens = textTokens(texts.reduce((sum, text) => sum + text.length, 0));
301
+ const reportedTokens = reportedReasoningTokens(message);
302
+ const countedTokens = Math.max(visibleTokens, reportedTokens ?? 0);
303
+ const signatureChars = message.content.reduce((sum, block) => {
304
+ if (block.type === "thinking") return sum + stringLength(block.thinkingSignature);
305
+ if (block.type === "toolCall") return sum + stringLength(block.thoughtSignature);
306
+ return sum;
307
+ }, 0);
308
+ const invisibleReasoning = createInvisibleReasoningEstimate(signatureChars, visibleTokens, reportedTokens);
309
+
310
+ if (texts.length === 0) {
311
+ if (countedTokens === 0 && invisibleReasoning === undefined) return [];
312
+ return [{
313
+ timestamp: message.timestamp,
314
+ breadcrumb: ["assistant"],
315
+ tokens: countedTokens,
316
+ ...(invisibleReasoning === undefined ? {} : { visibleTokens: 0, invisibleReasoning }),
317
+ text: "",
318
+ }];
319
+ }
320
+
321
+ const allocations = allocateTextTokens(texts);
322
+ const entries = texts.map((text, index): UsagePreviewEntry => ({
323
+ timestamp: message.timestamp,
324
+ breadcrumb: texts.length > 1
325
+ ? ["assistant", `thinking ${index + 1}/${texts.length}`]
326
+ : ["assistant"],
327
+ tokens: allocations[index] ?? 0,
328
+ text,
329
+ }));
330
+ const first = entries[0];
331
+ if (first !== undefined) {
332
+ const countedInvisibleTokens = countedTokens - visibleTokens;
333
+ entries[0] = {
334
+ ...first,
335
+ tokens: first.tokens + countedInvisibleTokens,
336
+ ...(invisibleReasoning === undefined
337
+ ? {}
338
+ : { visibleTokens: first.tokens, invisibleReasoning }),
339
+ };
340
+ }
341
+ return entries;
342
+ }
343
+
344
+ /** Describe a positive invisible share or signature-size proxy without retaining its source bytes. */
345
+ function createInvisibleReasoningEstimate(
346
+ signatureChars: number,
347
+ visibleTokens: number,
348
+ reportedTokens: number | undefined,
349
+ ): InvisibleReasoningEstimate | undefined {
350
+ if (reportedTokens !== undefined) {
351
+ const tokens = Math.max(0, reportedTokens - visibleTokens);
352
+ if (tokens === 0) return undefined;
353
+ return {
354
+ tokens,
355
+ basis: "provider-reported",
356
+ encoded: signatureChars > 0,
357
+ };
358
+ }
359
+ if (signatureChars === 0) return undefined;
360
+ // chars/4 over a signature is a rough proxy, not a bound: the server decrypts the
361
+ // envelope and counts the reconstructed thinking, at a model-dependent ratio that
362
+ // measured either side of 4. See doc/THINKING.md.
363
+ return {
364
+ tokens: textTokens(signatureChars),
365
+ basis: "signature-proxy",
366
+ encoded: true,
367
+ };
368
+ }
369
+
370
+ /** Accept only a finite, non-negative provider reasoning count from possibly old session data. */
371
+ function reportedReasoningTokens(message: AssistantContextMessage): number | undefined {
372
+ const usage: unknown = message.usage;
373
+ if (typeof usage !== "object" || usage === null) return undefined;
374
+ const reasoning = (usage as { reasoning?: unknown }).reasoning;
375
+ return typeof reasoning === "number" && Number.isFinite(reasoning) && reasoning >= 0
376
+ ? reasoning
377
+ : undefined;
378
+ }
379
+
380
+ /** Pool chars/4 rounding across all blocks while retaining one preview entry per block. */
381
+ function allocateTextTokens(texts: readonly string[]): number[] {
382
+ const allocations = texts.map((text) => Math.floor(text.length / 4));
383
+ const target = textTokens(texts.reduce((sum, text) => sum + text.length, 0));
384
+ let remainder = target - allocations.reduce((sum, tokens) => sum + tokens, 0);
385
+ const rankedIndexes = texts
386
+ .map((text, index) => ({ index, remainder: text.length % 4 }))
387
+ .filter((candidate) => candidate.remainder > 0)
388
+ .sort((a, b) => b.remainder - a.remainder || a.index - b.index);
389
+ for (const candidate of rankedIndexes) {
390
+ if (remainder === 0) break;
391
+ allocations[candidate.index] = (allocations[candidate.index] ?? 0) + 1;
392
+ remainder--;
393
+ }
394
+ return allocations;
395
+ }
396
+
397
+ /** String length from an optional or untyped signature field without exposing its contents. */
398
+ function stringLength(value: unknown): number {
399
+ return typeof value === "string" ? value.length : 0;
400
+ }
401
+
402
+ /** Per-block entries; the block-index cell appears only for multi-block messages. */
403
+ function blockEntries(timestamp: number, kind: string, texts: readonly string[]): UsagePreviewEntry[] {
404
+ return texts.map((text, index) => ({
405
+ timestamp,
406
+ breadcrumb: texts.length > 1 ? ["assistant", `${kind} ${index + 1}/${texts.length}`] : ["assistant"],
407
+ tokens: textTokens(text.length),
408
+ text,
409
+ }));
410
+ }
411
+
412
+ /** Text rendering of string-or-block message content; non-text blocks become placeholders. */
413
+ function contentToText(content: string | ReadonlyArray<{ type: string; text?: string }>): string {
414
+ if (typeof content === "string") return content;
415
+ return content
416
+ .map((block) => (block.type === "text" && block.text !== undefined ? block.text : `[${block.type}]`))
417
+ .join("\n");
418
+ }
419
+
420
+ /** Expand an aggregate snapshot item into its children, or itself when it has none. */
421
+ function breakdownFromItem(item: InjectionItem): UsageCategory[] {
422
+ if (item.children === undefined || item.children.length === 0) return [leafFromItem(item)];
423
+ return item.children.map((child) => leafFromItem(child));
424
+ }
425
+
426
+ /** Parent category whose total is the exact sum of its children; undefined when empty. */
427
+ function aggregate(id: string, label: string, children: UsageCategory[]): UsageCategory | undefined {
428
+ if (children.length === 0) return undefined;
429
+ return {
430
+ id,
431
+ label,
432
+ tokens: children.reduce((sum, child) => sum + child.tokens, 0),
433
+ children: [...children].sort((a, b) => b.tokens - a.tokens),
434
+ };
435
+ }
436
+
437
+ /** Keep only present categories with a non-zero estimate. */
438
+ function withoutEmpty(categories: Array<UsageCategory | undefined>): UsageCategory[] {
439
+ return categories.filter((category): category is UsageCategory => category !== undefined && category.tokens > 0);
440
+ }
441
+
442
+ /** One leaf per group key, with a preview entry for each of its messages. */
443
+ function leavesFromGroups(
444
+ idPrefix: string,
445
+ groups: ReadonlyMap<string, ReadonlyArray<ToolResultContextMessage | CustomContextMessage>>,
446
+ ): UsageCategory[] {
447
+ return [...groups].map(([label, messages]) => {
448
+ const entries = messages.map((message) => ({
449
+ timestamp: message.timestamp,
450
+ breadcrumb: [label],
451
+ tokens: estimateTokens(message),
452
+ text: contentToText(message.content),
453
+ }));
454
+ return leaf(`${idPrefix}:${label}`, label, entries);
455
+ });
456
+ }
457
+
458
+ /** Same chars/4 heuristic pi's estimateTokens uses for text content. */
459
+ function textTokens(chars: number): number {
460
+ return Math.ceil(chars / 4);
461
+ }