pi-posthorse 0.4.4 → 0.4.6

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 (4) hide show
  1. package/README.md +12 -6
  2. package/index.ts +156 -71
  3. package/package.json +8 -3
  4. package/ui.ts +180 -0
package/README.md CHANGED
@@ -8,6 +8,8 @@ Native, no-summary context windows for the [`fitchmultz/pi`](https://github.com/
8
8
 
9
9
  Pi owns the persisted boundary. Posthorse owns the policy: stable window guidance, one best-effort checkpoint reminder, `new_context`, `get_context_remaining`, durable `notes`, and window-aware `history`. A rollover removes the old window from active model context while the JSONL transcript stays append-only and complete.
10
10
 
11
+ An isolated [Codex prototype](adapters/codex-posthorse/README.md) tests local notes and recovery with Codex's native context reset. It is not ready for everyday use and is not included in the Pi package.
12
+
11
13
  ## Requirements
12
14
 
13
15
  - Node `>=22.19.0`.
@@ -40,7 +42,7 @@ pi -e git:github.com/fitchmultz/pi-posthorse # try it for one run w
40
42
 
41
43
  Update with `pi update npm:pi-posthorse` or `pi update --extensions`; move a pinned Git install with `pi install git:github.com/fitchmultz/pi-posthorse@<new tag>`. Uninstall with `pi remove npm:pi-posthorse` (or the Git source you installed). Removing the package leaves `.pi/notes` and Pi's session history in place.
42
44
 
43
- After installing or updating Posthorse, run `/reload` in existing Pi sessions or restart them to load the new version.
45
+ Restart Pi after installing or updating Posthorse to load the new extension code.
44
46
 
45
47
  Keep exactly one copy loaded. `pi list` shows every package source; if an older entry such as `git:github.com/fitchmultz/pi-headroom.git` or a local checkout is still listed, `pi remove` it before installing the npm package, otherwise two copies register the same tools and compete for the same rollover hook.
46
48
 
@@ -52,7 +54,7 @@ Keep exactly one copy loaded. `pi list` shows every package source; if an older
52
54
  4. **`new_context`.** Requests an atomic rollover after the complete tool batch succeeds. An optional handoff is persisted and becomes the first state of the fresh window. If a sibling tool in the same batch fails, Pi does not commit the boundary; the checkpoint reminder still applies.
53
55
  5. **Automatic rollover without summaries.** With a supported context budget and room for a recovery record, Posthorse claims Pi's automatic threshold and overflow trigger through `session_before_auto_compact`, before Pi resolves summarization credentials or prepares a summary. Oversized first turns and tool results can then roll over even without summarization credentials. Otherwise Pi's own compaction remains in control. Manual `/compact` is unchanged.
54
56
  6. **Bounded recovery record.** The automatic handoff keeps direct user inputs, `ask_question` outcomes, visible coordination messages, and the trailing tool batch that no model has consumed yet (call arguments, bounded result text, and the entry ids to recover the rest). A clearly labeled, possibly stale older checkpoint comes last, after the current inputs and unseen results. Older assistant prose and consumed tool results are not treated as state. Newly submitted input stays separate and is saved after the boundary, not copied into the handoff.
55
- 7. **`notes` and `history`.** Notes live with the repository root, shared across linked worktrees. History searches normalized transcript text and returns stored images for a requested entry.
57
+ 7. **`notes` and `history`.** Notes are shared across linked worktrees, at the main checkout for conventional Git layouts or inside the common Git directory when metadata is stored separately. History searches normalized transcript text and returns stored images for a requested entry.
56
58
 
57
59
  At turn end, Posthorse checks whether usage is in the reminder band before explicitly looking up the full branch. Context filtering skips its branch lookup when model input contains neither `posthorse-reminder` nor legacy `headroom-reminder` messages. History searches, reads, and recovery remain available with no new limits.
58
60
 
@@ -75,20 +77,24 @@ Only one automatic compaction or rollover policy extension should be enabled at
75
77
  - `notes({ op, ... })`: `list`, `read` (paged; `offset` continues), `write` (empty content clears), `append` (one atomic newline-terminated record), `search` (excerpts centered on the match)
76
78
  - `history({ op, ... })`: `search`, `read`; results carry native window ids, reads return stored images with the first page and the next character offset when text remains
77
79
 
78
- Read pages, including returned images, shrink to the context that is actually left. Before usage is known, they reserve prompt/tool overhead and leave half the rest free. Unsafe pages are refused with the offset preserved; call `new_context` and retry.
80
+ In the TUI, tools use Pi's native expandable cards. Collapsed cards show the operation and target, a short content preview, and counts or page ranges with the next offset when more remains. Expand with Pi's tool-output shortcut (`Ctrl+O` by default), or click the card's header or body in fullscreen mode. Expanded cards show the complete returned page and its metadata, not content the tool has not fetched yet. Writes and context requests also show the submitted content or handoff.
81
+
82
+ Committed context-window messages and checkpoint reminders are compact, expandable cards too. The `new_context` tool card describes a request; only the committed context-window message says a fresh window has started.
83
+
84
+ Read pages, including returned images, shrink to the context that is actually left. Parallel note and history reads share that budget; pages already counted by Pi are not counted twice. Before usage is known, they reserve prompt/tool overhead and leave half the rest free. Unsafe pages are refused with the offset preserved in the call; call `new_context` and retry. Refusal text uses the same budget, so later failures omit repeated guidance once no more fits.
79
85
 
80
86
  `history search` puts matching original content before recovery material: handoffs, compaction and branch summaries, checkpoint reminders, and `notes`, `new_context`, and `history` calls/results. Ordinary prose or another tool call in the same assistant entry keeps its priority when that content matches. Every entry remains searchable; `history read` returns the complete normalized entry, including any recovery content omitted from a search excerpt.
81
87
 
82
88
  Within each group, current-branch matches are newest first. With `all: true`, Posthorse searches every session file in the active Pi session directory, newest-modified sessions first and newest entries within each session; this is not a global timestamp sort. The result limit applies after priority, so newer echoes cannot displace older original matches. Entries copied by a fork are reported once.
83
89
 
84
- Notes live in `.pi/notes/` at the repository root (the main checkout for a linked worktree, the current directory outside Git). Add the directory to `.gitignore` when the project should not track it.
90
+ Notes live in `.pi/notes/` at the main checkout for conventional Git layouts, or the current directory outside Git. When Git metadata is stored separately, including in submodules, all checkouts use `.pi/notes/` inside Git's common directory instead; no configuration is needed. If that metadata is unavailable, notes stay local to the checkout. Old checkout-local notes are imported when that checkout is accessed, without overwriting shared files or deleting the originals. Writes report the actual storage path. Add `.pi/notes/` to `.gitignore` when the project should not track it.
85
91
 
86
92
  ## Data and privacy
87
93
 
88
94
  - Posthorse makes no network requests.
89
95
  - Notes are plaintext files under `.pi/notes`. They survive package removal and may be committed unless ignored.
90
96
  - `history` with `all: true` scans nested JSONL files in the active Pi session directory, including subagent sessions.
91
- - History can return user text, assistant text and thinking, tool arguments and results, handoffs, custom messages, and images. Direct shell entries Pi marked `excludeFromContext` come back as a placeholder only.
97
+ - History can return user text, assistant text and thinking, assistant failure status and error messages, tool arguments and results, handoffs, custom messages, and images. Direct shell entries Pi marked `excludeFromContext` come back as a placeholder only.
92
98
  - Returned history content enters the currently selected model and provider context.
93
99
  - Removing Posthorse does not remove notes or Pi session history.
94
100
 
@@ -105,4 +111,4 @@ npm run check
105
111
  PI_FORK=../pi scripts/integration.sh # loads the real extension into the fork's test harness (fork built)
106
112
  ```
107
113
 
108
- `npm run check` type-checks `index.ts` and the unit tests; the integration test runs inside the fork's harness. Unit tests cover reminder boundaries, skipped branch lookups, and legacy reminders; native integration tests cover rollover and history recovery. CI runs the unit tests on Node 22.19 and 24, `npm audit`, `npm pack --dry-run`, and the integration job against the pinned fork revision.
114
+ `npm run check` type-checks the extension, renderers, and tests. Tests cover reminder policy, notes/history behavior, and real native card components, including width, expansion, and legacy results. The pinned Pi 0.85.0 SDK's unbundled entry imports an undeclared server package, so test-only resolution loads its shipped native bundle. Integration tests run inside the fork's harness and cover rollover and history recovery; use a disposable built fork checkout because the script briefly copies the test into its test directory. CI runs the unit tests on Node 22.19 and 24, `npm audit`, `npm pack --dry-run`, and the integration job against the pinned fork revision.
package/index.ts CHANGED
@@ -8,18 +8,21 @@
8
8
 
9
9
  import {
10
10
  appendFileSync,
11
+ constants,
12
+ copyFileSync,
11
13
  createReadStream,
12
14
  existsSync,
13
15
  mkdirSync,
14
16
  readdirSync,
15
17
  readFileSync,
18
+ realpathSync,
16
19
  statSync,
17
20
  writeFileSync,
18
21
  } from "node:fs";
19
- import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
20
- import { createInterface } from "node:readline";
22
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
21
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
24
  import { Type } from "typebox";
25
+ import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
23
26
 
24
27
  const REMINDER_BUFFER_TOKENS = 32_000;
25
28
  /** Absolute ceiling for handoffs and read pages; pages shrink to the live remaining budget. */
@@ -63,6 +66,7 @@ type ImageLike = { type: "image"; data: string; mimeType: string };
63
66
  type MessageLike = {
64
67
  role?: string;
65
68
  stopReason?: string;
69
+ errorMessage?: string;
66
70
  content?: unknown;
67
71
  toolName?: string;
68
72
  toolCallId?: string;
@@ -86,7 +90,7 @@ type EntryLike = {
86
90
  };
87
91
 
88
92
  type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
89
- type HistoryHit = { id: string; text: string; priority: 0 | 1 };
93
+ type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
90
94
  type RecoveryRecord = {
91
95
  id: string;
92
96
  timestamp: string;
@@ -159,25 +163,53 @@ function textOf(message: MessageLike): string {
159
163
  .join("\n");
160
164
  }
161
165
 
162
- function textResult(text: string, images: ImageLike[] = []) {
163
- return { content: [{ type: "text" as const, text }, ...images], details: undefined };
166
+ function textResult(text: string, images: ImageLike[] = [], display?: PosthorseDisplay) {
167
+ return { content: [{ type: "text" as const, text }, ...images], details: display };
164
168
  }
165
169
 
166
- /**
167
- * Notes belong to the repository root, walking up from cwd. A linked worktree's `.git` is a file
168
- * ("gitdir: <main>/.git/worktrees/<name>"); its notes belong to the main checkout so every worktree
169
- * shares them and they outlive the worktree. Outside Git, cwd is the root.
170
- */
170
+ /** Import old checkout-local notes without overwriting shared notes, including concurrent writes. */
171
+ function importLegacyNotes(source: string, target: string): void {
172
+ if (!existsSync(source)) return;
173
+ if (existsSync(target) && (!statSync(target).isDirectory() || realpathSync(source) === realpathSync(target))) return;
174
+ mkdirSync(target, { recursive: true });
175
+ for (const name of readdirSync(source)) {
176
+ const from = join(source, name);
177
+ const to = join(target, name);
178
+ if (statSync(from).isDirectory()) {
179
+ importLegacyNotes(from, to);
180
+ } else {
181
+ try {
182
+ copyFileSync(from, to, constants.COPYFILE_EXCL);
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ /** Conventional repos use the main checkout; separate Git directories are their own shared root. */
171
191
  function notesRoot(cwd: string): string {
172
192
  for (let dir = cwd; ; dir = dirname(dir)) {
173
193
  const marker = join(dir, ".git");
174
194
  if (existsSync(marker)) {
195
+ let root: string;
175
196
  try {
176
- const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
177
- const main = gitdir && resolve(dir, gitdir).match(/^(.+)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/)?.[1];
178
- if (main) return main;
179
- } catch {}
180
- return dir;
197
+ let target = marker;
198
+ if (!statSync(marker).isDirectory()) {
199
+ const gitdir = readFileSync(marker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
200
+ if (!gitdir) return dir;
201
+ target = resolve(dir, gitdir);
202
+ }
203
+ target = realpathSync(target);
204
+ const commonFile = join(target, "commondir");
205
+ const common = realpathSync(existsSync(commonFile) ? resolve(target, readFileSync(commonFile, "utf8").trim()) : target);
206
+ root = basename(common) === ".git" ? dirname(common) : common;
207
+ } catch {
208
+ // Orphaned/copied worktrees can outlive their Git metadata; local notes still work.
209
+ return dir;
210
+ }
211
+ if (root !== dir) importLegacyNotes(join(dir, ".pi", "notes"), join(root, ".pi", "notes"));
212
+ return root;
181
213
  }
182
214
  if (dirname(dir) === dir) return cwd;
183
215
  }
@@ -194,6 +226,12 @@ function excerptAround(text: string, index: number, before: number, length: numb
194
226
  return `${start ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
195
227
  }
196
228
 
229
+ function assistantFailure(message: MessageLike): string {
230
+ if (message.role !== "assistant") return "";
231
+ if (message.stopReason !== "error" && message.stopReason !== "aborted" && !message.errorMessage) return "";
232
+ return `[${message.stopReason ?? "error"}]${message.errorMessage ? ` ${message.errorMessage}` : ""}`;
233
+ }
234
+
197
235
  function flattenEntry(entry: EntryLike): string | undefined {
198
236
  if (entry.type === "message") {
199
237
  const message = entry.message ?? {};
@@ -202,7 +240,7 @@ function flattenEntry(entry: EntryLike): string | undefined {
202
240
  if (message.excludeFromContext === true) return "[bashExecution] (excluded from model context by Pi)";
203
241
  return `[bashExecution] $ ${message.command ?? ""}\n${message.output ?? ""}`;
204
242
  }
205
- return `[${message.role ?? "message"}] ${[textOf(message), imageSummary(imagesOf(message.content))].filter(Boolean).join("\n")}`;
243
+ return `[${message.role ?? "message"}] ${[textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content))].filter(Boolean).join("\n")}`;
206
244
  }
207
245
  if (entry.type === "compaction" || entry.type === "branch_summary") {
208
246
  return `[${entry.type}] ${entry.summary ?? ""}`;
@@ -253,8 +291,8 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
253
291
  if (partText) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${partText}`;
254
292
  }
255
293
  }
256
- const images = imageSummary(item.images);
257
- if (images) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${images}`;
294
+ const metadata = [assistantFailure(entry.message), imageSummary(item.images)].filter(Boolean).join("\n");
295
+ if (metadata) originals[originals.length - 1] += `${originals.at(-1) ? "\n" : ""}${metadata}`;
258
296
  if (originals[0]) originals[0] = `[assistant] ${originals[0]}`;
259
297
  const original = originals.find((part) => part.toLowerCase().includes(query));
260
298
  priority = original ? 0 : 1;
@@ -263,10 +301,12 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
263
301
  matchIndex = text.toLowerCase().indexOf(query);
264
302
  }
265
303
  }
304
+ const header = `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${entry.id}] `;
266
305
  return {
267
306
  id: entry.id!,
268
307
  priority,
269
- text: `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${entry.id}] ${excerptAround(text, matchIndex, 100, 400)}`,
308
+ text: `${header}${excerptAround(text, matchIndex, 100, 400)}`,
309
+ headerLength: header.length,
270
310
  };
271
311
  }
