@maxiaochao/pi-toolkit 0.2.1 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2026-09-08 - v0.2.3
2
+
3
+ - add cache export Context segment views
4
+
5
+ ## 2026-09-03 - v0.2.2
6
+
7
+ - let the focused `/btw` overlay scroll its full transcript with Up/Down and PageUp/PageDown
8
+
1
9
  ## 2026-09-02 - v0.2.1
2
10
 
3
11
  - update global agent instructions for harness-native file editing
@@ -47,7 +55,11 @@
47
55
 
48
56
  - tbd
49
57
 
50
- # Changelog
58
+ ## Unreleased
59
+
60
+ - let `/cache_export` switch between all history and individual compaction-defined Context segments, defaulting to the latest segment; cache-hit cumulative lines reset at compaction or model boundaries
61
+ - add the independent `@maxiaochao/pi-codex-edit` package with model-aware Codex-style `apply_patch`
62
+ - include the benchmark summary and interactive HTML architecture explainer
51
63
 
52
64
  ## 2026-08-18 - v0.1.0
53
65
 
package/README.md CHANGED
@@ -38,7 +38,9 @@ To synchronize the file directly from a checkout instead:
38
38
  bash scripts/install.sh
39
39
  ```
40
40
 
41
- The `/btw` side chat is a separate agent session. It can inspect the main conversation and repository, and its thread stays out of the main context until you close it and choose to inject a summary. Use `/btw` to open it or `/btw <question>` to ask directly.
41
+ The `/btw` side chat is a separate agent session. It can inspect the main conversation and repository, and its thread stays out of the main context until you close it and choose to inject a summary. Use `/btw` to open it or `/btw <question>` to ask directly. While the BTW overlay has focus, use `Up` / `Down` to scroll its transcript one line at a time and `PageUp` / `PageDown` to move by one transcript page.
42
+
43
+ Use `/cache_export [path]` to write an interactive per-request cache dashboard. It defaults to the latest compaction-defined Context segment for current-state diagnosis, and the View selector can show any earlier segment or all session history. Compaction breaks context charts; cache-hit accumulation also resets at model changes, while TTL and other inferred interruptions remain diagnostic events rather than segment boundaries.
42
44
 
43
45
  The bundled `nightowl` theme is available to Pi through the package manifest and is selected automatically on npm installation only when no theme is already configured.
44
46
 
@@ -68,6 +70,22 @@ pi update npm:@maxiaochao/pi-tiny-subagent
68
70
 
69
71
  See [packages/tiny-subagent/README.md](packages/tiny-subagent/README.md) for personas, model selection, child isolation, live transcript rendering, and diagnostics.
70
72
 
73
+ ## Codex Edit
74
+
75
+ The Codex edit package is a separate npm package. Install it when you want GPT/Codex models to use the freeform `apply_patch` protocol while other models keep Pi's native `edit` tool:
76
+
77
+ ```bash
78
+ pi install npm:@maxiaochao/pi-codex-edit
79
+ ```
80
+
81
+ It includes the extension implementation plus the benchmark summary and interactive architecture explainer. Its versions, release workflow, and tag are independent from the root package and tiny subagent:
82
+
83
+ ```bash
84
+ pi update npm:@maxiaochao/pi-codex-edit
85
+ ```
86
+
87
+ See [packages/codex-edit/README.md](packages/codex-edit/README.md) for the routing rules and [packages/codex-edit/summary.md](packages/codex-edit/summary.md) for the measured tradeoffs.
88
+
71
89
  ## Development
72
90
 
73
91
  Repository development and release rules are documented in [AGENTS.md](AGENTS.md).
@@ -0,0 +1,9 @@
1
+ export function getBtwPageScrollSize(viewportHeight: number): number {
2
+ return Math.max(1, Math.floor(viewportHeight));
3
+ }
4
+
5
+ export function clampBtwScrollTop(scrollTop: number, contentHeight: number, viewportHeight: number): number {
6
+ const maxScrollTop = Math.max(0, Math.floor(contentHeight) - Math.floor(viewportHeight));
7
+ const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : 0;
8
+ return Math.max(0, Math.min(maxScrollTop, requested));
9
+ }
package/extensions/btw.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type OverlayHandle,
24
24
  type TUI,
25
25
  } from "@earendil-works/pi-tui";
26
+ import { clampBtwScrollTop, getBtwPageScrollSize } from "./btw-scroll.ts";
26
27
 
27
28
  const BTW_ENTRY_TYPE = "btw-thread-entry";
28
29
  const BTW_RESET_TYPE = "btw-thread-reset";
@@ -59,6 +60,7 @@ type OverlayRuntime = {
59
60
  close?: () => void;
60
61
  finish?: () => void;
61
62
  setDraft?: (value: string) => void;
63
+ scrollToEnd?: () => void;
62
64
  closed?: boolean;
63
65
  };
64
66
 
@@ -201,6 +203,10 @@ class BtwOverlay extends Container implements Focusable {
201
203
  private readonly getStatus: () => string;
202
204
  private readonly onSubmitCallback: (value: string) => void;
203
205
  private readonly onDismissCallback: () => void;
206
+ private transcriptScrollTop = 0;
207
+ private transcriptFollowingEnd = true;
208
+ private transcriptContentHeight = 0;
209
+ private transcriptViewportHeight = 0;
204
210
  private _focused = false;
205
211
 
206
212
  get focused(): boolean {
@@ -245,9 +251,65 @@ class BtwOverlay extends Container implements Focusable {
245
251
  return;
246
252
  }
247
253
 
254
+ if (this.keybindings.matches(data, "tui.select.up")) {
255
+ this.scrollTranscriptBy(-1);
256
+ return;
257
+ }
258
+
259
+ if (this.keybindings.matches(data, "tui.select.down")) {
260
+ this.scrollTranscriptBy(1);
261
+ return;
262
+ }
263
+
264
+ const pageSize = getBtwPageScrollSize(this.transcriptViewportHeight);
265
+ if (this.keybindings.matches(data, "tui.select.pageUp")) {
266
+ this.scrollTranscriptBy(-pageSize);
267
+ return;
268
+ }
269
+
270
+ if (this.keybindings.matches(data, "tui.select.pageDown")) {
271
+ this.scrollTranscriptBy(pageSize);
272
+ return;
273
+ }
274
+
248
275
  this.input.handleInput(data);
249
276
  }
250
277
 
278
+ private scrollTranscriptBy(lines: number): void {
279
+ const maxScrollTop = Math.max(0, this.transcriptContentHeight - this.transcriptViewportHeight);
280
+ const currentScrollTop = this.transcriptFollowingEnd
281
+ ? maxScrollTop
282
+ : clampBtwScrollTop(this.transcriptScrollTop, this.transcriptContentHeight, this.transcriptViewportHeight);
283
+ const nextScrollTop = clampBtwScrollTop(
284
+ currentScrollTop + Math.trunc(lines),
285
+ this.transcriptContentHeight,
286
+ this.transcriptViewportHeight,
287
+ );
288
+ const nextFollowingEnd = nextScrollTop === maxScrollTop;
289
+ if (nextScrollTop === currentScrollTop && nextFollowingEnd === this.transcriptFollowingEnd) {
290
+ return;
291
+ }
292
+
293
+ this.transcriptScrollTop = nextScrollTop;
294
+ this.transcriptFollowingEnd = nextFollowingEnd;
295
+ this.tui.requestRender();
296
+ }
297
+
298
+ scrollTranscriptToEnd(): void {
299
+ const nextScrollTop = clampBtwScrollTop(
300
+ this.transcriptContentHeight,
301
+ this.transcriptContentHeight,
302
+ this.transcriptViewportHeight,
303
+ );
304
+ if (this.transcriptFollowingEnd && this.transcriptScrollTop === nextScrollTop) {
305
+ return;
306
+ }
307
+
308
+ this.transcriptScrollTop = nextScrollTop;
309
+ this.transcriptFollowingEnd = true;
310
+ this.tui.requestRender();
311
+ }
312
+
251
313
  setDraft(value: string): void {
252
314
  this.input.setValue(value);
253
315
  this.tui.requestRender();
@@ -279,7 +341,19 @@ class BtwOverlay extends Container implements Focusable {
279
341
 
280
342
  // Markdown renders to innerWidth already — no manual wrapping needed
281
343
  const transcript = this.getTranscript(innerWidth, this.theme);
282
- const visibleTranscript = transcript.slice(-transcriptHeight);
344
+ this.transcriptContentHeight = transcript.length;
345
+ this.transcriptViewportHeight = transcriptHeight;
346
+ const maxScrollTop = Math.max(0, this.transcriptContentHeight - this.transcriptViewportHeight);
347
+ if (this.transcriptFollowingEnd) {
348
+ this.transcriptScrollTop = maxScrollTop;
349
+ } else {
350
+ this.transcriptScrollTop = clampBtwScrollTop(
351
+ this.transcriptScrollTop,
352
+ this.transcriptContentHeight,
353
+ this.transcriptViewportHeight,
354
+ );
355
+ }
356
+ const visibleTranscript = transcript.slice(this.transcriptScrollTop, this.transcriptScrollTop + transcriptHeight);
283
357
  const transcriptPadding = Math.max(0, transcriptHeight - visibleTranscript.length);
284
358
 
285
359
  const status = this.getStatus();
@@ -308,7 +382,7 @@ class BtwOverlay extends Container implements Focusable {
308
382
  lines.push(
309
383
  `${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`,
310
384
  );
311
- lines.push(this.frameLine(this.theme.fg("dim", "Enter submit · Esc close"), innerWidth));
385
+ lines.push(this.frameLine(this.theme.fg("dim", "↑/↓ scroll · PgUp/PgDn page · Enter submit · Esc close"), innerWidth));
312
386
  lines.push(this.borderLine(innerWidth, "bottom"));
313
387
 
314
388
  return lines;
@@ -396,7 +470,7 @@ export default function (pi: ExtensionAPI) {
396
470
  }
397
471
 
398
472
  const lines: string[] = [];
399
- for (const item of thread.slice(-6)) {
473
+ for (const item of thread) {
400
474
  // User message
401
475
  const userText = item.question.trim().split("\n")[0];
402
476
  lines.push(theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"));
@@ -509,6 +583,7 @@ export default function (pi: ExtensionAPI) {
509
583
  setOverlayDraft("");
510
584
  setOverlayStatus("Ready");
511
585
  await disposeSideSession();
586
+ overlayRuntime?.scrollToEnd?.();
512
587
  if (persist) {
513
588
  const details: BtwResetDetails = { timestamp: Date.now() };
514
589
  pi.appendEntry(BTW_RESET_TYPE, details);
@@ -546,6 +621,7 @@ export default function (pi: ExtensionAPI) {
546
621
  thread.push(details);
547
622
  }
548
623
 
624
+ overlayRuntime?.scrollToEnd?.();
549
625
  syncOverlay();
550
626
  }
551
627
 
@@ -691,6 +767,7 @@ export default function (pi: ExtensionAPI) {
691
767
  overlay.focused = true;
692
768
  overlay.setDraft(overlayDraft);
693
769
  runtime.setDraft = (value) => overlay.setDraft(value);
770
+ runtime.scrollToEnd = () => overlay.scrollTranscriptToEnd();
694
771
  runtime.refresh = () => {
695
772
  overlay.focused = runtime.handle?.isFocused() ?? false;
696
773
  tui.requestRender();
@@ -836,6 +913,7 @@ export default function (pi: ExtensionAPI) {
836
913
  return;
837
914
  }
838
915
 
916
+ overlayRuntime?.scrollToEnd?.();
839
917
  const side = await ensureSideSession(ctx);
840
918
  if (!side) {
841
919
  notify(ctx, "Unable to create BTW side session.", "error");
@@ -61,7 +61,7 @@ function defaultOutputPath(ctx) {
61
61
 
62
62
  export default function (pi) {
63
63
  pi.registerCommand("cache_export", {
64
- description: "Export a tau-style interactive cache dashboard (hit-rate charts, per-request table, events)",
64
+ description: "Export an interactive cache dashboard (latest Context segment by default, all-history switch, per-request charts)",
65
65
  handler: async (args, ctx) => {
66
66
  const sessionManager = ctx.sessionManager;
67
67
  const entries = typeof sessionManager.getBranch === "function"
@@ -71,10 +71,12 @@ export function parseEntries(entries) {
71
71
  }
72
72
 
73
73
  // pi stores tool calls inside assistant content blocks
74
+ const calledTools = [];
74
75
  for (const blk of m.content || []) {
75
76
  if (blk && typeof blk === "object" && blk.type === "toolCall") {
76
77
  const name = blk.tool || blk.name || "?";
77
78
  toolCounts[name] = (toolCounts[name] || 0) + 1;
79
+ calledTools.push(name);
78
80
  }
79
81
  }
80
82
 
@@ -129,6 +131,7 @@ export function parseEntries(entries) {
129
131
  stopReason: m.stopReason || "",
130
132
  estimatedCost: cost,
131
133
  gapTools,
134
+ calledTools,
132
135
  prevOutput: requests.length ? requests[requests.length - 1].output : 0,
133
136
  });
134
137
  gapTools = [];
@@ -184,11 +187,12 @@ const fmtPct1 = (x) => (x * 100).toFixed(1) + "%";
184
187
  // ---------------------------------------------------------------------------
185
188
 
186
189
  function lineChart(title, series, opts = {}) {
187
- const { yMax = null, percent = false, timestamps = null, events = [] } = opts;
190
+ const { yMax = null, percent = false, timestamps = null, events = [], breakBefore = [], requestNumbers = null } = opts;
188
191
  const width = 900, height = 330, left = 68, right = 20, top = 42, bottom = 52;
189
192
  const plotWidth = width - left - right, plotHeight = height - top - bottom;
190
193
  const count = Math.max(0, ...series.map(([, v]) => v.length));
191
- const observedMax = Math.max(0, ...series.map(([, v]) => Math.max(0, ...v)));
194
+ const observedMax = Math.max(0, ...series.flatMap(([, values]) => values.filter(Number.isFinite)));
195
+ const breaks = new Set(breakBefore);
192
196
  let maximum = yMax || Math.max(observedMax, 1);
193
197
  if (!percent) {
194
198
  const magnitude = 10 ** Math.max(0, String(Math.trunc(maximum)).length - 1);
@@ -217,7 +221,7 @@ function lineChart(title, series, opts = {}) {
217
221
  for (let index = 0; index < count; index++) {
218
222
  if (count <= 20 || index % Math.max(1, Math.trunc(count / 12)) === 0 || index === count - 1) {
219
223
  const [x] = point(index, 0);
220
- parts.push(`<text class="tick" x="${x.toFixed(1)}" y="${height - 22}" text-anchor="middle">${index + 1}</text>`);
224
+ parts.push(`<text class="tick" x="${x.toFixed(1)}" y="${height - 22}" text-anchor="middle">${requestNumbers?.[index] ?? index + 1}</text>`);
221
225
  }
222
226
  }
223
227
  parts.push(
@@ -240,11 +244,23 @@ function lineChart(title, series, opts = {}) {
240
244
  });
241
245
  series.forEach(([name, values], seriesIndex) => {
242
246
  const [dark, light] = seriesColor(name);
243
- const points = values.map((value, i) => point(i, value).map((c) => c.toFixed(1)).join(",")).join(" ");
244
- const labels = values.map((v) => (percent ? `${(v * 100).toFixed(1)}%` : fmtInt(Math.trunc(v)))).join("|");
247
+ const runs = [];
248
+ let run = [];
249
+ values.forEach((value, i) => {
250
+ if (breaks.has(i) || !Number.isFinite(value)) {
251
+ if (run.length) runs.push(run);
252
+ run = [];
253
+ }
254
+ if (Number.isFinite(value)) run.push(point(i, value).map((c) => c.toFixed(1)).join(","));
255
+ });
256
+ if (run.length) runs.push(run);
257
+ const polylines = runs.map((points) =>
258
+ `<polyline class="series-line" data-dark="${dark}" data-light="${light}" points="${points.join(" ")}" fill="none" stroke="${dark}" stroke-width="3"/>`,
259
+ ).join("");
260
+ const labels = values.map((v) => !Number.isFinite(v) ? "N/A" : (percent ? `${(v * 100).toFixed(1)}%` : fmtInt(Math.trunc(v)))).join("|");
245
261
  parts.push(
246
262
  `<g class="series" data-series-id="${seriesIndex}" data-name="${esc(name, true)}" data-labels="${esc(labels, true)}">` +
247
- `<polyline class="series-line" data-dark="${dark}" data-light="${light}" points="${points}" fill="none" stroke="${dark}" stroke-width="3"/>` +
263
+ polylines +
248
264
  `<circle class="hover-point" data-dark="${dark}" data-light="${light}" r="5" fill="${dark}" visibility="hidden"/></g>`,
249
265
  );
250
266
  });
@@ -488,60 +504,121 @@ function renderMissPanel(miss) {
488
504
  // render_usage_dashboard port
489
505
  // ---------------------------------------------------------------------------
490
506
 
491
- export function renderDashboard(data) {
492
- const { requests, events, toolCalls, compactions } = data;
493
- if (!requests.length) {
494
- return `<p class="empty">No assistant responses with token usage were found in this session.</p>`;
507
+ export function buildContextSegments(requests, events) {
508
+ const starts = new Set(events
509
+ .filter((event) => event.kind === "compaction" && event.requestNumber >= 1 && event.requestNumber <= requests.length)
510
+ .map((event) => event.requestNumber));
511
+ const segments = [];
512
+ let start = 0;
513
+ for (let index = 1; index < requests.length; index++) {
514
+ if (!starts.has(requests[index].number)) continue;
515
+ segments.push({ number: segments.length + 1, requests: requests.slice(start, index) });
516
+ start = index;
495
517
  }
518
+ segments.push({ number: segments.length + 1, requests: requests.slice(start) });
519
+ return segments;
520
+ }
496
521
 
522
+ function countToolCalls(requests) {
523
+ const counts = new Map();
524
+ for (const request of requests) {
525
+ for (const name of request.calledTools || []) counts.set(name, (counts.get(name) || 0) + 1);
526
+ }
527
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
528
+ }
529
+
530
+ function eventsForRequests(events, requests) {
531
+ if (!requests.length) return [];
532
+ const from = requests[0].number;
533
+ const to = requests[requests.length - 1].number;
534
+ return events.filter((event) => event.requestNumber >= from && event.requestNumber <= to);
535
+ }
536
+
537
+ function chartEvents(events, requests) {
538
+ if (!requests.length) return [];
539
+ const first = requests[0].number;
540
+ return events.map((event) => ({ ...event, requestNumber: event.requestNumber - first + 1 }));
541
+ }
542
+
543
+ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabel) {
497
544
  const timestamps = requests.map((r) => r.timestamp);
545
+ const requestNumbers = requests.map((r) => r.number);
498
546
  const cached = requests.map((r) => r.cached);
499
547
  const cacheWrites = requests.map((r) => r.cacheWrite);
500
548
  const fresh = requests.map((r) => r.fresh);
501
549
  const outputs = requests.map((r) => r.output);
502
550
  const reasoning = requests.map((r) => r.reasoning);
503
551
  const rates = requests.map((r) => (r.fresh + r.cached + r.cacheWrite > 0 ? r.cached / (r.fresh + r.cached + r.cacheWrite) : 0));
552
+ const contextBreakBefore = new Set();
553
+ const cacheBreakBefore = new Set();
554
+ const resetBefore = new Set(events
555
+ .filter((event) => event.kind === "compaction" || event.kind === "model")
556
+ .map((event) => event.requestNumber));
504
557
  const cumulativeRates = [];
505
558
  let runCached = 0, runPrompt = 0;
506
- for (const r of requests) {
559
+ let previousKey = null;
560
+ let cacheWindows = 1;
561
+ let currentWindowStart = 0;
562
+ for (let index = 0; index < requests.length; index++) {
563
+ const r = requests[index];
564
+ const key = `${r.provider}/${r.model}`;
565
+ const contextReset = index > 0 && events.some((event) => event.kind === "compaction" && event.requestNumber === r.number);
566
+ const cacheReset = index > 0 && (resetBefore.has(r.number) || (previousKey !== null && key !== previousKey));
567
+ if (contextReset) contextBreakBefore.add(index);
568
+ if (cacheReset) {
569
+ cacheBreakBefore.add(index);
570
+ cacheWindows += 1;
571
+ currentWindowStart = index;
572
+ runCached = 0;
573
+ runPrompt = 0;
574
+ }
507
575
  runCached += r.cached;
508
576
  runPrompt += r.fresh + r.cached + r.cacheWrite;
509
577
  cumulativeRates.push(runPrompt > 0 ? runCached / runPrompt : 0);
578
+ previousKey = key;
510
579
  }
511
580
 
512
581
  const tot = (f) => requests.reduce((s, r) => s + f(r), 0);
513
582
  const totalFresh = tot((r) => r.fresh), totalCached = tot((r) => r.cached),
514
583
  totalWrite = tot((r) => r.cacheWrite), totalPrompt = totalFresh + totalCached + totalWrite,
515
584
  totalOutput = tot((r) => r.output);
516
-
517
585
  const cacheHitRate = totalPrompt > 0 && (totalCached > 0 || totalWrite > 0) ? totalCached / totalPrompt : null;
586
+ const windowRequests = requests.slice(currentWindowStart);
587
+ const windowPrompt = windowRequests.reduce((sum, r) => sum + r.fresh + r.cached + r.cacheWrite, 0);
588
+ const windowCached = windowRequests.reduce((sum, r) => sum + r.cached, 0);
589
+ const windowHitRate = windowPrompt > 0 && (windowCached > 0 || windowRequests.some((r) => r.cacheWrite > 0))
590
+ ? windowCached / windowPrompt
591
+ : null;
518
592
 
519
593
  const cards = [
594
+ ["Scope", scopeLabel],
520
595
  ["Model requests", fmtInt(requests.length)],
521
- ["Cache hit rate", cacheHitRate !== null ? fmtPct1(cacheHitRate) : "N/A"],
596
+ ["View cache hit rate", cacheHitRate !== null ? fmtPct1(cacheHitRate) : "N/A"],
597
+ ["Latest cache window", `#${cacheWindows} · ${windowHitRate !== null ? fmtPct1(windowHitRate) : "N/A"}`],
522
598
  ["Cached input", fmtInt(totalCached)],
523
599
  ["Reported cache writes", fmtInt(totalWrite)],
524
600
  ["Fresh input", fmtInt(totalFresh)],
525
601
  ["Total prompt input", fmtInt(totalPrompt)],
526
602
  ["Output tokens", fmtInt(totalOutput)],
527
- ["Compactions", String(compactions)],
603
+ ["Compactions in view", String(compactions)],
528
604
  ];
