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/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 { 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";
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
- /** 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; 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
- 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
- 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, { global: boolean; expanded: boolean }>();
15
+ const states = new WeakMap<object, ExpansionState>();
155
16
  const renderer: MessageRenderer = (message, options, theme) => {
156
17
  let state = states.get(message);
157
- if (!state || state.global !== options.expanded) {
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 ? handoffAt < 0 ? "" : raw.slice(handoffAt + handoffLabel.length) : raw;
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(options.outputPad, Math.max(0, Math.floor((width - 1) / 2)));
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(theme.fg(window ? "customMessageLabel" : "warning", theme.bold(title)), inner);
177
- const body = view.expanded ? fullText.render(inner) : [
178
- ...(window ? historyText.render(inner).slice(0, 2) : []),
179
- ...previewText.render(inner).slice(0, window ? 2 : 3),
180
- hint(theme, inner),
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({ render: () => [heading, ...body], invalidate() {} });
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") return undefined;
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"]) pi.registerMessageRenderer(type, renderer);
73
+ for (const type of ["context-window", "posthorse-reminder"]) {
74
+ pi.registerMessageRenderer(type, renderer);
75
+ }
194
76
  }