pi-posthorse 0.4.3 → 0.4.5

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 +9 -3
  2. package/index.ts +45 -22
  3. package/package.json +8 -3
  4. package/ui.ts +180 -0
package/README.md CHANGED
@@ -11,7 +11,7 @@ Pi owns the persisted boundary. Posthorse owns the policy: stable window guidanc
11
11
  ## Requirements
12
12
 
13
13
  - Node `>=22.19.0`.
14
- - The `fitchmultz/pi` fork. The current tested revision is `f9b06177e565f70cd243a785d088d1c491830dbd` (Pi `0.85.0`). Posthorse needs the fork's native `context_window` entries, its `session_before_auto_compact` hook, and `ctx.getCompactionSettings()`.
14
+ - The `fitchmultz/pi` fork. The CI baseline is `f9b06177e565f70cd243a785d088d1c491830dbd` (Pi `0.85.0`). Posthorse needs the fork's native `context_window` entries, its `session_before_auto_compact` hook, and `ctx.getCompactionSettings()`.
15
15
  - Official, unpatched Pi is unsupported. Posthorse reports a clear extension error at session start and cannot operate; Pi itself keeps running.
16
16
 
17
17
  ## Install
@@ -40,7 +40,7 @@ pi -e git:github.com/fitchmultz/pi-posthorse # try it for one run w
40
40
 
41
41
  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
42
 
43
- After installing or updating Posthorse, run `/reload` in existing Pi sessions or restart them to load the new version.
43
+ Restart Pi after installing or updating Posthorse to load the new extension code.
44
44
 
45
45
  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
46
 
@@ -54,6 +54,8 @@ Keep exactly one copy loaded. `pi list` shows every package source; if an older
54
54
  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
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.
56
56
 
57
+ 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
+
57
59
  Automatic recovery is an emergency input record, not proof of progress. The fresh model is told to restore notes and todo state, inspect history when needed, and verify live state before taking stateful or external action.
58
60
 
59
61
  ## Settings
@@ -73,6 +75,10 @@ Only one automatic compaction or rollover policy extension should be enabled at
73
75
  - `notes({ op, ... })`: `list`, `read` (paged; `offset` continues), `write` (empty content clears), `append` (one atomic newline-terminated record), `search` (excerpts centered on the match)
74
76
  - `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
75
77
 
78
+ 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.
79
+
80
+ 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.
81
+
76
82
  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.
77
83
 
78
84
  `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.
@@ -103,4 +109,4 @@ npm run check
103
109
  PI_FORK=../pi scripts/integration.sh # loads the real extension into the fork's test harness (fork built)
104
110
  ```
105
111
 
106
- `npm run check` type-checks `index.ts` and the unit tests; the integration test runs inside the fork's harness. 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.
112
+ `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
@@ -20,6 +20,7 @@ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "no
20
20
  import { createInterface } from "node:readline";
21
21
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
22
  import { Type } from "typebox";
23
+ import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
23
24
 
24
25
  const REMINDER_BUFFER_TOKENS = 32_000;
25
26
  /** Absolute ceiling for handoffs and read pages; pages shrink to the live remaining budget. */
@@ -86,7 +87,7 @@ type EntryLike = {
86
87
  };
87
88
 
88
89
  type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