529
605
  const cardsHtml = cards
530
606
  .map(([l, v]) => `<div class="usage-card"><span>${esc(l)}</span><strong>${esc(v)}</strong></div>`)
531
607
  .join("");
532
608
 
609
+ const eventMarkers = chartEvents(events, requests);
533
610
  const charts = [
534
611
  figure(lineChart("Prompt input by request",
535
612
  [["cached", cached], ["cache writes", cacheWrites], ["fresh", fresh]],
536
- { timestamps, events })),
613
+ { timestamps, requestNumbers, events: eventMarkers, breakBefore: contextBreakBefore })),
537
614
  ];
538
615
  if (cacheHitRate !== null) {
539
616
  charts.push(figure(lineChart("Cache hit rate",
540
617
  [["request", rates], ["cumulative", cumulativeRates]],
541
- { yMax: 1.0, percent: true, timestamps })));
618
+ { yMax: 1.0, percent: true, timestamps, requestNumbers, events: eventMarkers, breakBefore: cacheBreakBefore })));
542
619
  }
543
620
  charts.push(figure(lineChart("Output and reasoning tokens",
544
- [["output", outputs], ["reasoning", reasoning]], { timestamps })));
621
+ [["output", outputs], ["reasoning", reasoning]], { timestamps, requestNumbers, events: eventMarkers, breakBefore: contextBreakBefore })));
545
622
 
