pi-posthorse 0.8.0 → 0.8.2
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 +20 -6
- package/index.ts +42 -1555
- package/package.json +33 -7
- package/src/budget.ts +168 -0
- package/src/context-tools.ts +113 -0
- package/src/contracts.ts +124 -0
- package/src/files.ts +182 -0
- package/src/history-entries.ts +251 -0
- package/src/history-pages.ts +173 -0
- package/src/history-search.ts +308 -0
- package/src/history.ts +146 -0
- package/src/message-text.ts +139 -0
- package/src/notes.ts +314 -0
- package/src/paging.ts +156 -0
- package/src/recovery-results.ts +111 -0
- package/src/recovery.ts +377 -0
- package/src/reminders.ts +116 -0
- package/src/rollover.ts +330 -0
- package/src/session-files.ts +264 -0
- package/src/ui/cards.ts +355 -0
- package/src/ui/display.ts +312 -0
- package/src/ui/text.ts +53 -0
- package/ui.ts +44 -163
package/src/ui/text.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { keyText, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text, stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export type OutputColor = "toolOutput" | "muted" | "dim" | "error" | "warning";
|
|
5
|
+
|
|
6
|
+
export function clean(value: string): string {
|
|
7
|
+
// Tool previews must not execute terminal controls or Unicode interlinear annotations.
|
|
8
|
+
// oxlint-disable-next-line no-control-regex
|
|
9
|
+
return stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\ufff9-\ufffb]/g, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function argsOf(value: unknown): Readonly<Record<string, unknown>> {
|
|
17
|
+
return isRecord(value) ? value : {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function argument(value: unknown, name: string): string {
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
return clean(value);
|
|
23
|
+
}
|
|
24
|
+
return value === null || value === undefined ? "…" : `[invalid ${name}]`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function textOf(content: unknown): string {
|
|
28
|
+
if (typeof content === "string") {
|
|
29
|
+
return content;
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(content)) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
return content
|
|
35
|
+
.flatMap((part: unknown) => {
|
|
36
|
+
return isRecord(part) && part.type === "text" && typeof part.text === "string"
|
|
37
|
+
? [part.text]
|
|
38
|
+
: [];
|
|
39
|
+
})
|
|
40
|
+
.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function textBlock(text: string, theme: Theme, color: OutputColor = "toolOutput"): Text {
|
|
44
|
+
return new Text(text.length > 0 ? theme.fg(color, clean(text)) : "", 0, 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hint(theme: Theme, width: number): string {
|
|
48
|
+
const key = keyText("app.tools.expand");
|
|
49
|
+
return truncateToWidth(
|
|
50
|
+
theme.fg("dim", key.length > 0 ? `… ${key} to expand` : "… details hidden"),
|
|
51
|
+
width,
|
|
52
|
+
);
|
|
53
|
+
}
|
package/ui.ts
CHANGED
|
@@ -1,161 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Box, MouseRegion,
|
|
1
|
+
import type { ExtensionAPI, MessageRenderer } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, MouseRegion, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { clean, hint, textBlock, textOf } from "./src/ui/text.ts";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
export
|
|
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; headerLength?: number; page?: { offset: number; end: number; total: number } }
|
|
8
|
-
| { kind: "note-read"; headerLength?: number; offset: number; end: number; total: number }
|
|
9
|
-
| { kind: "note-write" | "note-append" | "new-context" }
|
|
10
|
-
| { kind: "history-search"; entries: Array<{ headerLength: number; length: number }>; footerLength?: number; more?: boolean; skipped?: number }
|
|
11
|
-
| { kind: "history-read"; headerLength: number; offset: number; end: number; total: number; imageOffset?: number; imageEnd?: number; imageTotal?: number };
|
|
5
|
+
export type { PosthorseDisplay } from "./src/ui/display.ts";
|
|
6
|
+
export { toolCards } from "./src/ui/cards.ts";
|
|
12
7
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
const footerLength = display?.kind === "history-search" ? display.footerLength ?? 0 : 0;
|
|
47
|
-
if (!Number.isInteger(footerLength) || footerLength < 0 || footerLength > raw.length) return undefined;
|
|
48
|
-
const bodyLength = raw.length - footerLength;
|
|
49
|
-
let start = 0;
|
|
50
|
-
const sections: Array<{ body: string; header: string }> = [];
|
|
51
|
-
for (const span of spans) {
|
|
52
|
-
if (!Number.isInteger(span.headerLength) || !Number.isInteger(span.length) || span.headerLength < 0 || span.headerLength > span.length || start + span.length > bodyLength) return undefined;
|
|
53
|
-
sections.push({ header: raw.slice(start, start + span.headerLength).trimEnd(), body: raw.slice(start + span.headerLength, start + span.length) });
|
|
54
|
-
start += span.length + 1;
|
|
55
|
-
}
|
|
56
|
-
return start === bodyLength + 1 ? sections : undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function toolCards(name: ToolName): Pick<ToolDefinition, "renderCall" | "renderResult"> {
|
|
60
|
-
return {
|
|
61
|
-
renderCall(rawArgs, theme, context) {
|
|
62
|
-
const args = argsOf(rawArgs);
|
|
63
|
-
const action = name === "notes" || name === "history" ? ` · ${argument(args.op, "op")}` : name === "new_context" ? " · request" : "";
|
|
64
|
-
const status = context.isError ? " · error" : context.isPartial ? context.executionStarted ? " · running" : " · preparing" : "";
|
|
65
|
-
const target = args.op === "search" ? ` · “${argument(args.query, "query")}”` : name === "notes" && (args.op !== "list" || args.path !== undefined) ? ` · ${argument(args.path, "path")}` : name === "history" && args.op === "read" ? ` · ${argument(args.id, "id")}` : "";
|
|
66
|
-
const label = `${context.isPartial ? "…" : context.expanded ? "▾" : "▸"} ${titles[name]}${action}${status}${target}`;
|
|
67
|
-
return {
|
|
68
|
-
invalidate() {},
|
|
69
|
-
render(width) {
|
|
70
|
-
const heading = theme.fg(context.isError ? "error" : "toolTitle", theme.bold(label));
|
|
71
|
-
if (!context.expanded) return [truncateToWidth(heading.replace(/\s+/g, " "), width)];
|
|
72
|
-
const details = [
|
|
73
|
-
name === "history" && args.op === "search" ? `Scope: ${args.all === true ? "all project sessions" : "current branch"}` : "",
|
|
74
|
-
args.offset !== undefined ? `Offset: ${typeof args.offset === "number" ? n(args.offset) : "[invalid offset]"}` : "",
|
|
75
|
-
args.imageOffset !== undefined ? `Image offset: ${typeof args.imageOffset === "number" ? n(args.imageOffset) : "[invalid image offset]"}` : "",
|
|
76
|
-
args.limit !== undefined ? `Limit: ${typeof args.limit === "number" ? n(args.limit) : "[invalid limit]"}` : "",
|
|
77
|
-
].filter(Boolean).join(" · ");
|
|
78
|
-
return [...new Text(heading, 0, 0).render(width), ...textBlock(details, theme, "muted").render(width)];
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
},
|
|
82
|
-
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
83
|
-
const args = argsOf(context.args);
|
|
84
|
-
const raw = textOf(result.content);
|
|
85
|
-
const display = result.details as PosthorseDisplay | undefined;
|
|
86
|
-
const sections = !context.isError && !isPartial ? historySections(raw, display) : undefined;
|
|
87
|
-
let summary = "";
|
|
88
|
-
let body = raw;
|
|
89
|
-
let color: "toolOutput" | "warning" | "error" = context.isError ? "error" : "toolOutput";
|
|
90
|
-
if (!context.isError && !isPartial) {
|
|
91
|
-
switch (display?.kind) {
|
|
92
|
-
case "context": {
|
|
93
|
-
const usage = display.usage;
|
|
94
|
-
if (!usage || usage.tokens == null) { summary = "Context usage unknown"; break; }
|
|
95
|
-
const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens to configured context limit`;
|
|
96
|
-
const rollover = display.rollover === "enabled" && display.rolloverAt !== undefined ? `≈${n(Math.max(0, display.rolloverAt - usage.tokens))} tokens to rollover` : `Automatic rollover ${display.rollover ?? "unknown"}`;
|
|
97
|
-
summary = `${rollover}\n${configured}\n≈${n(usage.tokens)} / ${n(usage.contextWindow)} used${usage.percent == null ? "" : ` (≈${Math.round(usage.percent)}%)`}`;
|
|
98
|
-
if (display.rollover === "unsupported") color = "warning";
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
101
|
-
case "notes-list": summary = display.count ? `${n(display.count)} note${display.count === 1 ? "" : "s"} returned` : "No notes yet"; break;
|
|
102
|
-
case "notes-search": summary = display.count ? `${n(display.count)} matches returned` : "No matches"; break;
|
|
103
|
-
case "note-read": summary = pageSummary(display); break;
|
|
104
|
-
case "note-write": summary = args.content === "" ? "Cleared note" : "Saved note"; break;
|
|
105
|
-
case "note-append": summary = "Appended to note"; break;
|
|
106
|
-
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"}${display.skipped ? ` · ${n(display.skipped)} in context skipped` : ""}${(display.more ?? display.footerLength) ? "\nMore results; continue with cursor" : ""}`; break;
|
|
107
|
-
case "history-read":
|
|
108
|
-
summary = pageSummary(display);
|
|
109
|
-
if (display.imageTotal && display.imageEnd !== undefined) {
|
|
110
|
-
summary = summary.split("\n")[0];
|
|
111
|
-
summary += `\nImages ${n(display.imageOffset ?? 0)}–${n(display.imageEnd)} of ${n(display.imageTotal)}`;
|
|
112
|
-
if (display.end < display.total || display.imageEnd < display.imageTotal) {
|
|
113
|
-
summary = `Next offset ${n(display.end)}\nimageOffset ${n(display.imageEnd)}\n${summary}`;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
break;
|
|
117
|
-
case "new-context": summary = "Requested after foreground tools succeed.\nBackground work continues."; break;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
if (!context.isError && !isPartial && (display?.kind === "notes-list" || display?.kind === "notes-search") && display.page && (display.page.offset || display.page.end < display.page.total)) summary += `\n${pageSummary(display.page)}`;
|
|
121
|
-
if (!body.trim()) body = context.isError ? "No error details returned." : isPartial ? "Running…" : name === "notes" && args.op === "read" ? "Empty note" : "No text returned.";
|
|
122
|
-
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)"}` : "";
|
|
123
|
-
const images = Array.isArray(result.content) ? result.content.filter((part) => part.type === "image") : [];
|
|
124
|
-
const attachment = images.length ? `${images.length} image${images.length === 1 ? "" : "s"} attached${context.showImages ? "" : " (display hidden)"}` : "";
|
|
125
|
-
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);
|
|
126
|
-
const summaryText = textBlock(summary, theme, color);
|
|
127
|
-
const submittedText = textBlock(submitted, theme);
|
|
128
|
-
const attachmentText = textBlock(attachment, theme, "muted");
|
|
129
|
-
// Old results have no spans. Only the preview sheds the known history prefix.
|
|
130
|
-
const noteHeaderLength = !context.isError && (display?.kind === "note-read" || display?.kind === "notes-list") ? display.headerLength ?? 0 : 0;
|
|
131
|
-
const preview = sections ? sections.map(({ body }) => body).join("\n") : noteHeaderLength ? body.slice(noteHeaderLength) : name === "history" && !context.isError ? body.replace(/^[^\n]*?\[window [^\]\n]+\] \[[^\]\n]+\](?: \[chars \d+-\d+ of \d+\])? /gm, "") : body;
|
|
132
|
-
const previewText = textBlock(preview, theme, color);
|
|
133
|
-
const searchPreviews = sections && display?.kind === "history-search" ? sections.slice(0, 3).map(({ body }) => theme.fg("toolOutput", clean(body).replace(/\s+/g, " "))) : undefined;
|
|
134
|
-
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);
|
|
135
|
-
return {
|
|
136
|
-
invalidate() {},
|
|
137
|
-
render(width) {
|
|
138
|
-
if (expanded) {
|
|
139
|
-
return [...fullText.render(width), ...attachmentText.render(width), ...(sections && display?.kind === "history-search" && display.footerLength ? textBlock(raw.slice(-display.footerLength), theme, "muted").render(width) : []), ...(submitted ? ["", ...submittedText.render(width)] : [])];
|
|
140
|
-
}
|
|
141
|
-
const summaryRows = summaryText.render(width).slice(0, 5);
|
|
142
|
-
const previewRows = summaryOnly && !context.isError && !isPartial ? [] : searchPreviews
|
|
143
|
-
? searchPreviews.map((line) => truncateToWidth(line, width))
|
|
144
|
-
: previewText.render(width);
|
|
145
|
-
const shown = previewRows.slice(0, Math.max(0, Math.min(3, 5 - summaryRows.length - (attachment ? 1 : 0))));
|
|
146
|
-
const hidden = summaryOnly || previewRows.length > shown.length || Boolean(sections || submitted || noteHeaderLength);
|
|
147
|
-
return [...summaryRows, ...shown, ...(attachment ? [truncateToWidth(theme.fg("muted", attachment), width)] : []), ...(hidden ? [hint(theme, width)] : [])];
|
|
148
|
-
},
|
|
149
|
-
};
|
|
150
|
-
},
|
|
151
|
-
};
|
|
8
|
+
interface ExpansionState {
|
|
9
|
+
readonly global: boolean;
|
|
10
|
+
expanded: boolean;
|
|
152
11
|
}
|
|
153
12
|
|
|
13
|
+
/** Each saved message owns its click state; global expansion resets that local choice. */
|
|
154
14
|
export function registerPosthorseMessages(pi: ExtensionAPI): void {
|
|
155
|
-
const states = new WeakMap<object,
|
|
15
|
+
const states = new WeakMap<object, ExpansionState>();
|
|
156
16
|
const renderer: MessageRenderer = (message, options, theme) => {
|
|
157
17
|
let state = states.get(message);
|
|
158
|
-
if (
|
|
18
|
+
if (state === undefined || state.global !== options.expanded) {
|
|
159
19
|
state = { global: options.expanded, expanded: options.expanded };
|
|
160
20
|
states.set(message, state);
|
|
161
21
|
}
|
|
@@ -164,32 +24,53 @@ export function registerPosthorseMessages(pi: ExtensionAPI): void {
|
|
|
164
24
|
const raw = textOf(message.content);
|
|
165
25
|
const handoffLabel = "\n\nHandoff from the previous window:\n";
|
|
166
26
|
const handoffAt = raw.indexOf(handoffLabel);
|
|
167
|
-
const preview = window
|
|
27
|
+
const preview = window
|
|
28
|
+
? raw.slice(handoffAt < 0 ? raw.length : handoffAt + handoffLabel.length)
|
|
29
|
+
: raw;
|
|
168
30
|
const fullText = textBlock(raw, theme);
|
|
169
31
|
const previewText = textBlock(preview, theme);
|
|
170
32
|
const historyText = textBlock("Earlier conversation remains in history.", theme, "muted");
|
|
171
33
|
const content: Component = {
|
|
172
|
-
invalidate() {
|
|
34
|
+
invalidate() {
|
|
35
|
+
// Message layout is recomputed for the supplied width without cached rows.
|
|
36
|
+
},
|
|
173
37
|
render(width) {
|
|
174
|
-
const padding = Math.min(
|
|
38
|
+
const padding = Math.min(
|
|
39
|
+
options.outputPad,
|
|
40
|
+
Math.max(0, Math.floor((width - 1) / 2)),
|
|
41
|
+
);
|
|
175
42
|
const inner = Math.max(1, width - padding * 2);
|
|
176
43
|
const title = `${view.expanded ? "▾" : "▸"} ${window ? "Context window started" : "Checkpoint reminder"}`;
|
|
177
|
-
const heading = truncateToWidth(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
44
|
+
const heading = truncateToWidth(
|
|
45
|
+
theme.fg(window ? "customMessageLabel" : "warning", theme.bold(clean(title))),
|
|
46
|
+
inner,
|
|
47
|
+
);
|
|
48
|
+
const body = view.expanded
|
|
49
|
+
? fullText.render(inner)
|
|
50
|
+
: [
|
|
51
|
+
...(window ? historyText.render(inner).slice(0, 2) : []),
|
|
52
|
+
...previewText.render(inner).slice(0, window ? 2 : 3),
|
|
53
|
+
hint(theme, inner),
|
|
54
|
+
];
|
|
183
55
|
const box = new Box(padding, 1, (line) => theme.bg("customMessageBg", line));
|
|
184
|
-
box.addChild({
|
|
56
|
+
box.addChild({
|
|
57
|
+
render: () => [heading, ...body],
|
|
58
|
+
invalidate() {
|
|
59
|
+
// The parent rebuilds this stateless box for every render.
|
|
60
|
+
},
|
|
61
|
+
});
|
|
185
62
|
return box.render(width);
|
|
186
63
|
},
|
|
187
64
|
};
|
|
188
65
|
return new MouseRegion(content, (event) => {
|
|
189
|
-
if (event.type !== "click" || event.button !== "left")
|
|
66
|
+
if (event.type !== "click" || event.button !== "left") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
190
69
|
view.expanded = !view.expanded;
|
|
191
70
|
return { handled: true };
|
|
192
71
|
});
|
|
193
72
|
};
|
|
194
|
-
for (const type of ["context-window", "posthorse-reminder"])
|
|
73
|
+
for (const type of ["context-window", "posthorse-reminder"]) {
|
|
74
|
+
pi.registerMessageRenderer(type, renderer);
|
|
75
|
+
}
|
|
195
76
|
}
|