pi-posthorse 0.7.0 → 0.8.1
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 +49 -31
- package/index.ts +42 -1587
- package/package.json +38 -12
- 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 +248 -0
- package/src/history-pages.ts +173 -0
- package/src/history-search.ts +308 -0
- package/src/history.ts +140 -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 +259 -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 -162
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,160 +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; page?: { offset: number; end: number; total: 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 }>; 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 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;
|
|
131
|
-
const previewText = textBlock(preview, theme, color);
|
|
132
|
-
const searchPreviews = sections && display?.kind === "history-search" ? sections.slice(0, 3).map(({ body }) => theme.fg("toolOutput", clean(body).replace(/\s+/g, " "))) : undefined;
|
|
133
|
-
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);
|
|
134
|
-
return {
|
|
135
|
-
invalidate() {},
|
|
136
|
-
render(width) {
|
|
137
|
-
if (expanded) {
|
|
138
|
-
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)] : [])];
|
|
139
|
-
}
|
|
140
|
-
const summaryRows = summaryText.render(width).slice(0, 5);
|
|
141
|
-
const previewRows = summaryOnly && !context.isError && !isPartial ? [] : searchPreviews
|
|
142
|
-
? searchPreviews.map((line) => truncateToWidth(line, width))
|
|
143
|
-
: previewText.render(width);
|
|
144
|
-
const shown = previewRows.slice(0, Math.max(0, Math.min(3, 5 - summaryRows.length - (attachment ? 1 : 0))));
|
|
145
|
-
const hidden = summaryOnly || previewRows.length > shown.length || Boolean(sections || submitted);
|
|
146
|
-
return [...summaryRows, ...shown, ...(attachment ? [truncateToWidth(theme.fg("muted", attachment), width)] : []), ...(hidden ? [hint(theme, width)] : [])];
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
},
|
|
150
|
-
};
|
|
8
|
+
interface ExpansionState {
|
|
9
|
+
readonly global: boolean;
|
|
10
|
+
expanded: boolean;
|
|
151
11
|
}
|
|
152
12
|
|
|
13
|
+
/** Each saved message owns its click state; global expansion resets that local choice. */
|
|
153
14
|
export function registerPosthorseMessages(pi: ExtensionAPI): void {
|
|
154
|
-
const states = new WeakMap<object,
|
|
15
|
+
const states = new WeakMap<object, ExpansionState>();
|
|
155
16
|
const renderer: MessageRenderer = (message, options, theme) => {
|
|
156
17
|
let state = states.get(message);
|
|
157
|
-
if (
|
|
18
|
+
if (state === undefined || state.global !== options.expanded) {
|
|
158
19
|
state = { global: options.expanded, expanded: options.expanded };
|
|
159
20
|
states.set(message, state);
|
|
160
21
|
}
|
|
@@ -163,32 +24,53 @@ export function registerPosthorseMessages(pi: ExtensionAPI): void {
|
|
|
163
24
|
const raw = textOf(message.content);
|
|
164
25
|
const handoffLabel = "\n\nHandoff from the previous window:\n";
|
|
165
26
|
const handoffAt = raw.indexOf(handoffLabel);
|
|
166
|
-
const preview = window
|
|
27
|
+
const preview = window
|
|
28
|
+
? raw.slice(handoffAt < 0 ? raw.length : handoffAt + handoffLabel.length)
|
|
29
|
+
: raw;
|
|
167
30
|
const fullText = textBlock(raw, theme);
|
|
168
31
|
const previewText = textBlock(preview, theme);
|
|
169
32
|
const historyText = textBlock("Earlier conversation remains in history.", theme, "muted");
|
|
170
33
|
const content: Component = {
|
|
171
|
-
invalidate() {
|
|
34
|
+
invalidate() {
|
|
35
|
+
// Message layout is recomputed for the supplied width without cached rows.
|
|
36
|
+
},
|
|
172
37
|
render(width) {
|
|
173
|
-
const padding = Math.min(
|
|
38
|
+
const padding = Math.min(
|
|
39
|
+
options.outputPad,
|
|
40
|
+
Math.max(0, Math.floor((width - 1) / 2)),
|
|
41
|
+
);
|
|
174
42
|
const inner = Math.max(1, width - padding * 2);
|
|
175
43
|
const title = `${view.expanded ? "▾" : "▸"} ${window ? "Context window started" : "Checkpoint reminder"}`;
|
|
176
|
-
const heading = truncateToWidth(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
];
|
|
182
55
|
const box = new Box(padding, 1, (line) => theme.bg("customMessageBg", line));
|
|
183
|
-
box.addChild({
|
|
56
|
+
box.addChild({
|
|
57
|
+
render: () => [heading, ...body],
|
|
58
|
+
invalidate() {
|
|
59
|
+
// The parent rebuilds this stateless box for every render.
|
|
60
|
+
},
|
|
61
|
+
});
|
|
184
62
|
return box.render(width);
|
|
185
63
|
},
|
|
186
64
|
};
|
|
187
65
|
return new MouseRegion(content, (event) => {
|
|
188
|
-
if (event.type !== "click" || event.button !== "left")
|
|
66
|
+
if (event.type !== "click" || event.button !== "left") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
189
69
|
view.expanded = !view.expanded;
|
|
190
70
|
return { handled: true };
|
|
191
71
|
});
|
|
192
72
|
};
|
|
193
|
-
for (const type of ["context-window", "posthorse-reminder"])
|
|
73
|
+
for (const type of ["context-window", "posthorse-reminder"]) {
|
|
74
|
+
pi.registerMessageRenderer(type, renderer);
|
|
75
|
+
}
|
|
194
76
|
}
|