89
- type HistoryHit = { id: string; text: string; priority: 0 | 1 };
90
+ type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
90
91
  type RecoveryRecord = {
91
92
  id: string;
92
93
  timestamp: string;
@@ -159,8 +160,8 @@ function textOf(message: MessageLike): string {
159
160
  .join("\n");
160
161
  }
161
162
 
162
- function textResult(text: string, images: ImageLike[] = []) {
163
- return { content: [{ type: "text" as const, text }, ...images], details: undefined };
163
+ function textResult(text: string, images: ImageLike[] = [], display?: PosthorseDisplay) {
164
+ return { content: [{ type: "text" as const, text }, ...images], details: display };
164
165
  }
165
166
 
166
167
  /**
@@ -263,10 +264,12 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
263
264
  matchIndex = text.toLowerCase().indexOf(query);
264
265
  }
265
266
  }
267
+ const header = `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${entry.id}] `;
266
268
  return {
267
269
  id: entry.id!,
268
270
  priority,
269
- text: `${source ? `${source} ` : ""}${entry.timestamp ?? ""} [window ${item.windowId}] [${entry.id}] ${excerptAround(text, matchIndex, 100, 400)}`,
271
+ text: `${header}${excerptAround(text, matchIndex, 100, 400)}`,
272
+ headerLength: header.length,
270
273
  };
271
274
  }
272
275
 
@@ -678,6 +681,7 @@ Automatic handoffs are emergency recovery records, not proof of current state. R
678
681
  }
679
682
 
680
683
  export default function (pi: ExtensionAPI) {
684
+ registerPosthorseMessages(pi);
681
685
  const activeToolTokens = () => {
682
686
  const active = new Set(pi.getActiveTools());
683
687
  return pi
@@ -719,6 +723,10 @@ export default function (pi: ExtensionAPI) {
719
723
  if (!usage || usage.tokens == null || usage.contextWindow <= 0) return;
720
724
  const budget = budgetFor(native, usage.contextWindow);
721
725
  if (!budget || !budget.enabled || !budget.supported) return;
726
+ if (usage.tokens >= budget.rolloverAt) return;
727
+ const reminderBuffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
728
+ const remindAt = budget.rolloverAt - reminderBuffer;
729
+ if (usage.tokens < remindAt) return;
722
730
 
723
731
  const branch = ctx.sessionManager.getBranch() as EntryLike[];
724
732
  const fingerprint: ReminderFingerprint = {
@@ -726,10 +734,7 @@ export default function (pi: ExtensionAPI) {
726
734
  contextWindow: budget.contextWindow,
727
735
  reserveTokens: budget.reserveTokens,
728
736
  };
729
- if (usage.tokens >= budget.rolloverAt) return;
730
- const reminderBuffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
731
- const remindAt = budget.rolloverAt - reminderBuffer;
732
- if (usage.tokens < remindAt || hasReminder(branch, fingerprint)) return;
737
+ if (hasReminder(branch, fingerprint)) return;
733
738
  pi.sendMessage(
734
739
  {
735
740
  customType: REMINDER_TYPE,
@@ -749,6 +754,7 @@ export default function (pi: ExtensionAPI) {
749
754
  const windowId = marker?.role === "custom" ? (marker.details as { windowId?: unknown } | undefined)?.windowId : "initial";
750
755
  if (typeof windowId !== "string") return;
751
756
  const native = nativeContext(ctx);
757
+ if (!event.messages.some((message) => message.role === "custom" && isReminderType(message.customType))) return;
752
758
  const budget = budgetFor(native);
753
759
  const branch = ctx.sessionManager.getBranch() as EntryLike[];
754
760
  const fingerprint: ReminderFingerprint = {
@@ -779,6 +785,7 @@ export default function (pi: ExtensionAPI) {
779
785
  pi.registerTool({
780
786
  name: "new_context",
781
787
  label: "New Context",
788
+ ...toolCards("new_context"),
782
789
  description:
783
790
  "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.",
784
791
  promptSnippet: "start a fresh context window with an optional atomic handoff",
@@ -805,6 +812,8 @@ export default function (pi: ExtensionAPI) {
805
812
  return {
806
813
  ...textResult(
807
814
  "Requested a fresh Pi context after this complete tool batch succeeds. Earlier conversation stays in session history.",
815
+ [],
816
+ { kind: "new-context" },
808
817
  ),
809
818
  newContext: { handoff: trimmed },
810
819
  };
@@ -814,6 +823,7 @@ export default function (pi: ExtensionAPI) {
814
823
  pi.registerTool({
815
824
  name: "get_context_remaining",
816
825
  label: "Context Remaining",
826
+ ...toolCards("get_context_remaining"),
817
827
  description:
818
828
  "Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the model's hard limit.",
819
829
  promptSnippet: "check the remaining context budget only when needed",
@@ -821,14 +831,20 @@ export default function (pi: ExtensionAPI) {
821
831
  async execute(_id, _params, _signal, _onUpdate, ctx) {
822
832
  const native = nativeContext(ctx);
823
833
  const usage = native.getContextUsage();
824
- if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.");
834
+ if (!usage || usage.tokens == null) return textResult("Context usage is not known until the next model response.", [], { kind: "context" });
825
835
  const n = (value: number) => value.toLocaleString("en-US");
826
836
  const budget = budgetFor(native, usage.contextWindow);
837
+ const display: PosthorseDisplay = {
838
+ kind: "context", usage,
839
+ rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
840
+ rolloverAt: budget?.rolloverAt,
841
+ };
827
842
  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.`;
828
- if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}`);
829
- if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}`);
843
+ if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}`, [], display);
844
+ if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${hard}`, [], display);
830
845
  return textResult(
831
846
  `≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${hard}`,
847
+ [], display,
832
848
  );
833
849
  },
834
850
  });
@@ -836,6 +852,7 @@ export default function (pi: ExtensionAPI) {
836
852
  pi.registerTool({
837
853
  name: "notes",
838
854
  label: "Notes",
855
+ ...toolCards("notes"),
839
856
  description:
840
857
  "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.",
841
858
  promptSnippet: "save and recall durable state that survives context resets",
@@ -872,10 +889,10 @@ export default function (pi: ExtensionAPI) {
872
889
 
873
890
  switch (params.op) {
874
891
  case "list": {
875
- if (!existsSync(dir)) return textResult("(no notes yet)");
892
+ if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-list", count: 0 });
876
893
  const files: string[] = [];
877
894
  walk(dir, files);
878
- return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)");
895
+ return textResult(files.length ? files.map((file) => file.slice(dir.length + 1)).join("\n") : "(no notes yet)", [], { kind: "notes-list", count: files.length });
879
896
  }
880
897
  case "read": {
881
898
  const relative = requireValue(params.path, "path", params.op);
@@ -889,7 +906,7 @@ export default function (pi: ExtensionAPI) {
889
906
  const end = Math.min(text.length, offset + requirePage(nativeContext(ctx), offset, 0, activeToolTokens()));
890
907
  const more =
891
908
  end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
892
- return textResult(`${text.slice(offset, end)}${more}`);
909
+ return textResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
893
910
  }
894
911
  case "write": {
895
912
  const relative = requireValue(params.path, "path", params.op);
@@ -897,7 +914,7 @@ export default function (pi: ExtensionAPI) {
897
914
  const path = safeJoin(relative);
898
915
  mkdirSync(dirname(path), { recursive: true });
899
916
  writeFileSync(path, params.content);
900
- return textResult(`Wrote .pi/notes/${relative}`);
917
+ return textResult(`Wrote .pi/notes/${relative}`, [], { kind: "note-write" });
901
918
  }
902
919
  case "append": {
903
920
  const relative = requireValue(params.path, "path", params.op);
@@ -910,11 +927,11 @@ export default function (pi: ExtensionAPI) {
910
927
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
911
928
  const separator = existing && !existing.endsWith("\n") ? "\n" : "";
912
929
  appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
913
- return textResult(`Appended to .pi/notes/${relative}`);
930
+ return textResult(`Appended to .pi/notes/${relative}`, [], { kind: "note-append" });
914
931
  }
915
932
  case "search": {
916
933
  const query = requireValue(params.query, "query", params.op).toLowerCase();
917
- if (!existsSync(dir)) return textResult("(no notes yet)");
934
+ if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-search", count: 0 });
918
935
  const files: string[] = [];
919
936
  walk(dir, files);
920
937
  const hits: string[] = [];
@@ -928,7 +945,7 @@ export default function (pi: ExtensionAPI) {
928
945
  }
929
946
  }
930
947
  }
931
- return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}".`);
948
+ return textResult(hits.length ? hits.join("\n") : `No notes match "${params.query}".`, [], { kind: "notes-search", count: hits.length });
932
949
  }
933
950
  }
934
951
  },
@@ -937,6 +954,7 @@ export default function (pi: ExtensionAPI) {
937
954
  pi.registerTool({
938
955
  name: "history",
939
956
  label: "History",
957
+ ...toolCards("history"),
940
958
  description:
941
959
  "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.",
942
960
  promptSnippet: "recover earlier conversation that left the active context window",
@@ -955,13 +973,13 @@ export default function (pi: ExtensionAPI) {
955
973
  if (params.op === "search") {
956
974
  const query = requireValue(params.query, "query", params.op).toLowerCase();
957
975
  const limit = params.limit ?? 10;
958
- const hits: string[][] = [[], []];
976
+ const hits: HistoryHit[][] = [[], []];
959
977
  // Forks copy ancestor entries under the same ids into new session files; report each id once.
960
978
  const seen = new Set<string>();
961
979
  const addHit = (hit: HistoryHit | undefined) => {
962
980
  if (!hit || seen.has(hit.id) || hits[hit.priority].length >= limit) return;
963
981
  seen.add(hit.id);
964
- hits[hit.priority].push(hit.text);
982
+ hits[hit.priority].push(hit);
965
983
  };
966
984
 
967
985
  if (params.all) {
@@ -987,7 +1005,10 @@ export default function (pi: ExtensionAPI) {
987
1005
  }
988
1006
  }
989
1007
  const results = hits.flat().slice(0, limit);
990
- return textResult(results.length ? results.join("\n") : `No history matches "${params.query}".`);
1008
+ return textResult(results.length ? results.map((hit) => hit.text).join("\n") : `No history matches "${params.query}".`, [], {
1009
+ kind: "history-search",
1010
+ entries: results.map((hit) => ({ headerLength: hit.headerLength, length: hit.text.length })),
1011
+ });
991
1012
  }
992
1013
 
993
1014
  const id = requireValue(params.id, "id", params.op);
@@ -1001,10 +1022,12 @@ export default function (pi: ExtensionAPI) {
1001
1022
  offset + requirePage(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0, activeToolTokens()),
1002
1023
  );
1003
1024
  const more = end < item.text.length ? `\nMore remains; call history read with id "${id}" and offset ${end}.` : "";
1025
+ const header = `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] `;
1004
1026
  // Stored images ride along with the first page only.
1005
1027
  return textResult(
1006
- `${source ? `${source} ` : ""}${item.entry.timestamp ?? ""} [window ${item.windowId}] [${id}] [chars ${offset}-${end} of ${item.text.length}] ${item.text.slice(offset, end)}${more}`,
1028
+ `${header}${item.text.slice(offset, end)}${more}`,
1007
1029
  offset === 0 ? item.images : [],
1030
+ { kind: "history-read", headerLength: header.length, offset, end, total: item.text.length },
1008
1031
  );
1009
1032
  };
1010
1033
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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
+ }