546
623
  const showHit = cacheHitRate !== null;
547
624
  const rows = requests
@@ -561,9 +638,8 @@ export function renderDashboard(data) {
561
638
 
562
639
  return (
563
640
  `<div class="usage-cards">${cardsHtml}</div>` +
564
- `<p class="usage-note">Hover a request for exact values, ` +
565
- `select a legend item to hide a series, and use PNG to save a chart. Event markers show ` +
566
- `compactions, model or thinking changes. Some providers report cache reads but not cache writes.</p>` +
641
+ `<p class="usage-note">Prompt and output lines break at a Context segment boundary (compaction). Cache-window cumulative hit rate also resets at a model change. ` +
642
+ `TTL and service interruptions are shown only when recorded or inferred by the miss analysis; they do not create a Context segment.</p>` +
567
643
  `<div class="usage-charts">${charts.join("")}</div>` +
568
644
  renderMissPanel(analyzeMisses(requests, events)) +
569
645
  renderStreakPanel(analyzeStreaks(requests, events)) +
@@ -577,6 +653,46 @@ export function renderDashboard(data) {
577
653
  );
578
654
  }
579
655
 
656
+ export function renderDashboard(data) {
657
+ const { requests, events, toolCalls, compactions } = data;
658
+ if (!requests.length) {
659
+ return `<p class="empty">No assistant responses with token usage were found in this session.</p>`;
660
+ }
661
+
662
+ const segments = buildContextSegments(requests, events);
663
+ const latest = segments[segments.length - 1];
664
+ const allView = renderDashboardView(requests, events, toolCalls, compactions, `All ${segments.length} context segment${segments.length === 1 ? "" : "s"}`);
665
+ const segmentViews = segments.map((segment) => {
666
+ const segmentEvents = eventsForRequests(events, segment.requests);
667
+ const startsWithCompaction = segmentEvents.some((event) => event.kind === "compaction" && event.requestNumber === segment.requests[0].number);
668
+ const label = `Context segment ${segment.number}${segment.number === latest.number ? " (latest)" : ""}`;
669
+ return {
670
+ id: `segment-${segment.number}`,
671
+ label,
672
+ html: renderDashboardView(
673
+ segment.requests,
674
+ segmentEvents,
675
+ countToolCalls(segment.requests),
676
+ startsWithCompaction ? 1 : 0,
677
+ label,
678
+ ),
679
+ };
680
+ });
681
+ const options = [
682
+ `<option value="all">All context segments</option>`,
683
+ ...segmentViews.map((view) => `<option value="${view.id}"${view.id === `segment-${latest.number}` ? " selected" : ""}>${esc(view.label)}</option>`),
684
+ ].join("");
685
+ const views = [
686
+ `<section class="usage-context-view" data-context-view="all" hidden>${allView}</section>`,
687
+ ...segmentViews.map((view) => `<section class="usage-context-view" data-context-view="${view.id}"${view.id === `segment-${latest.number}` ? "" : " hidden"}>${view.html}</section>`),
688
+ ].join("");
689
+
690
+ return `<div class="usage-view-controls"><label for="context-segment-select">View</label>` +
691
+ `<select id="context-segment-select" data-context-segment-select>${options}</select>` +
692
+ `<span>Default: latest Context segment. All-history values remain available; cache metrics reset where compaction rewrites the prefix or the model changes.</span></div>` +
693
+ views;
694
+ }
695
+
580
696
  // ---------------------------------------------------------------------------
581
697
  // Full page
582
698
  // ---------------------------------------------------------------------------
@@ -602,6 +718,10 @@ main { max-width: 1080px; margin: 0 auto; padding: 40px 24px 80px; }
602
718
  h1 { color: var(--bright); font-size: 1.6rem; font-weight: 500; margin: 6px 0 4px; }
603
719
  .sub { color: var(--muted); font-size: .78rem; margin: 0 0 30px; }
604
720
  .usage-shell { margin-top: 18px; }
721
+ .usage-view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 9px 12px; margin-bottom: 18px; color: var(--muted); font-size: .78rem; }
722
+ .usage-view-controls label { color: var(--bright); }
723
+ .usage-view-controls select { background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 4px; color: var(--text); font: inherit; padding: 5px 8px; }
724
+ .usage-context-view[hidden] { display: none; }
605
725
  /* keep hover points visible on dark page chrome for both chart palettes */