272
312
 
@@ -288,25 +328,30 @@ function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry>
288
328
  }
289
329
  }
290
330
 
331
+ /** JSONL splits on LF, not the Unicode separators that Node's readline also recognizes. */
332
+ async function* jsonlLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
333
+ const stream = createReadStream(file, { encoding: "utf8", signal });
334
+ let pending = "";
335
+ for await (const chunk of stream) {
336
+ const lines = (pending + chunk).split("\n");
337
+ pending = lines.pop()!;
338
+ yield* lines;
339
+ }
340
+ if (pending) yield pending;
341
+ }
342
+
291
343
  async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
292
344
  if (!existsSync(file)) return;
293
- const stream = createReadStream(file, { encoding: "utf8", signal });
294
- const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
295
345
  const windows = new Map<string, string>();
296
- try {
297
- for await (const line of lines) {
298
- let entry: EntryLike;
299
- try {
300
- entry = JSON.parse(line) as EntryLike;
301
- } catch {
302
- continue;
303
- }
304
- const item = toWindowedEntry(entry, windows);
305
- if (item) yield item;
346
+ for await (const line of jsonlLines(file, signal)) {
347
+ let entry: EntryLike;
348
+ try {
349
+ entry = JSON.parse(line) as EntryLike;
350
+ } catch {
351
+ continue;
306
352
  }
307
- } finally {
308
- lines.close();
309
- stream.destroy();
353
+ const item = toWindowedEntry(entry, windows);
354
+ if (item) yield item;
310
355
  }
311
356
  }
312
357
 
@@ -633,26 +678,13 @@ function unsupportedMessage(budget: Budget): string {
633
678
  return `Posthorse: unsupported configuration. The model's context window (${n(budget.contextWindow)} tokens) minus Pi's compaction.reserveTokens (${n(budget.reserveTokens)}) leaves ${n(budget.usable)} usable tokens; Posthorse needs at least ${n(MIN_USABLE_TOKENS)}. Automatic rollover and checkpoint reminders are off for this model. Lower compaction.reserveTokens in Pi settings or use a larger-context model. new_context remains available with a model-aware handoff limit.`;
634
679
  }
635
680
 
636
- /** Characters that fit before Pi's automatic line (or the hard limit), capped at the absolute ceiling. */
637
- function requirePage(ctx: NativeContext, offset: number, imageCount: number, toolTokens: number): number {
681
+ /** Capacity before Pi's automatic line (or hard limit), including the margin for page metadata and refusals. */
682
+ function pageCapacity(ctx: NativeContext, toolTokens: number): number {
638
683
  const usage = ctx.getContextUsage();
639
- let chars: number;
640
- if (!usage || usage.tokens == null) {
641
- chars = Math.max(0, freshPayloadChars(ctx, toolTokens) - imageCount * ESTIMATED_IMAGE_CHARS);
642
- } else {
643
- const budget = budgetFor(ctx, usage.contextWindow);
644
- const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
645
- chars = Math.min(
646
- MAX_HANDOFF_CHARS,
647
- Math.max(0, line - usage.tokens - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
648
- );
649
- }
650
- if (chars < MIN_PAGE_CHARS) {
651
- throw new Error(
652
- `Too little context remains to read a page safely. Call new_context first, then retry with offset ${offset}.`,
653
- );
654
- }
655
- return chars;
684
+ if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens) / 4 + PAGE_MARGIN_TOKENS;
685
+ const budget = budgetFor(ctx, usage.contextWindow);
686
+ const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
687
+ return line - usage.tokens;
656
688
  }
657
689
 
658
690
  function buildGuidance(ctx: NativeContext): string {
@@ -678,6 +710,7 @@ Automatic handoffs are emergency recovery records, not proof of current state. R
678
710
  }
679
711
 
680
712
  export default function (pi: ExtensionAPI) {
713
+ registerPosthorseMessages(pi);
681
714
  const activeToolTokens = () => {
682
715
  const active = new Set(pi.getActiveTools());
683
716
  return pi
@@ -694,7 +727,42 @@ export default function (pi: ExtensionAPI) {
694
727
  );
695
728
  };
696
729
 
730
+ let pendingPageTokens = 0;
731
+ let previousPageUsage: number | null | undefined;
732
+ const pageSize = (ctx: NativeContext, offset: number, imageCount: number) => {
733
+ const usage = ctx.getContextUsage()?.tokens;
734
+ // Parallel siblings are not in native usage yet; sequential results must not be counted twice.
735
+ if (usage != null && previousPageUsage != null) {
736
+ pendingPageTokens = Math.max(0, pendingPageTokens - Math.max(0, usage - previousPageUsage));
737
+ }
738
+ previousPageUsage = usage;
739
+ const remaining = pageCapacity(ctx, activeToolTokens()) - pendingPageTokens;
740
+ const chars = Math.min(
741
+ MAX_HANDOFF_CHARS,
742
+ Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
743
+ );
744
+ if (chars < MIN_PAGE_CHARS) {
745
+ const message = `Too little context remains to read a page safely. Call new_context first, then retry with offset ${offset}.`;
746
+ // Refusals consume context too. Once even the guidance no longer fits, omit repeated text;
747
+ // the failed call still carries its original offset and earlier refusals explain the retry.
748
+ const text = Math.ceil(message.length / 4) <= remaining ? message : "";
749
+ pendingPageTokens += Math.ceil(text.length / 4);
750
+ throw new Error(text);
751
+ }
752
+ return chars;
753
+ };
754
+ const pageResult = (text: string, images: ImageLike[], display: PosthorseDisplay) => {
755
+ pendingPageTokens += Math.ceil(text.length / 4) + images.length * (ESTIMATED_IMAGE_CHARS / 4);
756
+ return textResult(text, images, display);
757
+ };
758
+ pi.on("turn_start", () => {
759
+ pendingPageTokens = 0;
760
+ previousPageUsage = undefined;
761
+ });
762
+
697
763
  pi.on("session_start", (_event, ctx) => {
764
+ pendingPageTokens = 0;
765
+ previousPageUsage = undefined;
698
766
  nativeContext(ctx);
699
767
  });
700
768
 
@@ -781,6 +849,7 @@ export default function (pi: ExtensionAPI) {
781
849
  pi.registerTool({
782
850
  name: "new_context",
783
851
  label: "New Context",
852
+ ...toolCards("new_context"),
784
853
  description:
785
854
  "Start a genuinely fresh context window after this tool batch. Earlier conversation leaves active context without a generated summary but remains recoverable through history. Pass concise continuation state in handoff, or save richer state with notes first.",
786
855
  promptSnippet: "start a fresh context window with an optional atomic handoff",
@@ -807,6 +876,8 @@ export default function (pi: ExtensionAPI) {
807
876
  return {
808
877
  ...textResult(
809
878
  "Requested a fresh Pi context after this complete tool batch succeeds. Earlier conversation stays in session history.",
879
+ [],
880
+ { kind: "new-context" },
810
881
  ),
811
882
  newContext: { handoff: trimmed },
812
883
  };
@@ -816,6 +887,7 @@ export default function (pi: ExtensionAPI) {
816
887
  pi.registerTool({
817
888
  name: "get_context_remaining",
818
889
  label: "Context Remaining",
890
+ ...toolCards("get_context_remaining"),
819
891
  description:
820
892
  "Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the model's hard limit.",
821
893
  promptSnippet: "check the remaining context budget only when needed",
@@ -823,14 +895,20 @@ export default function (pi: ExtensionAPI) {
823
895
  async execute(_id, _params, _signal, _onUpdate, ctx) {
824
896
  const native = nativeContext(ctx);
825
897
  const usage = native.getContextUsage();
826
- if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.");
898
+ if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.", [], { kind: "context" });
827
899
  const n = (value: number) => value.toLocaleString("en-US");
828
900
  const budget = budgetFor(native, usage.contextWindow);
901
+ const display: PosthorseDisplay = {
902
+ kind: "context", usage,
903
+ rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
904
+ rolloverAt: budget?.rolloverAt,
905
+ };
829
906
  const hard = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the hard context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate.`;
830
- if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}`);
831
- if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}`);
907
+ if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}`, [], display);
908
+ if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}`, [], display);
832
909
  return textResult(
833
910
  `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${hard}`,
911
+ [], display,
834
912
  );
835
913
  },
836
914
  });
@@ -838,8 +916,9 @@ export default function (pi: ExtensionAPI) {
838
916
  pi.registerTool({
839
917
  name: "notes",
840
918
  label: "Notes",
919
+ ...toolCards("notes"),
841
920
  description:
842
- "Persistent notes in .pi/notes/ that survive context resets. Ops: list, read (paged; pass offset to continue), write (create/replace; empty content clears), append, search (case-insensitive substring over note lines). Inside a Git repository, including nested directories and linked worktrees, notes belong to the main checkout.",
921
+ "Persistent notes in .pi/notes/ that survive context resets. Ops: list, read (paged; pass offset to continue), write (create/replace; empty content clears), append, search (case-insensitive substring over note lines). Git worktrees share notes: at the main checkout for conventional .git layouts, or in the common Git directory when metadata is stored separately. Old checkout-local notes are imported without overwriting shared notes.",
843
922
  promptSnippet: "save and recall durable state that survives context resets",
844
923
  promptGuidelines: [
845
924
  "Use notes for durable state too large for a new_context handoff",
@@ -874,10 +953,10 @@ export default function (pi: ExtensionAPI) {
874
953
 
875
954
  switch (params.op) {
876
955
  case "list": {
877
- if (!existsSync(dir)) return textResult("(no notes yet)");
956
+ if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-list", count: 0 });
878
957
  const files: string[] = [];
879
958
  walk(dir, files);
880
- return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)");
959
+ return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)", [], { kind: "notes-list", count: files.length });
881
960
  }
882
961
  case "read": {
883
962
  const relative = requireValue(params.path, "path", params.op);
@@ -888,10 +967,10 @@ export default function (pi: ExtensionAPI) {
888
967
  if (offset && offset >= text.length) {
889
968
  throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
890
969
  }
891
- const end = Math.min(text.length, offset + requirePage(nativeContext(ctx), offset, 0, activeToolTokens()));
970
+ const end = Math.min(text.length, offset + pageSize(nativeContext(ctx), offset, 0));
892
971
  const more =
893
972
  end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
894
- return textResult(`${text.slice(offset, end)}${more}`);
973
+ return pageResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
895
974
  }
896
975
  case "write": {
897
976
  const relative = requireValue(params.path, "path", params.op);
@@ -899,7 +978,7 @@ export default function (pi: ExtensionAPI) {
899
978
  const path = safeJoin(relative);
900
979
  mkdirSync(dirname(path), { recursive: true });
901
980
  writeFileSync(path, params.content);
902
- return textResult(`Wrote .pi/notes/${relative}`);
981
+ return textResult(`Wrote ${path}`, [], { kind: "note-write" });
903
982
  }
904
983
  case "append": {
905
984
  const relative = requireValue(params.path, "path", params.op);
@@ -912,11 +991,11 @@ export default function (pi: ExtensionAPI) {
912
991
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
913
992
  const separator = existing && !existing.endsWith("\n") ? "\n" : "";
914
993
  appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
915
- return textResult(`Appended to .pi/notes/${relative}`);
994
+ return textResult(`Appended to ${path}`, [], { kind: "note-append" });
916
995
  }
917
996
  case "search": {
918
997
  const query = requireValue(params.query, "query", params.op).toLowerCase();
919
- if (!existsSync(dir)) return textResult("(no notes yet)");
998
+ if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-search", count: 0 });
920
999
  const files: string[] = [];
921
1000
  walk(dir, files);
922
1001
  const hits: string[] = [];
@@ -930,7 +1009,7 @@ export default function (pi: ExtensionAPI) {
930
1009
  }
931
1010
  }
932
1011
  }
933
- return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}".`);
1012
+ return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}".`, [], { kind: "notes-search", count: hits.length });
934
1013
  }
935
1014
  }
936
1015
  },
@@ -939,6 +1018,7 @@ export default function (pi: ExtensionAPI) {
939
1018
  pi.registerTool({
940
1019
  name: "history",
941
1020
  label: "History",
1021
+ ...toolCards("history"),
942
1022
  description:
943
1023
  "Search or read normalized session entries, including earlier native context windows. Search prioritizes original content before recovery notes, handoffs, and history lookups; all remain searchable. Current branch by default; all=true searches every project session file. Within each group: newest-modified sessions first, newest entries per session. Reads return stored images and page long text with the next offset.",
944
1024
  promptSnippet: "recover earlier conversation that left the active context window",
@@ -957,13 +1037,13 @@ export default function (pi: ExtensionAPI) {
957
1037
  if (params.op === "search") {
958
1038
  const query = requireValue(params.query, "query", params.op).toLowerCase();
959
1039
  const limit = params.limit ?? 10;
960
- const hits: string[][] = [[], []];
1040
+ const hits: HistoryHit[][] = [[], []];
961
1041
  // Forks copy ancestor entries under the same ids into new session files; report each id once.
962
1042
  const seen = new Set<string>();
963
1043
  const addHit = (hit: HistoryHit | undefined) => {
964
1044
  if (!hit || seen.has(hit.id) || hits[hit.priority].length >= limit) return;
965
1045
  seen.add(hit.id);
966
- hits[hit.priority].push(hit.text);
1046
+ hits[hit.priority].push(hit);
967
1047
  };
968
1048
 
969
1049
  if (params.all) {
@@ -989,7 +1069,10 @@ export default function (pi: ExtensionAPI) {
989
1069
  }
990
1070
  }
991
1071
  const results = hits.flat().slice(0, limit);
992
- return textResult(results.length ? results.join("\n") : `No history matches "${params.query}".`);
1072
+ return textResult(results.length ? results.map((hit) => hit.text).join("\n") : `No history matches "${params.query}".`, [], {
1073
+ kind: "history-search",
1074
+ entries: results.map((hit) => ({ headerLength: hit.headerLength, length: hit.text.length })),
1075
+ });
993
1076
  }
994
1077
 
995
1078
  const id = requireValue(params.id, "id", params.op);
@@ -1000,13 +1083,15 @@ export default function (pi: ExtensionAPI) {
1000
1083
  }
1001
1084
  const end = Math.min(
1002
1085
  item.text.length,
1003
- offset + requirePage(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0, activeToolTokens()),
1086
+ offset + pageSize(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0),
1004
1087
  );
1005
1088
  const more = end < item.text.length ? `\nMore remains; call history read with id "${id}" and offset ${end}.` : "";
1089
+ const header = `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] `;
1006
1090
  // Stored images ride along with the first page only.
1007
- return textResult(
1008
- `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] ${item.text.slice(offset, end)}${more}`,
1091
+ return pageResult(
1092
+ `${header}${item.text.slice(offset, end)}${more}`,
1009
1093
  offset === 0 ? item.images : [],
1094
+ { kind: "history-read", headerLength: header.length, offset, end, total: item.text.length },
1010
1095
  );
1011
1096
  };
1012
1097
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. Native no-summary context windows for the fitchmultz/pi fork, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": ["pi-package", "pi-extension", "context", "posthorse", "rollover"],
@@ -9,7 +9,7 @@
9
9
  "url": "git+https://github.com/fitchmultz/pi-posthorse.git"
10
10
  },
11
11
  "license": "MIT",
12
- "files": ["index.ts"],
12
+ "files": ["index.ts", "ui.ts"],
13
13
  "engines": {
14
14
  "node": ">=22.19.0"
15
15
  },
@@ -19,23 +19,28 @@
19
19
  },
20
20
  "scripts": {
21
21
  "check": "tsc --noEmit",
22
- "test": "node --test test/posthorse.test.ts",
22
+ "test": "node --import ./test/pi-loader.ts --test test/*.test.ts",
23
23
  "prepublishOnly": "npm test && npm run check"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "@earendil-works/pi-coding-agent": "*",
27
+ "@earendil-works/pi-tui": "*",
27
28
  "typebox": "*"
28
29
  },
29
30
  "peerDependenciesMeta": {
30
31
  "@earendil-works/pi-coding-agent": {
31
32
  "optional": true
32
33
  },
34
+ "@earendil-works/pi-tui": {
35
+ "optional": true
36
+ },
33
37
  "typebox": {
34
38
  "optional": true
35
39
  }
36
40
  },
37
41
  "devDependencies": {
38
42
  "@earendil-works/pi-coding-agent": "0.85.0",
43
+ "@earendil-works/pi-tui": "0.85.0",
39
44
  "typebox": "1.3.7",
40
45
  "typescript": "^5.5.0"
41
46
  }
package/ui.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { keyText, type ExtensionAPI, type MessageRenderer, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Box, MouseRegion, Text, stripTerminalSequences, truncateToWidth, type Component } from "@earendil-works/pi-tui";
3
+
4
+ /** Display-only facts and offsets into content, never a second copy of a note or history page. */
5
+ export type PosthorseDisplay =
6
+ | { kind: "context"; usage?: { tokens: number | null; contextWindow: number; percent: number | null }; rollover?: "enabled" | "disabled" | "unsupported"; rolloverAt?: number }
7
+ | { kind: "notes-list" | "notes-search"; count: number }
8
+ | { kind: "note-read"; offset: number; end: number; total: number }
9
+ | { kind: "note-write" | "note-append" | "new-context" }
10
+ | { kind: "history-search"; entries: Array<{ headerLength: number; length: number }> }
11
+ | { kind: "history-read"; headerLength: number; offset: number; end: number; total: number };
12
+
13
+ type ToolName = "notes" | "history" | "get_context_remaining" | "new_context";
14
+ const titles: Record<ToolName, string> = { notes: "Notes", history: "History", get_context_remaining: "Context budget", new_context: "New context" };
15
+ const n = (value: number) => value.toLocaleString("en-US");
16
+
17
+ function clean(value: string): string {
18
+ return stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\ufff9-\ufffb]/g, "");
19
+ }
20
+ function argsOf(value: unknown): Record<string, unknown> {
21
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
22
+ }
23
+ function argument(value: unknown, name: string): string {
24
+ return typeof value === "string" ? clean(value) : value == null ? "…" : `[invalid ${name}]`;
25
+ }
26
+ function textOf(content: unknown): string {
27
+ if (typeof content === "string") return content;
28
+ return Array.isArray(content) ? content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n") : "";
29
+ }
30
+ function textBlock(text: string, theme: Theme, color: "toolOutput" | "muted" | "dim" | "error" | "warning" = "toolOutput"): Text {
31
+ return new Text(text ? theme.fg(color, clean(text)) : "", 0, 0);
32
+ }
33
+ function hint(theme: Theme, width: number): string {
34
+ const key = keyText("app.tools.expand");
35
+ return truncateToWidth(theme.fg("dim", key ? `… ${key} to expand` : "… details hidden"), width);
36
+ }
37
+ function pageSummary(page: { offset: number; end: number; total: number }): string {
38
+ if (!page.total) return "Empty note";
39
+ if (!page.offset && page.end === page.total) return `${n(page.total)} chars · complete`;
40
+ return `Chars ${n(page.offset)}–${n(page.end)} of ${n(page.total)}${page.end < page.total ? `\nNext offset ${n(page.end)}` : " · final page"}`;
41
+ }
42
+
43
+ function historySections(raw: string, display: PosthorseDisplay | undefined): Array<{ body: string; header: string }> | undefined {
44
+ const spans = display?.kind === "history-search" ? display.entries : display?.kind === "history-read" ? [{ headerLength: display.headerLength, length: raw.length }] : undefined;
45
+ if (!Array.isArray(spans) || !spans.length) return undefined;
46
+ let start = 0;
47
+ const sections: Array<{ body: string; header: string }> = [];
48
+ for (const span of spans) {
49
+ if (!Number.isInteger(span.headerLength) || !Number.isInteger(span.length) || span.headerLength < 0 || span.headerLength > span.length || start + span.length > raw.length) return undefined;
50
+ sections.push({ header: raw.slice(start, start + span.headerLength).trimEnd(), body: raw.slice(start + span.headerLength, start + span.length) });
51
+ start += span.length + 1;
52
+ }
53
+ return start === raw.length + 1 ? sections : undefined;
54
+ }
55
+
56
+ export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "renderResult"> {
57
+ return {
58
+ renderCall(rawArgs, theme, context) {
59
+ const args = argsOf(rawArgs);
60
+ const action = name === "notes" || name === "history" ? ` · ${argument(args.op, "op")}` : name === "new_context" ? " · request" : "";
61
+ const status = context.isError ? " · error" : context.isPartial ? context.executionStarted ? " · running" : " · preparing" : "";
62
+ const target = args.op === "search" ? ` · “${argument(args.query, "query")}”` : name === "notes" && args.op !== "list" ? ` · ${argument(args.path, "path")}` : name === "history" && args.op === "read" ? ` · ${argument(args.id, "id")}` : "";
63
+ const label = `${context.isPartial ? "…" : context.expanded ? "▾" : "▸"} ${titles[name]}${action}${status}${target}`;
64
+ return {
65
+ invalidate() {},
66
+ render(width) {
67
+ const heading = theme.fg(context.isError ? "error" : "toolTitle", theme.bold(label));
68
+ if (!context.expanded) return [truncateToWidth(heading.replace(/\s+/g, " "), width)];
69
+ const details = [
70
+ name === "history" && args.op === "search" ? `Scope: ${args.all === true ? "all project sessions" : "current branch"}` : "",
71
+ args.offset !== undefined ? `Offset: ${typeof args.offset === "number" ? n(args.offset) : "[invalid offset]"}` : "",
72
+ args.limit !== undefined ? `Limit: ${typeof args.limit === "number" ? n(args.limit) : "[invalid limit]"}` : "",
73
+ ].filter(Boolean).join(" · ");
74
+ return [...new Text(heading, 0, 0).render(width), ...textBlock(details, theme, "muted").render(width)];
75
+ },
76
+ };
77
+ },
78
+ renderResult(result, { expanded, isPartial }, theme, context) {
79
+ const args = argsOf(context.args);
80
+ const raw = textOf(result.content);
81
+ const display = result.details as PosthorseDisplay | undefined;
82
+ const sections = !context.isError && !isPartial ? historySections(raw, display) : undefined;
83
+ let summary = "";
84
+ let body = raw;
85
+ let color: "toolOutput" | "warning" | "error" = context.isError ? "error" : "toolOutput";
86
+ if (!context.isError && !isPartial) {
87
+ switch (display?.kind) {
88
+ case "context": {
89
+ const usage = display.usage;
90
+ if (!usage || usage.tokens == null) { summary = "Context usage unknown"; break; }
91
+ const hard = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens to hard limit`;
92
+ const rollover = display.rollover === "enabled" && display.rolloverAt !== undefined ? `≈${n(Math.max(0, display.rolloverAt - usage.tokens))} tokens to rollover` : `Automatic rollover ${display.rollover ?? "unknown"}`;
93
+ summary = `${rollover}\n${hard}\n≈${n(usage.tokens)} / ${n(usage.contextWindow)} used${usage.percent == null ? "" : ` (≈${Math.round(usage.percent)}%)`}`;
94
+ if (display.rollover === "unsupported") color = "warning";
95
+ break;
96
+ }
97
+ case "notes-list": summary = display.count ? `${n(display.count)} note${display.count === 1 ? "" : "s"}` : "No notes yet"; break;
98
+ case "notes-search": summary = display.count ? `${n(display.count)} matches returned` : "No matches"; break;
99
+ case "note-read": summary = pageSummary(display); break;
100
+ case "note-write": summary = args.content === "" ? "Cleared note" : "Saved note"; break;
101
+ case "note-append": summary = "Appended to note"; break;
102
+ case "history-search": if (Array.isArray(display.entries) && (!display.entries.length || sections)) summary = `${display.entries.length ? `${n(display.entries.length)} matches` : "No matches"} · ${args.all === true ? "all sessions" : "current branch"}`; break;
103
+ case "history-read": summary = pageSummary(display); break;
104
+ case "new-context": summary = "Requested for after the whole tool batch succeeds."; break;
105
+ }
106
+ }
107
+ if (!body.trim()) body = context.isError ? "No error details returned." : isPartial ? "Running…" : name === "notes" && args.op === "read" ? "Empty note" : "No text returned.";
108
+ const submitted = name === "new_context" && typeof args.handoff === "string" ? `Handoff supplied:\n${args.handoff}` : name === "notes" && (args.op === "write" || args.op === "append") && typeof args.content === "string" ? `Submitted content:\n${args.content || "(empty)"}` : "";
109
+ const images = Array.isArray(result.content) ? result.content.filter((part) => part.type === "image") : [];
110
+ const attachment = images.length ? `${images.length} image${images.length === 1 ? "" : "s"} attached${context.showImages ? "" : " (display hidden)"}` : "";
111
+ const fullText = sections ? new Text(sections.map(({ body, header }) => `${theme.fg("toolOutput", clean(body))}\n${theme.fg("dim", clean(header))}`).join("\n\n"), 0, 0) : textBlock(body, theme, color);
112
+ const summaryText = textBlock(summary, theme, color);
113
+ const submittedText = textBlock(submitted, theme);
114
+ const attachmentText = textBlock(attachment, theme, "muted");
115
+ // Old results have no spans. Only the preview sheds the known history prefix.
116
+ const preview = sections ? sections.map(({ body }) => body).join("\n") : name === "history" && !context.isError ? body.replace(/^[^\n]*?\[window [^\]\n]+\] \[[^\]\n]+\](?: \[chars \d+-\d+ of \d+\])? /gm, "") : body;
117
+ const previewText = textBlock(preview, theme, color);
118
+ const searchPreviews = sections && display?.kind === "history-search" ? sections.slice(0, 3).map(({ body }) => theme.fg("toolOutput", clean(body).replace(/\s+/g, " "))) : undefined;
119
+ const summaryOnly = display?.kind === "context" || display?.kind === "new-context" || display?.kind === "note-write" || display?.kind === "note-append" || ((display?.kind === "notes-list" || display?.kind === "notes-search") && display.count === 0) || (display?.kind === "note-read" && display.total === 0) || (display?.kind === "history-search" && Array.isArray(display.entries) && display.entries.length === 0);
120
+ return {
121
+ invalidate() {},
122
+ render(width) {
123
+ if (expanded) {
124
+ return [...fullText.render(width), ...attachmentText.render(width), ...(submitted ? ["", ...submittedText.render(width)] : [])];
125
+ }
126
+ const summaryRows = summaryText.render(width).slice(0, 5);
127
+ const previewRows = summaryOnly && !context.isError && !isPartial ? [] : searchPreviews
128
+ ? searchPreviews.map((line) => truncateToWidth(line, width))
129
+ : previewText.render(width);
130
+ const shown = previewRows.slice(0, Math.max(0, Math.min(3, 5 - summaryRows.length - (attachment ? 1 : 0))));
131
+ const hidden = summaryOnly || previewRows.length > shown.length || Boolean(sections || submitted);
132
+ return [...summaryRows, ...shown, ...(attachment ? [truncateToWidth(theme.fg("muted", attachment), width)] : []), ...(hidden ? [hint(theme, width)] : [])];
133
+ },
134
+ };
135
+ },
136
+ };
137
+ }
138
+
139
+ export function registerPosthorseMessages(pi: ExtensionAPI): void {
140
+ const states = new WeakMap<object, { global: boolean; expanded: boolean }>();
141
+ const renderer: MessageRenderer = (message, options, theme) => {
142
+ let state = states.get(message);
143
+ if (!state || state.global !== options.expanded) {
144
+ state = { global: options.expanded, expanded: options.expanded };
145
+ states.set(message, state);
146
+ }
147
+ const view = state;
148
+ const window = message.customType === "context-window";
149
+ const raw = textOf(message.content);
150
+ const handoffLabel = "\n\nHandoff from the previous window:\n";
151
+ const handoffAt = raw.indexOf(handoffLabel);
152
+ const preview = window ? handoffAt < 0 ? "" : raw.slice(handoffAt + handoffLabel.length) : raw;
153
+ const fullText = textBlock(raw, theme);
154
+ const previewText = textBlock(preview, theme);
155
+ const historyText = textBlock("Earlier conversation remains in history.", theme, "muted");
156
+ const content: Component = {
157
+ invalidate() {},
158
+ render(width) {
159
+ const padding = Math.min(options.outputPad, Math.max(0, Math.floor((width - 1) / 2)));
160
+ const inner = Math.max(1, width - padding * 2);
161
+ const title = `${view.expanded ? "▾" : "▸"} ${window ? "Context window started" : "Checkpoint reminder"}`;
162
+ const heading = truncateToWidth(theme.fg(window ? "customMessageLabel" : "warning", theme.bold(title)), inner);
163
+ const body = view.expanded ? fullText.render(inner) : [
164
+ ...(window ? historyText.render(inner).slice(0, 2) : []),
165
+ ...previewText.render(inner).slice(0, window ? 2 : 3),
166
+ hint(theme, inner),
167
+ ];
168
+ const box = new Box(padding, 1, (line) => theme.bg("customMessageBg", line));
169
+ box.addChild({ render: () => [heading, ...body], invalidate() {} });
170
+ return box.render(width);
171
+ },
172
+ };
173
+ return new MouseRegion(content, (event) => {
174
+ if (event.type !== "click" || event.button !== "left") return undefined;
175
+ view.expanded = !view.expanded;
176
+ return { handled: true };
177
+ });
178
+ };
179
+ for (const type of ["context-window", "posthorse-reminder", "headroom-reminder"]) pi.registerMessageRenderer(type, renderer);
180
+ }