pi-zip 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,12 +16,13 @@ Try it for one run without installing: `pi -e npm:pi-zip`.
16
16
 
17
17
  ## Measured results (v0.2.0)
18
18
 
19
- Live A/B runs on the same coding tasks (4 task templates × 4 seeds, the user away 6 minutes between prompts), paired by task, 95% bootstrap intervals. BC = [billion-context](https://github.com/ranxianglei/billion-context) with its defaults.
19
+ Live A/B runs on the same coding tasks (4 task templates × 4 seeds, the user away between prompts as stated), paired by task, 95% bootstrap intervals. BC = [billion-context](https://github.com/ranxianglei/billion-context) with its defaults.
20
20
 
21
21
  | Model | Cost vs BC | Speed | Quality (task done / planted facts recalled) |
22
22
  |---|---|---|---|
23
- | Claude Sonnet 5.5 | **0.79×** [0.72, 0.86] | p90 wait per prompt 53 s vs 85 s; same as plain Pi | 16/16 and 1.00, same as BC |
24
- | GPT-6.1-sol (14 tasks so far) | 1.00× [0.91, 1.09] | median task 197 s vs 293 s | 14/14 and 1.00 vs 13/14 and 0.96 |
23
+ | Claude Sonnet 5.5, away 6 min | **0.79×** [0.72, 0.86] | p90 wait per prompt 53 s vs 85 s; same as plain Pi | 16/16 and 1.00, same as BC |
24
+ | GPT-6.1-sol, away 6 min | 1.00× [0.92, 1.08] | median task 209 s vs 305 s | 16/16 and 1.00 vs 15/16 |
25
+ | GPT-6.1-sol, away 2 or 6 min | 1.07× [0.91, 1.22] | median task 271 s vs 389 s | 16/16 and 1.00 vs 14/16 |
25
26
 
26
27
  Against plain Pi on the same Claude runs: same speed, 0.45× the cost. Hidden-question probes on real long sessions (questions whose answer had been folded): 45–52% answered from the original via `zip_recall` vs 5% for BC's reconstruction, same number of wrong answers.
27
28
 
@@ -52,7 +53,41 @@ The second line appears once per session. Expand tool output (ctrl+o) to see why
52
53
  … 10 more
53
54
  ```
54
55
 
55
- These lines are saved in the session, so they are still there after a restart, but they are never sent to the model. On narrow terminals the words go first, then the bar; the numbers always stay. A summary still shows up as Pi's own `[compaction]` block as well; the pi-zip line next to it tells you who made it. Recalls show up as ordinary `zip_recall` tool calls.
56
+ These lines are saved in the session, so they are still there after a restart, but they are never sent to the model. On narrow terminals the words go first, then the bar; the numbers always stay. A summary still shows up as Pi's own `[compaction]` block as well; the pi-zip line next to it tells you who made it.
57
+
58
+ Everything else pi-zip shows uses the same one-line grammar, and only when something changed:
59
+
60
+ ```
61
+ ▸ pi-zip on folds old tool output after the prompt cache expires (5 min here) · nothing to set up · /zip for status
62
+ ▸ pi-zip reread-only a tool allowlist hides zip_recall · only re-readable outputs fold · allow zip_recall to fold more
63
+ ▸ pi-zip paused billion-context also manages context, so pi-zip only guards requests · to use pi-zip: pi remove the other one
64
+ ```
65
+
66
+ The first one appears once per machine. A folded output keeps its place in the transcript; its tool row gets a dim mark on the right, so you can see what the model no longer sees in full:
67
+
68
+ ```
69
+ $ npm test ▸ folded · k3x9q2m7ab
70
+ ```
71
+
72
+ A recall is one quiet row (ctrl+o shows the recalled text):
73
+
74
+ ```
75
+ ↺ recall k3x9q2m7ab grep "Expected"
76
+ bash npm test · turn 1 3 of 812 lines
77
+ ```
78
+
79
+ While a summary started in the background is being finished, Pi's working line says so (Esc skips the wait). `/zip` prints a small card into the transcript:
80
+
81
+ ```
82
+ ▸ pi-zip on
83
+ cache anthropic/claude-sonnet-5-5 · explicit · lives ~5 min (declared)
84
+ alive ██████▁▁▁▁▁▁▁▁▁▁ 30 s → 2 h
85
+ session 56 folds ~310K · 2 summaries $0.41 · 3 recalls
86
+ last 10:50 folded 12 old outputs · cache cold (away 47 min): this request rewrites it anyway, so editing is free
87
+ mode full (zip_recall available)
88
+ ```
89
+
90
+ `alive` is what pi-zip currently believes about the cache: how likely it is to be still warm after 30 s, 1 min, … 2 h away, learned from the provider's replies (see below).
56
91
 
57
92
  ## The three rules
58
93
 
@@ -60,11 +95,11 @@ These lines are saved in the session, so they are still there after a restart, b
60
95
  2. **Edit only when the cache is already gone.** Provider prompt caches expire (the model's declared TTL, usually minutes). Changing the context while the cache is warm means paying to rewrite it; changing it after it expired is free, because the whole context is rewritten anyway, and a smaller context makes that rewrite cheaper. So when you come back after the TTL (or after switching model, or when the session was last touched longer ago than the TTL), pi-zip folds old outputs down to about 40K real tokens in one step, and every request of that turn sends the same bytes. While the cache is warm it does nothing, with one exception, the warm valve: above the 40K target it applies that plan, minus the previous user turn (a warm edit never folds the turn you just finished, except when you come back after the declared TTL: there the previous turn is eligible exactly as at a cold return, so a provider whose cache outlives its TTL does not keep it at every return), when the edit pays for the rewrite it causes, and never on the request right after an edited one (no back-to-back warm rewrites). That is one inequality, r Δ²/(2g) + η Δ ≥ K with K = (w − r)(P T − (1 − P) Δ): the reads the removed Δ tokens would cost while the context grows back at g tokens per request (measured in the session), plus, near Pi's compaction trigger, what Pi would charge for the same room (η), against the rewrite of the T = A tokens left after the edit (pricing only the suffix after the earliest edit fires warm edits earlier and lost quality in the offline evaluation; the suffix is logged as `Tsuf` for measurement). r and w are the read and rewrite price ratios of the cache class, never the model's price table: explicit write premium 0.1 / 1.25 x input (2 x on the 1-hour tier), automatic prefix cache 0.2 / 1 x input; the class is read from the provider's usage reports, and until the first response the old fixed rule applies. P is the probability that the cache is still warm. A cold return is P = 0, so K < 0 and it always fires; a single small fold never pays at a warm cache, a large one does.
61
96
  3. **Never in the way.** Planning is local and takes milliseconds. Anything that needs a model call (a summary, only when folding is not enough and the same inequality prices the extra model call in) is prepared while you are away: if the cache is about to expire (0.8 x its lifetime after your last request) and you have not come back, a background timer writes the summary with a separate, uncached call. The timer is cancelled the moment you send a prompt. If you return before the summary finishes, only the remaining time is waited, Esc stops the waiting, and the notice says so. If you return while the cache is still warm and the valve does not fire, the prepared summary is discarded (its cost is still counted). In non-interactive modes (`-p`, `--mode json`) nothing is ever started in the background: a cold return that needs a summary computes it right then.
62
97
 
63
- **The cache lifetime is learned, not configured.** Every response says how much of the prompt came from the cache. pi-zip compares that read with what the request re-sent unchanged (the previous prompt, or the untouched prefix before one of its own edits: on an automatic prefix cache every edit leaves the first 8K tokens alone, so even the response right after a fold says whether the cache survived) and so learns, per provider and model, whether the cache survived a gap of that length: a few counts per gap bin (30 s to 90 min, with bin edges on the 5-minute and 1-hour tiers), monotone in the gap, older evidence halved after 16 newer observations of the same bin, stored without any content in `~/.pi/agent/pi-zip/cache-survival.json`. Before any evidence the model's declared TTL decides, exactly as before (300 s when it declares none; too short a guess is cheaper than too long); beyond it one clean read overrides it, inside it a lone miss counts as noise (warm caches do miss now and then) and only repeated misses do. A GLM cache read in full after 365 s makes the next 365 s return warm; a Claude 5-minute cache that read nothing after 360 s stays dead. Whether the provider bills cache writes (explicit cache) or not (automatic prefix cache) is read from the first response too. `/zip status` shows the class, its price ratios, and the learned survival per bin with its sample count.
98
+ **The cache lifetime is learned, not configured.** Every response says how much of the prompt came from the cache. pi-zip compares that read with what the request re-sent unchanged (the previous prompt, or the untouched prefix before one of its own edits: on an automatic prefix cache every edit leaves the first 8K tokens alone, so even the response right after a fold says whether the cache survived) and so learns, per provider and model, whether the cache survived a gap of that length: a few counts per gap bin (30 s to 90 min, with bin edges on the 5-minute and 1-hour tiers), monotone in the gap, older evidence halved after 16 newer observations of the same bin, stored without any content in `~/.pi/agent/pi-zip/cache-survival.json`. Before any evidence the model's declared TTL decides, exactly as before (300 s when it declares none; too short a guess is cheaper than too long); beyond it one clean read overrides it, inside it a lone miss counts as noise (warm caches do miss now and then) and only repeated misses do. A GLM cache read in full after 365 s makes the next 365 s return warm; a Claude 5-minute cache that read nothing after 360 s stays dead. Whether the provider bills cache writes (explicit cache) or not (automatic prefix cache) is read from the first response too. `/zip status` shows the class, the lifetime it currently believes, and the learned survival as the `alive` row.
64
99
 
65
100
  Protected from folding: the current user turn and the previous one. When the context is above the target, re-readable outputs of the previous turn (an unchanged file, a read-only command) can still be folded at a cold return or at any return after the declared TTL (never on a warm request inside it), and any output in either turn can be folded once it is 60 assistant requests old (so a long agent run that is a single user turn with hundreds of tool calls is not exempt from folding; the newest 59 requests' outputs always stay, and every fold stays recallable). Messages you type while the agent is running (steering, follow-up) belong to that turn and do not start a new one. Outputs you have already recalled, and `zip_recall` results themselves, are never folded again. "Read-only" is a conservative whitelist: `find -delete` or `-exec`, command substitution, redirects, background jobs, `git diff --output` and the like are not.
66
101
 
67
- **Token counts are calibrated, not guessed.** Sizes are estimated as chars/4, which undercounts real tokens (typically by about 1.7x in coding sessions). So the cold cap, the compaction room and the law's token counts are all compared against `k` x the estimate, where `k` = real tokens / estimated tokens for the newest assistant message that reports usage (input + cache read + cache write, over the estimate of the context that request carried; clamped to 1 to 2.5). `k` is read from the session itself on every decision, so a restart, `pi -p` or a resumed session calibrates exactly like a long-lived one, and nothing extra is stored. With no usage to read (a brand-new session, or a provider that reports none) `k` is 1.7. Sizes in the notices, `/zip stats` and the ledger use the same scale.
102
+ **Token counts are calibrated, not guessed.** Sizes are estimated as chars/4, which undercounts real tokens (typically by about 1.7x in coding sessions). So the cold cap, the compaction room and the law's token counts are all compared against `k` x the estimate, where `k` = real tokens / estimated tokens for the newest assistant message that reports usage (input + cache read + cache write, over the estimate of the context that request carried; clamped to 1 to 2.5). `k` is read from the session itself on every decision, so a restart, `pi -p` or a resumed session calibrates exactly like a long-lived one, and nothing extra is stored. With no usage to read (a brand-new session, or a provider that reports none) `k` is 1.7. Sizes in the notices, `/zip status` and the ledger use the same scale.
68
103
 
69
104
  The cold cap is kept below Pi's own compaction trigger (window minus `compaction.reserveTokens`), so on small windows Pi's lossy compaction does not get there first.
70
105
 
@@ -72,13 +107,12 @@ The cold cap is kept below Pi's own compaction trigger (window minus `compaction
72
107
 
73
108
  | Command | Effect |
74
109
  |---|---|
75
- | `/zip status` | on / off / paused, cache TTL, what this session has folded, summarised and recalled, learned cache survival |
76
- | `/zip stats` | this session's folds (with tokens removed), summaries and recalls; persisted in the session, so a restart does not reset them |
110
+ | `/zip` or `/zip status` | the card above: state, cache class and lifetime, learned survival, this session's folds, summaries and recalls (counted from the session, so a restart does not reset them) |
77
111
  | `/zip off` | strict no-op: no folds, no summaries, requests left untouched (earlier folds stay recallable) |
78
112
  | `/zip on` | resume |
79
113
  | `/zip quiet` | toggle the per-turn notice (folding continues) |
80
114
 
81
- `off` and `quiet` are remembered per session.
115
+ `off` and `quiet` are remembered per session; each change is one line in the transcript. In `-p` / json mode `/zip status` prints plain text to stderr.
82
116
 
83
117
  ## Recall
84
118
 
@@ -120,9 +154,9 @@ The guard has two parts. Before saving a fold or a summary it checks that the ed
120
154
 
121
155
  ## FAQ
122
156
 
123
- **Will it save money?** Mostly on cold returns, which is where a long session pays for a full cache rewrite. While the cache is warm it edits only when the inequality above says the rewrite pays back (large contexts, near Pi's compaction trigger, outputs 60+ requests old). `/zip stats` shows what this session has folded (and roughly how many tokens that removed), its summaries with what their model calls cost, and its recalls. The numbers live in the session file, so a restart does not reset them.
157
+ **Will it save money?** Mostly on cold returns, which is where a long session pays for a full cache rewrite. While the cache is warm it edits only when the inequality above says the rewrite pays back (large contexts, near Pi's compaction trigger, outputs 60+ requests old). `/zip status` shows what this session has folded (and roughly how many tokens that removed), its summaries with what their model calls cost, and its recalls. The numbers live in the session file, so a restart does not reset them.
124
158
 
125
- **Does it cost extra?** Planning is free. A summary is one extra model call (the current model, no tools, no prompt cache), shown in `/zip stats`. A background summary you never use (you came back while the cache was warm) is counted there too. Recalled content re-enters the context at normal prices.
159
+ **Does it cost extra?** Planning is free. A summary is one extra model call (the current model, no tools, no prompt cache), shown in `/zip status`. A background summary you never use (you came back while the cache was warm) is counted there too. Recalled content re-enters the context at normal prices.
126
160
 
127
161
  **Can the model lose information?** Folded outputs are replaced by a placeholder with key lines and a handle, and the placeholder tells the model not to guess. Summaries quote your requests verbatim and never paraphrase the model's reasoning. If the model ignores the handle and guesses, that is a model failure pi-zip cannot catch; this is why only re-readable outputs of the previous turn are folded at a cold return; other outputs of the protected turns wait until they are 60 requests old.
128
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zip",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Keeps long Pi sessions cheap without losing anything: folds old tool output only when the prompt cache has already gone cold, and every fold can be recalled byte for byte. Zero config.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { type NoticeData, registerZipCommand, renderNotice } from "./notice.ts";
5
5
  import { RECALL_TOOL } from "./placeholder.ts";
6
6
  import { registerRecallTool } from "./recall.ts";
7
7
  import { NOTICE_CUSTOM, Zip } from "./run.ts";
8
+ import { markFirstLine, measure, renderCard, renderState, setMeasure } from "./ui.ts";
8
9
 
9
10
  export default function piZip(pi: ExtensionAPI) {
10
11
  if (process.env.PI_ZIP_OFF === "1") return; // test only: behave exactly as if not installed (registers nothing)
@@ -12,20 +13,22 @@ export default function piZip(pi: ExtensionAPI) {
12
13
  registerRecallTool(pi, zip); // stays registered even when off: earlier folds must stay recallable, and a tool-list change would bust the cache
13
14
  registerZipCommand(pi, zip);
14
15
  try {
15
- // fold/summary notices stay in the transcript as one dim line (a custom entry: never part of the model's context)
16
- let vw: (s: string) => number = (s) => s.length;
16
+ // Everything pi-zip shows lives in the transcript as custom entries (never part of the model's context): fold/summary
17
+ // notices, state lines and the /zip status card. Widths are measured with Pi's own pi-tui once it has loaded.
17
18
  import("@earendil-works/pi-tui").then((m: Any) => {
18
- if (typeof m?.visibleWidth === "function") vw = m.visibleWidth;
19
+ if (typeof m?.visibleWidth === "function" && typeof m?.truncateToWidth === "function")
20
+ setMeasure({ vw: m.visibleWidth, cut: (s: string, w: number) => (m.visibleWidth(s) <= w ? s : w <= 0 ? "" : m.truncateToWidth(s, w, "…")) });
19
21
  }, () => {});
20
22
  pi.registerEntryRenderer?.(NOTICE_CUSTOM, (entry: Any, o: Any, theme: Any) => {
21
23
  const d = entry?.data;
22
24
  if (!d?.text) return undefined;
23
- const data: NoticeData = d.v === 2 ? d : { v: 2, text: d.text, before: 0, after: 0, desc: "" };
24
25
  return {
25
26
  render: (width: number) => {
26
- if (d.v !== 2) return [theme.fg("dim", vw(d.text) > width ? d.text.slice(0, Math.max(0, width - 1)) + "…" : d.text)];
27
27
  try {
28
- return renderNotice(data, !!o?.expanded, width, theme, vw);
28
+ if (d.v !== 2) return [theme.fg("dim", measure.cut(d.text, width))];
29
+ if (d.kind === "state") return renderState(d.word, d.reason ?? "", width, theme, measure);
30
+ if (d.kind === "card" && d.card) return renderCard(d.card, width, theme, measure);
31
+ return renderNotice(d as NoticeData, !!o?.expanded, width, theme, measure.vw);
29
32
  } catch {
30
33
  return [];
31
34
  }
@@ -34,25 +37,56 @@ export default function piZip(pi: ExtensionAPI) {
34
37
  };
35
38
  });
36
39
  zip.entryRenderer = typeof pi.registerEntryRenderer === "function";
40
+ // a folded output keeps its place in the transcript; its call row gets a dim "▸ folded · <handle>" on the right
41
+ pi.registerToolRenderer?.((toolName: string, next: () => Any) => {
42
+ const base = next();
43
+ if (toolName === RECALL_TOOL || typeof base?.renderCall !== "function") return base;
44
+ return {
45
+ ...base,
46
+ renderCall: (args: Any, theme: Any, c: Any) => {
47
+ const comp = base.renderCall(args, theme, c);
48
+ const id = c?.toolCallId;
49
+ if (!comp || typeof comp.render !== "function" || typeof id !== "string") return comp;
50
+ return new Proxy(comp, {
51
+ get(t, p, r) {
52
+ if (p !== "render") return Reflect.get(t, p, r);
53
+ return (width: number) => {
54
+ const lines = t.render(width);
55
+ const h = zip.foldedHandle(id);
56
+ try {
57
+ return h ? markFirstLine(lines, h, width, theme, measure) : lines;
58
+ } catch {
59
+ return lines;
60
+ }
61
+ };
62
+ },
63
+ });
64
+ },
65
+ };
66
+ });
37
67
  } catch {
38
68
  /* older Pi: notices fall back to the status line */
39
69
  }
40
70
  // A tool allowlist (`pi --tools read,bash`, sub-agent launchers) replaces the whole selection and Pi then does not even register
41
71
  // zip_recall; folds of outputs the model could not get back would be lost, so only rereadable outputs fold then (plan rereadOnly).
42
- const checkRecall = () => {
72
+ const checkRecall = (ctx: Any) => {
43
73
  try {
44
74
  const active = pi.getActiveTools?.();
45
- if (Array.isArray(active)) zip.setRecallOk(active.includes(RECALL_TOOL));
75
+ if (Array.isArray(active)) zip.setRecallOk(active.includes(RECALL_TOOL), ctx);
46
76
  } catch {
47
77
  /* never in the way */
48
78
  }
49
79
  };
50
80
  pi.on("session_start", (_e, ctx) => {
51
- checkRecall();
52
- return zip.sessionStart(ctx);
81
+ const r = zip.sessionStart(ctx);
82
+ checkRecall(ctx);
83
+ zip.refreshFolded(ctx);
84
+ zip.welcome(ctx);
85
+ return r;
53
86
  });
54
87
  pi.on("before_agent_start", (_e, ctx) => {
55
- checkRecall();
88
+ checkRecall(ctx);
89
+ zip.refreshFolded(ctx); // the branch may have changed (/tree, fork)
56
90
  return zip.beforeAgentStart(ctx);
57
91
  });
58
92
  pi.on("context_with_system", (e, ctx) => zip.context(e, ctx)); // the complete transcript: system messages stay where Pi put them
package/src/notice.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // User-facing text (F15, F16): one notice line per turn, honest stats, the /zip command. Notices never enter model context.
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { type Any, PRODUCT } from "./util.ts";
4
+ import type { CardData, StateWord } from "./ui.ts";
4
5
 
5
6
  export interface NoticeAction {
6
7
  kind: "fold" | "summary";
@@ -23,6 +24,10 @@ export interface NoticeItem {
23
24
  /** What a transcript notice stores (custom entry data; never sent to the model). `text` is the plain one-line form (status-line fallback, ledger). */
24
25
  export interface NoticeData {
25
26
  v: 2;
27
+ kind?: "state" | "card"; // absent: a fold/summary notice
28
+ word?: StateWord;
29
+ reason?: string;
30
+ card?: CardData;
26
31
  text: string;
27
32
  before: number;
28
33
  after: number;
@@ -118,6 +123,9 @@ export class Stats {
118
123
 
119
124
  export interface ZipControl {
120
125
  status(ctx?: Any): string;
126
+ card?(ctx?: Any): CardData;
127
+ /** Show a /zip result in the transcript; false = no transcript surface here (print/json/rpc or an older Pi). */
128
+ show?(ctx: Any, data: NoticeData): boolean;
121
129
  statsLine(ctx?: Any): string;
122
130
  setOff(off: boolean): string;
123
131
  toggleQuiet(): string;
@@ -125,16 +133,25 @@ export interface ZipControl {
125
133
 
126
134
  export function registerZipCommand(pi: ExtensionAPI, zip: ZipControl) {
127
135
  pi.registerCommand("zip", {
128
- description: `${PRODUCT}: status | stats | off | on | quiet`,
136
+ description: `${PRODUCT}: status | off | on | quiet`,
129
137
  handler: async (args: string, cctx: Any) => {
130
138
  const sub = (args ?? "").trim().toLowerCase() || "status";
139
+ if ((sub === "status" || sub === "stats") && zip.card && zip.show) {
140
+ const card = zip.card(cctx);
141
+ if (zip.show(cctx, { v: 2, kind: "card", card, text: zip.status(cctx), before: 0, after: 0, desc: "" })) return;
142
+ }
131
143
  const text =
132
144
  sub === "status" ? zip.status(cctx)
133
145
  : sub === "stats" ? zip.statsLine(cctx)
134
146
  : sub === "off" ? zip.setOff(true)
135
147
  : sub === "on" ? zip.setOff(false)
136
148
  : sub === "quiet" ? zip.toggleQuiet()
137
- : `${PRODUCT}: unknown subcommand "${sub}" (use status | stats | off | on | quiet)`;
149
+ : `${PRODUCT}: unknown subcommand "${sub}" (use status | off | on | quiet)`;
150
+ if (zip.show && (sub === "off" || sub === "on" || sub === "quiet")) {
151
+ const word: StateWord = sub === "quiet" ? (/notices off/.test(text) ? "quiet" : "notices on") : sub;
152
+ const reason = text.replace(/^pi-zip: (off|on)\.? ?/, "").replace(/^pi-zip: /, "");
153
+ if (zip.show(cctx, { v: 2, kind: "state", word, reason: reason || "folding resumes", text, before: 0, after: 0, desc: "" })) return;
154
+ }
138
155
  if (cctx?.ui?.notify && (cctx.mode === "tui" || cctx.mode === "rpc")) {
139
156
  try {
140
157
  cctx.ui.notify(text);
package/src/recall.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // zip_recall (F3): exact, batched retrieval of folded originals from the session file (I2).
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { handleFor, RECALL_TOOL } from "./placeholder.ts";
3
+ import { handleFor, RECALL_TOOL, shortArgs } from "./placeholder.ts";
4
+ import { measure, recallCallLine, recallResultLines, type RecallSectionInfo } from "./ui.ts";
4
5
  import { type Any, clamp, textOf } from "./util.ts";
5
6
 
6
7
  const RECALL_PAGE_CHARS = 20_000; // default page
@@ -101,34 +102,50 @@ export interface RecallItem {
101
102
  handle: string;
102
103
  text: string | null;
103
104
  tool: string | null;
105
+ label?: string; // tool + short args of the call that produced it (transcript only)
106
+ turn?: number;
104
107
  }
105
108
 
106
- export function recallSections(items: RecallItem[], opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown }): { text: string; ok: number; missing: number } {
109
+ export function recallSections(items: RecallItem[], opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown }): { text: string; ok: number; missing: number; sections: RecallSectionInfo[] } {
107
110
  const parts: string[] = [];
111
+ const sections: RecallSectionInfo[] = [];
108
112
  let ok = 0;
109
113
  let missing = 0;
110
114
  for (const it of items) {
111
115
  if (it.text === null) {
112
116
  missing++;
117
+ sections.push({ handle: it.handle, missing: true });
113
118
  parts.push(`[handle ${it.handle}] not found. Copy the handle exactly from the folded block's marker line or the summary's handle table.`);
114
119
  continue;
115
120
  }
116
121
  ok++;
117
122
  const slice = sliceRecall(it.text, opts);
123
+ const grep = opts.grep !== undefined && String(opts.grep).trim() !== "", range = opts.range !== undefined && String(opts.range).trim() !== "";
124
+ const how = grep ? "grep" : range ? "range" : slice.clipped || slice.offset > 0 ? "page" : "all";
125
+ const shown = grep ? (slice.matched ?? 0) : slice.body ? slice.body.split("\n").length : 0;
126
+ sections.push({ handle: it.handle, label: it.label ?? it.tool ?? undefined, turn: it.turn, totalLines: slice.totalLines, shownLines: shown, how });
118
127
  parts.push(`[handle ${it.handle}${it.tool ? " · " + it.tool : ""} · ${it.text.length} chars · ${slice.totalLines} lines]\n${slice.text}`);
119
128
  }
120
- return { text: parts.join("\n\n"), ok, missing };
129
+ return { text: parts.join("\n\n"), ok, missing, sections };
121
130
  }
122
131
 
123
132
  /** Resolve handles against the session branch (which spans entries before any compaction): exact originals. */
124
133
  export function resolveHandlesInBranch(branch: Any[], handles: string[]): { items: RecallItem[]; entryIds: (string | null)[] } {
125
134
  const items: RecallItem[] = handles.map((h) => ({ handle: h, text: null, tool: null }));
126
135
  const entryIds: (string | null)[] = handles.map(() => null);
136
+ const calls = new Map<string, Any>();
137
+ let turn = 0;
127
138
  for (const en of branch) {
128
- if (en?.type !== "message" || en.message?.role !== "toolResult") continue;
139
+ const msg = en?.type === "message" ? en.message : null;
140
+ if (msg?.role === "user") turn++;
141
+ if (msg?.role === "assistant" && Array.isArray(msg.content)) for (const c of msg.content) if (c?.type === "toolCall" && c.id) calls.set(c.id, c);
142
+ if (msg?.role !== "toolResult") continue;
129
143
  const k = handles.indexOf(handleFor(en.id));
130
144
  if (k < 0) continue;
131
- items[k] = { handle: handles[k], text: textOf(en.message.content), tool: en.message.toolName ?? null };
145
+ const call = calls.get(msg.toolCallId);
146
+ const tool = msg.toolName ?? call?.name ?? null;
147
+ const args = call ? shortArgs(call.arguments) : "";
148
+ items[k] = { handle: handles[k], text: textOf(msg.content), tool, label: tool ? `${tool}${args ? " " + args : ""}` : undefined, turn: turn || undefined };
132
149
  entryIds[k] = en.id;
133
150
  }
134
151
  return { items, entryIds };
@@ -177,6 +194,14 @@ export function registerRecallTool(pi: ExtensionAPI, hooks: RecallHooks) {
177
194
  },
178
195
  required: [],
179
196
  } as Any,
197
+ renderCall(args: Any, theme: Any) {
198
+ return lines((w) => recallCallLine(args ?? {}, w, theme, measure));
199
+ },
200
+ renderResult(result: Any, options: Any, theme: Any) {
201
+ const text = textOf(result?.content);
202
+ const secs: RecallSectionInfo[] = Array.isArray(result?.details?.sections) ? result.details.sections : [];
203
+ return lines((w) => recallResultLines(secs, text, !!options?.expanded, w, theme, measure));
204
+ },
180
205
  async execute(_id: string, params: Any, _signal: Any, _onUpdate: Any, ctx: Any): Promise<Any> {
181
206
  const grep = params?.grep !== undefined ? String(params.grep) : undefined;
182
207
  const range = params?.range !== undefined ? String(params.range) : undefined;
@@ -190,10 +215,15 @@ export function registerRecallTool(pi: ExtensionAPI, hooks: RecallHooks) {
190
215
  const { items, entryIds } = resolveHandlesInBranch(ctx.sessionManager.getBranch() as Any[], handles);
191
216
  const out = recallSections(items, { grep, range, offset, limit });
192
217
  hooks.onRecall(handles, items.reduce((a, it) => a + (it.text?.length ?? 0), 0), entryIds);
193
- return { content: [{ type: "text", text: out.text }], details: { handles, resolved: out.ok, missing: out.missing }, isError: out.ok === 0 };
218
+ return { content: [{ type: "text", text: out.text }], details: { handles, resolved: out.ok, missing: out.missing, sections: out.sections }, isError: out.ok === 0 };
194
219
  } catch (err) {
195
220
  return { content: [{ type: "text", text: `${RECALL_TOOL} failed: ${err instanceof Error ? err.message : String(err)}` }], details: {}, isError: true };
196
221
  }
197
222
  },
198
223
  });
199
224
  }
225
+
226
+ /** A minimal pi-tui Component: lines computed for the width at render time. */
227
+ function lines(fn: (width: number) => string[]): Any {
228
+ return { render: (width: number) => fn(Math.max(1, width)), invalidate: () => {} };
229
+ }
package/src/run.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // The per-session state machine behind index.ts: cold detection, the run plan (F8), the warm valve (F10), persistence at turn_end,
2
2
  // settle preparation and the away-timer for the summary (F12).
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { appendFileSync, mkdirSync } from "node:fs";
4
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
5
5
  import { dirname } from "node:path";
6
6
  import { detectCold, lastMessageMs, lastPrompt, modelKey, resolveTtl, ttlFor } from "./cache.ts";
7
- import { describe, lawPrices, loadStats, pWarm, record, sample, GAP_EDGES, type Entry } from "./learn.ts";
7
+ import { describe, lawPrices, loadStats, pWarm, record, sample, statsPath, GAP_EDGES, type Entry } from "./learn.ts";
8
8
  import { validateEdits, repairPayload } from "./guard.ts";
9
9
  import { fmtK, Stats, noticeDesc, noticeText, type NoticeAction, type NoticeData, type ZipControl } from "./notice.ts";
10
10
  import { applyPlanToMessages, buildBlocks, calibrate, countUserTurns, G0, planContext, reserveTokensFor, untouchedEst, type Block, type Calibration, type Cut, type FoldTarget, type Law, type PlanOpts, type PlanResult, type RunPlan } from "./plan.ts";
@@ -12,6 +12,7 @@ import { handleFor, PH_MARK, RECALL_TOOL } from "./placeholder.ts";
12
12
  import { recalledHandlesFromBranch } from "./recall.ts";
13
13
  import { buildCut } from "./summary.ts";
14
14
  import { type Any, PRODUCT, textOf, tok4 } from "./util.ts";
15
+ import { fmtLife, SPARK_GAPS, type CardData, type StateWord } from "./ui.ts";
15
16
 
16
17
  export const PLAN_CUSTOM = "pi-zip/plan";
17
18
  export const STATE_CUSTOM = "pi-zip/state";
@@ -103,9 +104,62 @@ export class Zip implements ZipControl {
103
104
  private noticed = false; // a transcript notice was written in this process (the branch may not show it yet)
104
105
  /** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
105
106
  summarizedWarm = false;
106
- setRecallOk(ok: boolean) {
107
+ setRecallOk(ok: boolean, ctx?: Any) {
107
108
  if (ok !== this.recallOk) this.ledger({ type: "recall_available", ok });
108
109
  this.recallOk = ok;
110
+ if (!ok && ctx) this.stateLine(ctx, "reread-only", "a tool allowlist hides zip_recall · only re-readable outputs fold · allow zip_recall to fold more");
111
+ }
112
+
113
+ /** Folded tool outputs of the current branch by toolCallId -> handle (the transcript marks them). */
114
+ readonly foldedCalls = new Map<string, string>();
115
+ foldedHandle = (toolCallId: string): string | undefined => this.foldedCalls.get(toolCallId);
116
+ refreshFolded(ctx: Any) {
117
+ const branch = this.branch(ctx);
118
+ const byId = new Map(branch.map((e: Any) => [e?.id, e]));
119
+ this.foldedCalls.clear();
120
+ for (const e of branch) {
121
+ if (e?.type !== "context_edit" || !String(e?.replacement?.content?.[0]?.text ?? "").startsWith(PH_MARK)) continue;
122
+ const id = byId.get(e.targetId)?.message?.toolCallId;
123
+ if (typeof id === "string") this.foldedCalls.set(id, handleFor(e.targetId));
124
+ }
125
+ }
126
+
127
+ /** /zip output in the transcript (custom entry, never sent to the model). */
128
+ show(ctx: Any, data: NoticeData): boolean {
129
+ if (ctx?.mode !== "tui" || !this.entryRenderer) return false;
130
+ try {
131
+ this.pi.appendEntry(NOTICE_CUSTOM, data);
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** A state change as one transcript line ("▸ pi-zip paused …"); each word once per session unless always. */
139
+ stateLine(ctx: Any, word: StateWord, reason: string, once = true) {
140
+ const text = `${PRODUCT} ${word} ${reason}`;
141
+ if (ctx?.mode !== "tui" && word !== "paused") return; // print/json/rpc (sub-agents): no transcript to keep it in
142
+ if (once && (this.statesShown.has(word) || this.branch(ctx).some((e: Any) => e?.type === "custom" && e.customType === NOTICE_CUSTOM && e.data?.kind === "state" && e.data?.word === word))) return;
143
+ this.statesShown.add(word);
144
+ this.ledger({ type: "state", word, reason });
145
+ // a state is not tied to this turn's edits: append now, so it sits where it happened
146
+ if (!this.show(ctx, { v: 2, kind: "state", word, reason, text, before: 0, after: 0, desc: "" })) this.notify(ctx, text);
147
+ }
148
+ private statesShown = new Set<string>();
149
+
150
+ /** First pi-zip session on this machine: one line that sets the expectation (nothing visible happens until the cache expires). */
151
+ welcome(ctx: Any) {
152
+ if (ctx?.mode !== "tui" || !this.entryRenderer || this.off || this.conflict) return;
153
+ const flag = `${dirname(process.env.PI_ZIP_CACHE_STATS || statsPath())}/welcomed`;
154
+ try {
155
+ if (existsSync(flag)) return;
156
+ mkdirSync(dirname(flag), { recursive: true });
157
+ writeFileSync(flag, new Date().toISOString() + "\n");
158
+ } catch {
159
+ return;
160
+ }
161
+ const ttl = ttlFor(ctx.model ?? this.model);
162
+ this.stateLine(ctx, "on", `folds old tool output after the prompt cache expires (${fmtLife(ttl / 1000).slice(1)} here) · nothing to set up · /zip for status`);
109
163
  }
110
164
 
111
165
  ledger(rec: Record<string, unknown>) {
@@ -202,7 +256,7 @@ export class Zip implements ZipControl {
202
256
  this.conflict = found;
203
257
  if (found && !this.conflictNoticed) {
204
258
  this.conflictNoticed = true;
205
- this.notify(ctx, `${PRODUCT} · "${found}" also manages context, so folding is paused (only the request guard stays on)`);
259
+ this.stateLine(ctx, "paused", `${found} also manages context, so pi-zip only guards requests · to use pi-zip: pi remove the other one`, true);
206
260
  }
207
261
  }
208
262
 
@@ -272,7 +326,7 @@ export class Zip implements ZipControl {
272
326
  this.ledger({ type: "prompt", cold, reason, ttlMs: ttl.ms, ttlSource: ttl.source, settleTargets: this.settleIds.length, gapS: gapS === null ? null : Math.round(gapS), pWarm: Math.round(p * 1000) / 1000, survSrc: src, cls: this.ent?.cls ?? null });
273
327
  if (ttl.note && !this.ttlNoticed) {
274
328
  this.ttlNoticed = true; // once per session
275
- if (!this.quiet) this.notify(ctx, ttl.note);
329
+ if (!this.quiet) this.stateLine(ctx, "cache", ttl.note.replace(`${PRODUCT} · `, ""));
276
330
  }
277
331
  }
278
332
 
@@ -323,14 +377,29 @@ export class Zip implements ZipControl {
323
377
  }
324
378
 
325
379
  /** A prepared summary for this cut, or null. A summary still being written is waited for (only the remainder); Esc stops the waiting, not the work. */
326
- private async takeBg(key: string | null, signal?: AbortSignal): Promise<{ cut: Cut | null; adopted: boolean }> {
380
+ /** While the user waits for a summary, Pi's working line says what for (and that Esc skips a prepared one). */
381
+ private async working<T>(ctx: Any, text: string, p: Promise<T>): Promise<T> {
382
+ const ui = ctx?.mode === "tui" ? ctx?.ui : null;
383
+ try {
384
+ ui?.setWorkingMessage?.(text);
385
+ } catch {}
386
+ try {
387
+ return await p;
388
+ } finally {
389
+ try {
390
+ ui?.setWorkingMessage?.(undefined);
391
+ } catch {}
392
+ }
393
+ }
394
+
395
+ private async takeBg(key: string | null, signal?: AbortSignal, ctx?: Any): Promise<{ cut: Cut | null; adopted: boolean }> {
327
396
  const b = this.bg;
328
397
  if (!b || key === null || b.key !== key) {
329
398
  this.discardBg();
330
399
  return { cut: null, adopted: false };
331
400
  }
332
401
  const w0 = performance.now();
333
- const cut = b.done ?? (await waitFor(b.promise, signal));
402
+ const cut = b.done ?? (await this.working(ctx, `${PRODUCT}: finishing the summary it started while you were away · Esc skips it`, waitFor(b.promise, signal)));
334
403
  this.bgWaitMs = performance.now() - w0;
335
404
  if (cut) {
336
405
  this.bg = null;
@@ -364,7 +433,7 @@ export class Zip implements ZipControl {
364
433
  let cut: Cut | null = null;
365
434
  let adopted = false;
366
435
  if (p && p.cutIdx !== null) {
367
- const got = await this.takeBg(p.firstKeptEntryId, ctx.signal);
436
+ const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
368
437
  cut = got.cut;
369
438
  adopted = got.adopted;
370
439
  } else {
@@ -373,7 +442,7 @@ export class Zip implements ZipControl {
373
442
  const sameSet = this.settleIds.length === folds.length && this.settleIds.every((id) => folds.some((u) => u.entryId === id));
374
443
  const source: RunPlan["source"] = !this.cold ? "valve" : adopted || (this.settleIds.length > 0 && sameSet) ? "settle" : "runstart";
375
444
  const foldMs = performance.now() - t0;
376
- if (!cut && p && p.cutIdx !== null && !ctx.signal?.aborted) cut = await buildCut(p, ctx); // produced now, before the first request: the user waits
445
+ if (!cut && p && p.cutIdx !== null && !ctx.signal?.aborted) cut = await this.working(ctx, `${PRODUCT}: writing a summary (folding alone cannot make the context small enough)`, buildCut(p, ctx)); // produced now, before the first request: the user waits
377
446
  // a plan that cannot be persisted must never be sent: the request view would differ from what turn_end can write (I1)
378
447
  if (p) {
379
448
  const invalid = checkEdits(p.blocks, p.userTurns, folds, cut);
@@ -518,8 +587,8 @@ export class Zip implements ZipControl {
518
587
  if (!p || (!p.folds.length && p.cutIdx === null)) return undefined;
519
588
  let cut: Cut | null = null;
520
589
  if (p.cutIdx !== null) {
521
- const got = await this.takeBg(p.firstKeptEntryId, ctx.signal);
522
- cut = got.cut ?? (await buildCut(p, ctx)); // the user is here and the cache is warm; the alternative is Pi's lossy compaction
590
+ const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
591
+ cut = got.cut ?? (await this.working(ctx, `${PRODUCT}: writing a summary (the context is close to the window)`, buildCut(p, ctx))); // the user is here and the cache is warm; the alternative is Pi's lossy compaction
523
592
  } else this.discardBg();
524
593
  const plan: RunPlan = { source: "valve", folds: p.folds, cut, ctxBefore: p.ctxTokens, ctxAfter: p.ctxAfterFolds, ms: performance.now() - t0, k: p.k, persisted: false, untouched: p.k * untouchedEst(p.blocks, p.folds, !!cut, popts.sys) };
525
594
  return this.commit(e, ctx, plan, o);
@@ -547,6 +616,7 @@ export class Zip implements ZipControl {
547
616
  }
548
617
  if (!plan.sent) this.nextEdit = plan.untouched ?? 0; // persisted now, first sent with the next request
549
618
  const ours: Any[] = live.map((t) => ({ type: "context_edit", targetId: t.entryId, replacement: { content: [{ type: "text", text: t.ph }] } }));
619
+ for (const t of live) if (t.toolCallId) this.foldedCalls.set(t.toolCallId, handleFor(t.entryId));
550
620
  if (cut) ours.push({ type: "compaction", summary: cut.text, firstKeptEntryId: cut.firstKeptEntryId, details: { by: PRODUCT, trigger: cut.trigger }, usage: cut.usage });
551
621
  const before = Math.round(k * live.reduce((a, t) => a + t.entryTokens, 0));
552
622
  const after = Math.round(k * live.reduce((a, t) => a + t.phTokens, 0));
@@ -668,10 +738,11 @@ export class Zip implements ZipControl {
668
738
  } catch {}
669
739
  }
670
740
  /** What this session carries: folds and summaries persisted in its file (they survive restarts) and the recalls in its branch. */
671
- private sessionTotals(ctx: Any): string {
741
+ private totals(ctx: Any) {
672
742
  const branch = ctx ? this.branch(ctx) : [];
673
743
  const byId = new Map(branch.map((e: Any) => [e?.id, e]));
674
744
  let folds = 0, folded = 0, summaries = 0, recalls = 0, usd = 0;
745
+ let last: Any = null;
675
746
  for (const e of branch) {
676
747
  const ph = String(e?.replacement?.content?.[0]?.text ?? "");
677
748
  if (e?.type === "context_edit" && ph.startsWith(PH_MARK)) {
@@ -681,11 +752,36 @@ export class Zip implements ZipControl {
681
752
  summaries++;
682
753
  usd += Number(e.usage?.cost?.total) || 0;
683
754
  } else if (e?.type === "custom" && e.customType === UNUSED_SUMMARY_CUSTOM) usd += Number(e.data?.usd) || 0;
755
+ else if (e?.type === "custom" && e.customType === NOTICE_CUSTOM && e.data?.v === 2 && !e.data?.kind) last = e;
684
756
  else if (e?.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === RECALL_TOOL) recalls++;
685
757
  }
758
+ return { folds, folded, summaries, recalls, usd, last };
759
+ }
760
+ /** What this session carries: folds and summaries persisted in its file (they survive restarts) and the recalls in its branch. */
761
+ private sessionTotals(ctx: Any): string {
762
+ const { folds, folded, summaries, recalls, usd } = this.totals(ctx);
686
763
  const n = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
687
764
  return `this session: ${n(folds, "folded output", "folded outputs")} (~${fmtK(folded)} tokens), ${n(summaries, "summary", "summaries")}${usd > 0 ? ` (summary calls $${usd.toFixed(4)})` : ""}, ${n(recalls, "recall", "recalls")}`;
688
765
  }
766
+ /** The /zip status card (rendered by ui.ts renderCard). */
767
+ card(ctx?: Any): CardData {
768
+ const t = this.totals(ctx);
769
+ const model = ctx?.model ?? this.model;
770
+ const key = modelKey(model), ttlS = ttlFor(model) / 1000, ent = key ? loadStats().models[key] : undefined;
771
+ const alive = key ? SPARK_GAPS.map((g) => pWarm(ent, g, ttlS).p) : undefined;
772
+ let lifeS = 0;
773
+ for (let g = 30; g <= 7200; g += 30) if (pWarm(ent, g, ttlS).p >= 0.5) lifeS = g;
774
+ const learned = !!ent && ent.n > 0 && pWarm(ent, Math.max(30, lifeS), ttlS).src === "learned";
775
+ const life = key ? `${lifeS >= 7200 ? "over 2 h" : fmtLife(lifeS || ttlS)} (${learned ? `learned from ${Math.round(ent!.n)} ${Math.round(ent!.n) === 1 ? "reply" : "replies"}` : "declared"})` : undefined;
776
+ const ts = t.last?.timestamp ? new Date(t.last.timestamp) : null;
777
+ const hhmm = ts && !Number.isNaN(ts.getTime()) ? `${String(ts.getHours()).padStart(2, "0")}:${String(ts.getMinutes()).padStart(2, "0")} ` : "";
778
+ const last = t.last ? `${hhmm}${t.last.data.desc}${t.last.data.why ? " · " + t.last.data.why : ""}` : undefined;
779
+ const state = this.off ? "off" : this.conflict ? "paused" : this.recallOk ? "on" : "reread-only";
780
+ return {
781
+ state, stateNote: state === "paused" ? `${this.conflict} also manages context` : state === "off" ? "/zip on to resume" : undefined,
782
+ model: key || undefined, cls: ent?.cls, life, alive, folds: t.folds, foldedTokens: fmtK(t.folded), summaries: t.summaries, summaryUsd: t.usd, recalls: t.recalls, last, quiet: this.quiet,
783
+ };
784
+ }
689
785
  status(ctx?: Any): string {
690
786
  const state = this.off ? "off" : this.conflict ? `paused ("${this.conflict}" also manages context; only the request guard is on)` : "on";
691
787
  const key = modelKey(this.model), ttl = ttlFor(this.model), ent = loadStats().models[key];
package/src/ui.ts ADDED
@@ -0,0 +1,158 @@
1
+ // Transcript surfaces (pure, testable): the status card, state lines, the zip_recall row and the fold mark on a folded output.
2
+ // One visual grammar everywhere: "▸ pi-zip" in the accent colour, numbers in the text colour, everything else dim, no boxes;
3
+ // collapsed = the conclusion, ctrl+o = the detail. Every returned line fits the given width.
4
+ import { PRODUCT } from "./util.ts";
5
+
6
+ export interface Th {
7
+ fg(color: string, text: string): string;
8
+ }
9
+ /** Display width and truncation: Pi's visibleWidth / truncateToWidth in the renderer, string length in tests. */
10
+ export interface Measure {
11
+ vw(s: string): number;
12
+ cut(s: string, width: number): string; // plain text, at most width columns, with "…" when cut
13
+ }
14
+ export const plainMeasure: Measure = {
15
+ vw: (s) => s.length,
16
+ cut: (s, w) => (s.length <= w ? s : w <= 0 ? "" : s.slice(0, w - 1) + "…"),
17
+ };
18
+ /** The measure renderers use: index.ts swaps in Pi's own (pi-tui) when it loads. */
19
+ export let measure: Measure = plainMeasure;
20
+ export function setMeasure(m: Measure) {
21
+ measure = m;
22
+ }
23
+
24
+ export const MARK = "▸ ";
25
+ const HEAD = MARK + PRODUCT;
26
+ const LABEL_W = 9;
27
+
28
+ /** Plain text without control characters (tabs become two spaces): recalled output goes into the TUI as text, never as escape codes. */
29
+ export const clean = (s: string): string => s.replace(/\t/g, " ").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
30
+
31
+ // ---- state lines -------------------------------------------------------------------------------------------------------
32
+
33
+ export type StateWord = "on" | "off" | "paused" | "reread-only" | "quiet" | "notices on" | "cache";
34
+ const WARN = new Set<StateWord>(["off", "paused", "reread-only"]);
35
+
36
+ /** "▸ pi-zip paused billion-context also manages context · run: pi remove npm:billion-context" */
37
+ export function renderState(word: StateWord, reason: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
38
+ const wordC = th.fg(WARN.has(word) ? "warning" : "accent", word);
39
+ const base = `${HEAD} ${word}`;
40
+ if (m.vw(base) > width) return m.vw(word) <= width ? [wordC] : [];
41
+ const room = width - m.vw(base) - 2;
42
+ const r = reason && room >= 12 ? m.cut(reason, room) : "";
43
+ return [`${th.fg("accent", HEAD)} ${wordC}${r ? " " + th.fg("dim", r) : ""}`];
44
+ }
45
+
46
+ // ---- status card ------------------------------------------------------------------------------------------------------
47
+
48
+ export interface CardData {
49
+ state: "on" | "off" | "paused" | "reread-only";
50
+ stateNote?: string; // why paused / reread-only
51
+ model?: string; // provider/id
52
+ cls?: string; // explicit | automatic
53
+ life?: string; // "~5 min (declared)" | "~1 h (learned from 42 returns)"
54
+ alive?: number[]; // P(warm) at SPARK_GAPS
55
+ folds: number;
56
+ foldedTokens: string; // "310K"
57
+ summaries: number;
58
+ summaryUsd: number;
59
+ recalls: number;
60
+ last?: string; // "10:50 folded 12 old outputs · cache cold (away 47 min)"
61
+ quiet: boolean;
62
+ }
63
+
64
+ /** Gaps (seconds) the "alive" sparkline samples: 30 s to 2 h, denser where caches usually expire. */
65
+ export const SPARK_GAPS = [30, 60, 120, 180, 240, 300, 360, 420, 600, 900, 1200, 1800, 2700, 3600, 5400, 7200];
66
+ const SPARK = "▁▂▃▄▅▆▇█";
67
+ export const sparkline = (ps: number[]): string => ps.map((p) => SPARK[Math.max(0, Math.min(7, Math.round(p * 7)))]).join("");
68
+
69
+ /** "~5 min", "~1 h", "~2.5 h" */
70
+ export function fmtLife(s: number): string {
71
+ if (s < 90) return `~${Math.round(s)} s`;
72
+ if (s < 5400) return `~${Math.round(s / 60)} min`;
73
+ return `~${+(s / 3600).toFixed(1)} h`;
74
+ }
75
+
76
+ export function cardText(c: CardData): string[] {
77
+ const rows: [string, string][] = [];
78
+ if (c.model) rows.push(["cache", [c.model, c.cls ?? "class unknown until the first reply", c.life ? `lives ${c.life}` : ""].filter(Boolean).join(" · ")]);
79
+ if (c.alive?.length) rows.push(["alive", `${sparkline(c.alive)} 30 s → 2 h`]);
80
+ const n = (x: number, one: string, many = one + "s") => `${x} ${x === 1 ? one : many}`;
81
+ rows.push(["session", `${n(c.folds, "fold")} ~${c.foldedTokens} · ${n(c.summaries, "summary", "summaries")}${c.summaryUsd > 0 ? ` $${c.summaryUsd.toFixed(2)}` : ""} · ${n(c.recalls, "recall")}`]);
82
+ if (c.last) rows.push(["last", c.last]);
83
+ const mode = c.state === "reread-only" ? "reread-only (zip_recall hidden by a tool allowlist)" : "full (zip_recall available)";
84
+ rows.push(["mode", `${mode}${c.quiet ? " · notices off" : ""}`]);
85
+ return rows.map(([k, v]) => `${k.padEnd(LABEL_W)}${v}`);
86
+ }
87
+
88
+ export function renderCard(c: CardData, width: number, th: Th, m: Measure = plainMeasure): string[] {
89
+ const out = renderState(c.state, c.stateNote ?? "", width, th, m);
90
+ const indent = " ";
91
+ for (const row of cardText(c)) {
92
+ const line = m.cut(indent + row, width);
93
+ if (!line) continue;
94
+ const warn = row.startsWith("mode") && c.state === "reread-only";
95
+ const k = line.slice(0, indent.length + LABEL_W), v = line.slice(indent.length + LABEL_W);
96
+ out.push(th.fg("dim", k) + (row.startsWith("session") ? th.fg("text", v) : th.fg(warn ? "warning" : "dim", v)));
97
+ }
98
+ return out;
99
+ }
100
+
101
+ // ---- zip_recall row ------------------------------------------------------------------------------------------------------
102
+
103
+ export interface RecallSectionInfo {
104
+ handle: string;
105
+ label?: string; // "bash npm test"
106
+ turn?: number;
107
+ totalLines?: number;
108
+ shownLines?: number;
109
+ how?: "all" | "grep" | "range" | "page";
110
+ missing?: boolean;
111
+ }
112
+
113
+ /** "↺ recall k3x9q2m7ab grep "Expected"" */
114
+ export function recallCallLine(args: { handle?: string; handles?: string[]; grep?: string; range?: string; offset?: number }, width: number, th: Th, m: Measure = plainMeasure): string[] {
115
+ const hs = [...(typeof args?.handle === "string" ? [args.handle] : []), ...(Array.isArray(args?.handles) ? args.handles.filter((h) => typeof h === "string") : [])];
116
+ const what = hs.length > 1 ? `${hs.length} handles` : (hs[0] ?? "");
117
+ const opt = [args?.grep ? `grep "${args.grep}"` : "", args?.range ? `lines ${args.range}` : "", args?.offset ? `from char ${args.offset}` : ""].filter(Boolean).join(" · ");
118
+ const plain = m.cut(`↺ recall ${what}${opt ? " " + opt : ""}`, width);
119
+ const head = "↺ recall";
120
+ if (!plain.startsWith(head)) return [th.fg("toolTitle", plain)];
121
+ return [th.fg("toolTitle", head) + th.fg("accent", plain.slice(head.length, head.length + 1 + what.length)) + th.fg("dim", plain.slice(head.length + 1 + what.length))];
122
+ }
123
+
124
+ export function sectionLine(s: RecallSectionInfo): string {
125
+ if (s.missing) return `${s.handle} not found`;
126
+ const where = [s.label, s.turn ? `turn ${s.turn}` : ""].filter(Boolean).join(" · ");
127
+ const total = s.totalLines ?? 0;
128
+ const got = s.how === "all" ? `all ${total} lines` : `${s.shownLines ?? 0} of ${total} lines`;
129
+ return `${where || s.handle} ${got}`;
130
+ }
131
+
132
+ /** Collapsed: one line per handle ("bash npm test · turn 1 3 of 812 lines"). Expanded: the recalled text, at most maxLines. */
133
+ export function recallResultLines(sections: RecallSectionInfo[], text: string, expanded: boolean, width: number, th: Th, m: Measure = plainMeasure, maxLines = 40): string[] {
134
+ const out: string[] = [];
135
+ for (const s of sections) {
136
+ const l = m.cut(sectionLine(s), width);
137
+ if (l) out.push(s.missing ? th.fg("error", l) : th.fg("dim", l));
138
+ }
139
+ if (!sections.length && text) out.push(th.fg("dim", m.cut(clean(text.split("\n")[0]), width)));
140
+ if (!expanded || !text) return out;
141
+ const lines = clean(text).split("\n");
142
+ for (const l of lines.slice(0, maxLines)) out.push(th.fg("toolOutput", m.cut(l, width)));
143
+ if (lines.length > maxLines) out.push(th.fg("dim", m.cut(`… ${lines.length - maxLines} more lines (the model got them all)`, width)));
144
+ return out;
145
+ }
146
+
147
+ // ---- the mark on a folded output ---------------------------------------------------------------------------------------
148
+
149
+ /** Right-aligns "▸ folded · <handle>" on the first line of a tool call row, if it fits; otherwise leaves the row alone. */
150
+ export function markFirstLine(lines: string[], handle: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
151
+ if (!lines.length) return lines;
152
+ const mark = `${MARK}folded · ${handle}`;
153
+ // renderers often pad a row to the full width (and may close styles after the padding): drop the padding, keep the codes
154
+ const first = lines[0].replace(/ +((?:\x1b\[[0-9;]*m)*)$/, "$1");
155
+ const gap = width - m.vw(first) - m.vw(mark);
156
+ if (gap < 2) return lines;
157
+ return [first + " ".repeat(gap) + th.fg("dim", mark), ...lines.slice(1)];
158
+ }