606
726
  .hover-point { stroke: #ffffff; stroke-width: 2; }
607
727
  ${USAGE_STYLES}
@@ -611,6 +731,15 @@ ${USAGE_STYLES}
611
731
  <h1>${esc(title)}</h1>
612
732
  <p class="sub">tau-style dashboard rendered by pi-cache-dashboard · ${n} requests · times in ${esc(tzLabel())}${opts.sessionId ? " · session " + esc(opts.sessionId) : ""}</p>
613
733
  <section class="usage-shell" id="panel-usage">${renderDashboard(data)}</section>
614
- <script>${USAGE_SCRIPT}</script>
734
+ <script>${USAGE_SCRIPT}
735
+ for (const select of document.querySelectorAll("[data-context-segment-select]")) {
736
+ const shell = select.closest(".usage-shell");
737
+ const show = () => shell.querySelectorAll("[data-context-view]").forEach((view) => {
738
+ view.hidden = view.dataset.contextView !== select.value;
739
+ });
740
+ select.addEventListener("change", show);
741
+ show();
742
+ }
743
+ </script>
615
744
  </main></body></html>`;
616
745
  }
@@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import registerCacheExport from "../index.ts";
7
- import { parseEntries, analyzeStreaks, analyzeMisses, lineChartForTest } from "../render.ts";
7
+ import { parseEntries, analyzeStreaks, analyzeMisses, buildContextSegments, lineChartForTest, renderDashboard } from "../render.ts";
8
8
 
9
9
  let pass = 0, fail = 0;
10
10
  function check(name, cond, detail = "") {
@@ -318,6 +318,28 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
318
318
  s.length === 2 && s.every((x) => x.hits.length === 3), JSON.stringify(s.map((x) => [x.from, x.to])));
319
319
  }
320
320
 
321
+ // P2. Context segments are compaction-only; models reset cache windows -----
322
+ {
323
+ const entries = [
324
+ req({ cached: 90000, fresh: 10000, min: 1, model: "a" }),
325
+ { type: "model_change", timestamp: "2026-01-01T00:01:30Z", provider: "p", modelId: "b" },
326
+ req({ cached: 0, fresh: 100000, min: 2, model: "b" }),
327
+ { type: "compaction", timestamp: "2026-01-01T00:02:30Z" },
328
+ req({ cached: 0, fresh: 20000, min: 3, model: "b" }),
329
+ ];
330
+ const d = parseEntries(entries);
331
+ const segments = buildContextSegments(d.requests, d.events);
332
+ check("P2 model switch does not create a Context segment",
333
+ segments.length === 2 && segments[0].requests.length === 2 && segments[1].requests.length === 1,
334
+ JSON.stringify(segments.map((segment) => segment.requests.map((request) => request.number))));
335
+ const chart = lineChartForTest("segmented", [["cached", [10, 20, 30]]], { breakBefore: new Set([2]) });
336
+ check("P2 compaction chart boundary breaks the polyline", (chart.match(/<polyline /g) || []).length === 2, chart);
337
+ const dashboard = renderDashboard(d);
338
+ check("P2 dashboard defaults to latest segment and keeps an all-history view",
339
+ dashboard.includes('value="all"') && dashboard.includes('value="segment-2" selected') && dashboard.includes('data-context-view="all" hidden'),
340
+ dashboard.slice(0, 500));
341
+ }
342
+
321
343
  // Q. subagent results remain visible as main-session tool input -----------
322
344
  {
323
345
  const entries = [
@@ -0,0 +1,14 @@
1
+ import assert from "node:assert/strict";
2
+ import { clampBtwScrollTop, getBtwPageScrollSize } from "../btw-scroll.ts";
3
+
4
+ assert.equal(getBtwPageScrollSize(18), 18, "page scrolling uses the transcript viewport height");
5
+ assert.equal(getBtwPageScrollSize(8.9), 8, "page scrolling uses a whole number of lines");
6
+ assert.equal(getBtwPageScrollSize(0), 1, "page scrolling always moves at least one line");
7
+
8
+ assert.equal(clampBtwScrollTop(-3, 20, 8), 0, "scroll top clamps at the start");
9
+ assert.equal(clampBtwScrollTop(4, 20, 8), 4, "scroll top remains inside the range");
10
+ assert.equal(clampBtwScrollTop(99, 20, 8), 12, "scroll top clamps at the end");
11
+ assert.equal(clampBtwScrollTop(5, 4, 8), 0, "short content cannot scroll");
12
+ assert.equal(clampBtwScrollTop(Number.NaN, 20, 8), 0, "invalid scroll input falls back to the start");
13
+
14
+ console.log("BTW history scrolling tests passed");
package/global/AGENTS.md CHANGED
@@ -74,17 +74,7 @@ Before adding code to any file:
74
74
  - Check shared utilities in the same module.
75
75
  - If you're unsure why code is structured a certain way, ask before writing.
76
76
 
77
- ## 6. Checkpoint Long-Running Work
78
-
79
- **Multi-step work needs checkpoints. Don't keep going from a state you can't describe.**
80
-
81
- After each significant step:
82
- - Summarize what was done.
83
- - State what was verified.
84
- - State what remains.
85
- - If you can't describe the current state back, stop and restate instead of continuing.
86
-
87
- ## 7. Fail Visibly, Not Silently
77
+ ## 6. Fail Visibly, Not Silently
88
78
 
89
79
  **Silent skips are failures. Surface uncertainty instead of hiding it.**
90
80
 
@@ -93,14 +83,14 @@ After each significant step:
93
83
  - Default to surfacing uncertainty. When in doubt, over-communicate what you're unsure about.
94
84
  - If a required input, file, or tool is missing (or a step is impossible under the stated constraints), do NOT fabricate it to make a check pass. Report what is missing and what you need.
95
85
 
96
- ## 8. Reuse Before Writing
86
+ ## 7. Reuse Before Writing
97
87
 
98
88
  **Before writing new code, check in order:**
99
89
  1. Does it already exist in this codebase? Reuse it - do not re-implement it yourself, even with the standard library. (If the existing helper is itself the bug, fix it once - see Rule 3.)
100
90
  2. Does the standard library cover it? Use it.
101
91
  3. Only then: write the minimum code that works.
102
92
 
103
- ## 9. Use Available Editing Tools
93
+ ## 8. Use Available Editing Tools
104
94
 
105
95
  **Follow the current harness's tool surface; do not assume a shell patch helper exists.**
106
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export extensions, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "ws": "^8.19.0"
44
44
  },
45
45
  "scripts": {
46
- "test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs",
46
+ "test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs",
47
47
  "load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts && pi -ne -e ./extensions/btw.ts",
48
48
  "eval:agents": "node evaluations/agents-md/run.mjs",
49
49
  "eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",