pi-posthorse 0.4.4 → 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.
- package/README.md +6 -2
- package/index.ts +39 -18
- package/package.json +8 -3
- package/ui.ts +180 -0
package/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -75,6 +75,10 @@ Only one automatic compaction or rollover policy extension should be enabled at
|
|
|
75
75
|
- `notes({ op, ... })`: `list`, `read` (paged; `offset` continues), `write` (empty content clears), `append` (one atomic newline-terminated record), `search` (excerpts centered on the match)
|
|
76
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
|
|
77
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
|
+
|
|
78
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.
|
|
79
83
|
|
|
80
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.
|
|
@@ -105,4 +109,4 @@ npm run check
|
|
|
105
109
|
PI_FORK=../pi scripts/integration.sh # loads the real extension into the fork's test harness (fork built)
|
|
106
110
|
```
|
|
107
111
|
|
|
108
|
-
`npm run check` type-checks
|
|
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:
|
|
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: `${
|
|
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
|
|
@@ -781,6 +785,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
781
785
|
pi.registerTool({
|
|
782
786
|
name: "new_context",
|
|
783
787
|
label: "New Context",
|
|
788
|
+
...toolCards("new_context"),
|
|
784
789
|
description:
|
|
785
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.",
|
|
786
791
|
promptSnippet: "start a fresh context window with an optional atomic handoff",
|
|
@@ -807,6 +812,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
807
812
|
return {
|
|
808
813
|
...textResult(
|
|
809
814
|
"Requested a fresh Pi context after this complete tool batch succeeds. Earlier conversation stays in session history.",
|
|
815
|
+
[],
|
|
816
|
+
{ kind: "new-context" },
|
|
810
817
|
),
|
|
811
818
|
newContext: { handoff: trimmed },
|
|
812
819
|
};
|
|
@@ -816,6 +823,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
816
823
|
pi.registerTool({
|
|
817
824
|
name: "get_context_remaining",
|
|
818
825
|
label: "Context Remaining",
|
|
826
|
+
...toolCards("get_context_remaining"),
|
|
819
827
|
description:
|
|
820
828
|
"Best available native estimate of the context budget: tokens until Pi's automatic rollover line and until the model's hard limit.",
|
|
821
829
|
promptSnippet: "check the remaining context budget only when needed",
|
|
@@ -823,14 +831,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
823
831
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
824
832
|
const native = nativeContext(ctx);
|
|
825
833
|
const usage = native.getContextUsage();
|
|
826
|
-
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" });
|
|
827
835
|
const n = (value: number) => value.toLocaleString("en-US");
|
|
828
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
|
+
};
|
|
829
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.`;
|
|
830
|
-
if (!budget?.enabled) return textResult(`Automatic rollover is disabled (Pi compaction.enabled=false). ${hard}
|
|
831
|
-
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);
|
|
832
845
|
return textResult(
|
|
833
846
|
`≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${hard}`,
|
|
847
|
+
[], display,
|
|
834
848
|
);
|
|
835
849
|
},
|
|
836
850
|
});
|
|
@@ -838,6 +852,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
838
852
|
pi.registerTool({
|
|
839
853
|
name: "notes",
|
|
840
854
|
label: "Notes",
|
|
855
|
+
...toolCards("notes"),
|
|
841
856
|
description:
|
|
842
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.",
|
|
843
858
|
promptSnippet: "save and recall durable state that survives context resets",
|
|
@@ -874,10 +889,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
874
889
|
|
|
875
890
|
switch (params.op) {
|
|
876
891
|
case "list": {
|
|
877
|
-
if (!existsSync(dir)) return textResult("(no notes yet)");
|
|
892
|
+
if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-list", count: 0 });
|
|
878
893
|
const files: string[] = [];
|
|
879
894
|
walk(dir, files);
|
|
880
|
-
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 });
|
|
881
896
|
}
|
|
882
897
|
case "read": {
|
|
883
898
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -891,7 +906,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
891
906
|
const end = Math.min(text.length, offset + requirePage(nativeContext(ctx), offset, 0, activeToolTokens()));
|
|
892
907
|
const more =
|
|
893
908
|
end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
|
|
894
|
-
return textResult(`${text.slice(offset, end)}${more}
|
|
909
|
+
return textResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
|
|
895
910
|
}
|
|
896
911
|
case "write": {
|
|
897
912
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -899,7 +914,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
899
914
|
const path = safeJoin(relative);
|
|
900
915
|
mkdirSync(dirname(path), { recursive: true });
|
|
901
916
|
writeFileSync(path, params.content);
|
|
902
|
-
return textResult(`Wrote .pi/notes/${relative}
|
|
917
|
+
return textResult(`Wrote .pi/notes/${relative}`, [], { kind: "note-write" });
|
|
903
918
|
}
|
|
904
919
|
case "append": {
|
|
905
920
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -912,11 +927,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
912
927
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
913
928
|
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
914
929
|
appendFileSync(path, `${separator}${content.replace(/\n?$/, "\n")}`);
|
|
915
|
-
return textResult(`Appended to .pi/notes/${relative}
|
|
930
|
+
return textResult(`Appended to .pi/notes/${relative}`, [], { kind: "note-append" });
|
|
916
931
|
}
|
|
917
932
|
case "search": {
|
|
918
933
|
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
919
|
-
if (!existsSync(dir)) return textResult("(no notes yet)");
|
|
934
|
+
if (!existsSync(dir)) return textResult("(no notes yet)", [], { kind: "notes-search", count: 0 });
|
|
920
935
|
const files: string[] = [];
|
|
921
936
|
walk(dir, files);
|
|
922
937
|
const hits: string[] = [];
|
|
@@ -930,7 +945,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
930
945
|
}
|
|
931
946
|
}
|
|
932
947
|
}
|
|
933
|
-
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 });
|
|
934
949
|
}
|
|
935
950
|
}
|
|
936
951
|
},
|
|
@@ -939,6 +954,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
939
954
|
pi.registerTool({
|
|
940
955
|
name: "history",
|
|
941
956
|
label: "History",
|
|
957
|
+
...toolCards("history"),
|
|
942
958
|
description:
|
|
943
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.",
|
|
944
960
|
promptSnippet: "recover earlier conversation that left the active context window",
|
|
@@ -957,13 +973,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
957
973
|
if (params.op === "search") {
|
|
958
974
|
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
959
975
|
const limit = params.limit ?? 10;
|
|
960
|
-
const hits:
|
|
976
|
+
const hits: HistoryHit[][] = [[], []];
|
|
961
977
|
// Forks copy ancestor entries under the same ids into new session files; report each id once.
|
|
962
978
|
const seen = new Set<string>();
|
|
963
979
|
const addHit = (hit: HistoryHit | undefined) => {
|
|
964
980
|
if (!hit || seen.has(hit.id) || hits[hit.priority].length >= limit) return;
|
|
965
981
|
seen.add(hit.id);
|
|
966
|
-
hits[hit.priority].push(hit
|
|
982
|
+
hits[hit.priority].push(hit);
|
|
967
983
|
};
|
|
968
984
|
|
|
969
985
|
if (params.all) {
|
|
@@ -989,7 +1005,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
989
1005
|
}
|
|
990
1006
|
}
|
|
991
1007
|
const results = hits.flat().slice(0, limit);
|
|
992
|
-
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
|
+
});
|
|
993
1012
|
}
|
|
994
1013
|
|
|
995
1014
|
const id = requireValue(params.id, "id", params.op);
|
|
@@ -1003,10 +1022,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1003
1022
|
offset + requirePage(nativeContext(ctx), offset, offset === 0 ? item.images.length : 0, activeToolTokens()),
|
|
1004
1023
|
);
|
|
1005
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}] `;
|
|
1006
1026
|
// Stored images ride along with the first page only.
|
|
1007
1027
|
return textResult(
|
|
1008
|
-
`${
|
|
1028
|
+
`${header}${item.text.slice(offset, end)}${more}`,
|
|
1009
1029
|
offset === 0 ? item.images : [],
|
|
1030
|
+
{ kind: "history-read", headerLength: header.length, offset, end, total: item.text.length },
|
|
1010
1031
|
);
|
|
1011
1032
|
};
|
|
1012
1033
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-posthorse",
|
|
3
|
-
"version": "0.4.
|
|
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 --
|
|
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
|
+
}
|