pi-minimalist 0.1.0

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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * FEATURE 1: the compact tool renderer.
3
+ *
4
+ * Installed on the process-global bridge `Symbol.for("pi.defaultToolRenderer")`
5
+ * and consulted by the prototype wrappers in `core-patch.ts`.
6
+ *
7
+ * Core overrides RENDERING only. Native tool definitions keep their schema,
8
+ * `execute()`, and builtin source ownership — that ownership is what lets
9
+ * pi-subagents expose read/bash/write to child runtimes, so this renderer must
10
+ * never register tools itself.
11
+ *
12
+ * This module owns everything derived from a render CONTEXT: the status glyph,
13
+ * the elapsed badge, and the per-call cached components.
14
+ */
15
+
16
+ import type { Component } from "@earendil-works/pi-tui";
17
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
18
+ import { CompactLine, EmptyComponent, GutteredComponent, realTimers, type Timers } from "./components.ts";
19
+ import { Painter, type ThemeLike } from "./row.ts";
20
+ import { describeTool, isBuiltIn, summaryName } from "./tools.ts";
21
+ import { RunGrouping, type Outcome } from "./run-grouping.ts";
22
+ import type { Config } from "./config.ts";
23
+
24
+ /**
25
+ * State shared between renderCall and renderResult through `context.state`
26
+ * (core's rendererState: one object per tool call, stable across renders).
27
+ */
28
+ export type RenderState = {
29
+ /**
30
+ * Component produced by the ORIGINAL built-in renderResult, so consecutive
31
+ * expanded re-renders reuse one instance (built-ins inspect lastComponent).
32
+ */
33
+ originalResult?: unknown;
34
+ /**
35
+ * Cached call row. Core's fallback path never passes lastComponent, so this is
36
+ * the only stable per-call cache there. Without it every repaint allocated a
37
+ * fresh row and started another ticker while clearing none.
38
+ */
39
+ callLine?: unknown;
40
+ /** Wall-clock timestamp (ms) of the first render after execution started. */
41
+ startedAt?: number;
42
+ };
43
+
44
+ /** The render context fields this module reads. Pi's real context has more. */
45
+ export type CallContext = {
46
+ toolCallId?: string;
47
+ state: RenderState;
48
+ isPartial?: boolean;
49
+ isError?: boolean;
50
+ executionStarted?: boolean;
51
+ expanded?: boolean;
52
+ };
53
+
54
+ /**
55
+ * `color` is a real ThemeColor so a mistyped token fails to COMPILE. At runtime
56
+ * theme.fg() would throw and core would silently fall back to a verbose card.
57
+ */
58
+ export type Status = {
59
+ glyph: string;
60
+ color: ThemeColor;
61
+ /** The grouping view of the same state, so nothing re-derives it from glyph. */
62
+ outcome: Outcome;
63
+ /** Seconds since execution started, or undefined when not timing. */
64
+ elapsed?: number;
65
+ };
66
+
67
+ /**
68
+ * Pick the status glyph, color, and outcome for a render context:
69
+ * - finished: done glyph in label color; failed: failed glyph in error color
70
+ * - running: running glyph, with elapsed seconds
71
+ * - queued: queued glyph
72
+ *
73
+ * GOTCHA: session replay (restart with history) never calls
74
+ * markExecutionStarted(), so `executionStarted` stays false for reloaded tools.
75
+ * "Finished" must therefore key off `!isPartial` (a final result exists), NOT off
76
+ * `executionStarted`, or every historical tool call shows the queued caret after
77
+ * a restart. `executionStarted` only separates queued from running while live.
78
+ */
79
+ export function statusGlyph(context: CallContext, config: Config, now: () => number = Date.now): Status {
80
+ const glyphs = config.glyphs();
81
+ const { label, error } = config.tokens();
82
+
83
+ if (!context.isPartial) {
84
+ // Final result present: covers both live completion and replayed history.
85
+ return context.isError
86
+ ? { glyph: glyphs.failed, color: error, outcome: "failure" }
87
+ : { glyph: glyphs.done, color: label, outcome: "success" };
88
+ }
89
+ if (context.executionStarted) {
90
+ const state = context.state;
91
+ state.startedAt ??= now();
92
+ return {
93
+ glyph: glyphs.running,
94
+ color: label,
95
+ outcome: "pending",
96
+ // Omitted entirely when the timer is off, so no ticker is ever started.
97
+ elapsed: config.get("timer") ? Math.floor((now() - state.startedAt) / 1000) : undefined,
98
+ };
99
+ }
100
+ return { glyph: glyphs.queued, color: label, outcome: "pending" };
101
+ }
102
+
103
+ /**
104
+ * Elapsed badge, or "" when not running or under one second.
105
+ *
106
+ * Hiding the first second avoids a `[⏱ 0s]` flicker on commands that finish
107
+ * instantly; the 1s ticker repaints the row once it becomes meaningful.
108
+ */
109
+ export function timerBadge(elapsed: number | undefined, config: Config): string {
110
+ if (elapsed === undefined || elapsed < 1) return "";
111
+ const icon = config.glyphs().timer;
112
+ // The ASCII preset has no clock glyph, so the badge degrades to `[3s]`.
113
+ return icon ? `[${icon} ${elapsed}s]` : `[${elapsed}s]`;
114
+ }
115
+
116
+ /** Original result renderer for this tool, handed to us by the core bridge. */
117
+ export type NativeResultRenderer = (result: any, options: any, theme: any, context: any) => Component;
118
+
119
+ /** The object stored on the process-global renderer symbol. */
120
+ export type ToolRenderer = {
121
+ renderShell: "self";
122
+ handles: (name: string) => boolean;
123
+ renderCall: (name: string, args: unknown, theme: any, context: any) => CompactLine;
124
+ renderResult: (
125
+ name: string,
126
+ result: any,
127
+ options: any,
128
+ theme: any,
129
+ context: any,
130
+ nativeRenderer?: NativeResultRenderer,
131
+ ) => Component | undefined;
132
+ };
133
+
134
+ export type RendererDeps = {
135
+ /** Live settings; read at render time so toggles apply to existing history. */
136
+ config: Config;
137
+ /** Shared grouping state; injected so existing rows update without a patch. */
138
+ grouping: RunGrouping;
139
+ /** Clock for the elapsed timer; injectable so tests need no real sleeping. */
140
+ now?: () => number;
141
+ /** Repaint scheduler for the ticker; injectable for fake-timer tests. */
142
+ timers?: Timers;
143
+ };
144
+
145
+ /**
146
+ * Build the renderer.
147
+ *
148
+ * A factory, not a module-level singleton, so tests drive the exact production
149
+ * code path with a deterministic clock and timers.
150
+ */
151
+ export function createToolRenderer(deps: RendererDeps): ToolRenderer {
152
+ const { config, grouping } = deps;
153
+ const now = deps.now ?? Date.now;
154
+ const timers = deps.timers ?? realTimers;
155
+
156
+ return {
157
+ // One custom line instead of Pi's padded Box shell.
158
+ renderShell: "self",
159
+ // Blacklist plus the master switch: every tool compacts unless excluded.
160
+ handles: (name) => config.compacts(name),
161
+
162
+ renderCall(name, args, theme: ThemeLike, context: CallContext & { lastComponent?: unknown; invalidate(): void }) {
163
+ const status = statusGlyph(context, config, now);
164
+ const component = reuseRow(context, timers);
165
+
166
+ // Tick while running; stop at a terminal state. invalidate() re-runs
167
+ // updateDisplay (recomputing elapsed) then renders — a plain
168
+ // requestRender would only repaint stale text. Async ticker fires are
169
+ // safe; never invalidate SYNCHRONOUSLY from a renderer.
170
+ if (status.elapsed !== undefined) component.startTicker(() => context.invalidate());
171
+ else component.stopTicker();
172
+
173
+ // Painted at render time, because the detail budget depends on the real
174
+ // viewport width and TUI only reveals it during render().
175
+ const paint = (width: number) => {
176
+ const painter = new Painter(theme, config);
177
+ const badge = timerBadge(status.elapsed, config);
178
+ const { label, details } = describeTool(name, args, {
179
+ expanded: context.expanded === true,
180
+ budget: detailBudget(width, status.glyph, name, badge),
181
+ config,
182
+ });
183
+ // No background while executing — the animated timer already signals
184
+ // activity, and a highlight flashing on each repaint was distracting.
185
+ return painter.labeled({ glyph: status.glyph, glyphColor: status.color, label, badge, details });
186
+ };
187
+
188
+ // EVERY call is observed, so a non-foldable one still cuts a run. Core
189
+ // always supplies toolCallId; without one, skip grouping rather than let
190
+ // several id-less rows collapse into one accidental group.
191
+ const id = context.toolCallId;
192
+ if (!id) {
193
+ component.setRow(paint);
194
+ return component;
195
+ }
196
+ grouping.observe(id, summaryName(name, args), status.outcome, context.expanded === true);
197
+ component.setRow((width) => grouping.rowFor(id, new Painter(theme, config), () => paint(width)));
198
+ return component;
199
+ },
200
+
201
+ renderResult(name, result, options, theme: ThemeLike, context, nativeRenderer) {
202
+ // The call row already carries status, so a collapsed result adds no height.
203
+ if (!options.expanded) return new EmptyComponent();
204
+
205
+ // A rendererless third-party tool has no native output to preserve;
206
+ // `undefined` tells core's fallback path to show its own text output.
207
+ if (!nativeRenderer) return isBuiltIn(name) ? new EmptyComponent() : undefined;
208
+
209
+ // Expanded rows delegate to the tool's ORIGINAL renderer, preserving diffs,
210
+ // syntax highlighting and MCP result formatting.
211
+ const state = context.state as RenderState;
212
+ const component = nativeRenderer(result, options, theme, {
213
+ // Replace only lastComponent: the native renderer must see ITS component,
214
+ // not our gutter wrapper.
215
+ ...context,
216
+ lastComponent: state.originalResult,
217
+ });
218
+ state.originalResult = component;
219
+
220
+ const wrapper =
221
+ context.lastComponent instanceof GutteredComponent
222
+ ? context.lastComponent
223
+ : new GutteredComponent(component, new Painter(theme, config).outputGutter());
224
+ wrapper.setInner(component);
225
+ return wrapper;
226
+ },
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Columns left for the detail text after the fixed parts of the row.
232
+ *
233
+ * Approximate on purpose: `CompactLine` performs the exact, ANSI-aware
234
+ * truncation. This only decides which field yields first in a composed detail.
235
+ */
236
+ function detailBudget(width: number, glyph: string, label: string, badge: string): number {
237
+ const gutter = 3;
238
+ const fixed = gutter + glyph.length + 1 + label.length + 1 + (badge ? badge.length + 1 : 0);
239
+ return Math.max(1, width - fixed);
240
+ }
241
+
242
+ /**
243
+ * Reuse the row component across repaints so the TUI updates it in place.
244
+ *
245
+ * Two caches on purpose: core's renderer path passes `lastComponent`, but its
246
+ * FALLBACK path always passes undefined, so `context.state` is the only stable
247
+ * per-call cache there. Allocating a fresh row per repaint used to start another
248
+ * 1s ticker while clearing none — an interval leak that froze the UI.
249
+ */
250
+ function reuseRow(context: CallContext & { lastComponent?: unknown }, timers: Timers): CompactLine {
251
+ const cached =
252
+ context.lastComponent instanceof CompactLine
253
+ ? context.lastComponent
254
+ : context.state.callLine instanceof CompactLine
255
+ ? context.state.callLine
256
+ : undefined;
257
+ const component = cached ?? new CompactLine(timers);
258
+ context.state.callLine = component;
259
+ return component;
260
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tool vocabulary: how each tool describes itself on one line.
3
+ *
4
+ * Pure and deterministic — no components, no theme, no globals. Which tools get
5
+ * compacted is `Config.compacts()`, not this file.
6
+ *
7
+ * `describeTool` returns the label and details SEPARATELY and must never join
8
+ * them: the painter colors each part, and an earlier version that returned one
9
+ * string forced the painter to split it back apart on the first space, silently
10
+ * assuming no label ever contains one.
11
+ */
12
+
13
+ import type { Config } from "./config.ts";
14
+
15
+ /** Native tools with a per-tool details extractor below. */
16
+ // `as const` preserves literal names instead of widening every item to string.
17
+ export const BUILT_INS = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
18
+
19
+ // `(typeof BUILT_INS)[number]` turns the tuple values into a union type.
20
+ export type ToolName = (typeof BUILT_INS)[number];
21
+
22
+ /** True for native tools with a hand-written one-line description. */
23
+ export function isBuiltIn(name: string): name is ToolName {
24
+ return BUILT_INS.includes(name as ToolName);
25
+ }
26
+
27
+ /** MCP tools discovered from their registering extension's source metadata. */
28
+ const mcpTools = new Set<string>();
29
+
30
+ /**
31
+ * Refresh after MCP adapter registration.
32
+ *
33
+ * No longer decides what gets COMPACTED (the blacklist does that). It is kept
34
+ * because it decides what gets LABELED `mcp`, which is real information: a bare
35
+ * `atlassian_getConfluencePage` row does not tell you it crossed an MCP server.
36
+ */
37
+ export function refreshMcpTools(tools: { name: string; sourceInfo: { path: string } }[]): void {
38
+ mcpTools.clear();
39
+ for (const tool of tools) {
40
+ if (tool.sourceInfo.path.includes("pi-mcp-adapter")) mcpTools.add(tool.name);
41
+ }
42
+ }
43
+
44
+ export function isMcpTool(name: string): boolean {
45
+ return name === "mcp" || name === "mcpScript" || name.startsWith("mcp__") || mcpTools.has(name);
46
+ }
47
+
48
+ /**
49
+ * Collapse whitespace and truncate for one-line display.
50
+ *
51
+ * `max` is a DISPLAY budget in characters; `hardCap` is a sanity limit that
52
+ * exists only so a multi-megabyte heredoc is not whitespace-collapsed, colored
53
+ * and measured on every repaint. Neither is the final word on width —
54
+ * `CompactLine` truncates at the real viewport column count.
55
+ */
56
+ export function compact(value: unknown, max = Number.POSITIVE_INFINITY, hardCap = 4000): string {
57
+ const limit = Math.min(max, hardCap);
58
+ // Slice BEFORE the regex so a huge string is never fully scanned. The extra
59
+ // headroom leaves room for whitespace runs that collapse away.
60
+ const raw = String(value ?? "");
61
+ const sliced = Number.isFinite(limit) ? raw.slice(0, limit * 2 + 16) : raw.slice(0, hardCap * 2 + 16);
62
+ // Replacing every whitespace run prevents multi-line call rows.
63
+ const text = sliced.replace(/\s+/g, " ").trim();
64
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
65
+ }
66
+
67
+ /** Minimum columns a composed field keeps, so it never vanishes entirely. */
68
+ const MIN_FIELD = 12;
69
+
70
+ export type DescribeOptions = {
71
+ /** Expanded rows keep full-length details (still on ONE physical line). */
72
+ expanded?: boolean;
73
+ /**
74
+ * Columns available for the details segment.
75
+ *
76
+ * Approximate on purpose: `CompactLine` performs the exact, ANSI-aware,
77
+ * wide-character-aware truncation. This budget only decides WHICH field is
78
+ * sacrificed when a composed detail (`/pattern/ in path`) cannot fit.
79
+ */
80
+ budget?: number;
81
+ config?: Config;
82
+ };
83
+
84
+ /**
85
+ * The one-line description of a call: `read` + `src/a.ts:1-50`, `bash` +
86
+ * `go test ./...`, or a third-party tool's name plus its most identifying
87
+ * argument.
88
+ */
89
+ export function describeTool(name: string, args: any, options: DescribeOptions = {}): { label: string; details: string } {
90
+ const hardCap = options.config?.get("maxDetailChars") ?? 4000;
91
+ const budget = options.budget ?? Number.POSITIVE_INFINITY;
92
+ const a = args ?? {};
93
+
94
+ if (name === "mcp") return { label: "mcp", details: mcpDetails(a, budget, hardCap) };
95
+ if (name === "mcpScript") return { label: "mcpScript", details: compact(a.code, budget, hardCap) };
96
+ if (name.startsWith("mcp__")) {
97
+ const server = name.slice("mcp__".length);
98
+ const tool = compact(a.tool, budget, hardCap);
99
+ return { label: "mcp", details: tool ? `${tool} @ ${server}` : `@ ${server}` };
100
+ }
101
+ if (isMcpTool(name)) return { label: "mcp", details: name };
102
+ if (isBuiltIn(name)) return { label: name, details: builtInDetails(name, a, options, hardCap) };
103
+ // Any other tool: show the name plus whichever argument identifies the call.
104
+ return { label: name, details: genericDetails(a, budget, hardCap) };
105
+ }
106
+
107
+ /**
108
+ * Name shown in a folded run summary: the concrete operation, never a category.
109
+ *
110
+ * `mcp` rows label themselves `mcp` for readability, but a summary of five
111
+ * different MCP calls reading `mcp ×5` would hide which servers were touched.
112
+ */
113
+ export function summaryName(name: string, args: any): string {
114
+ if (isBuiltIn(name) || name === "mcpScript") return name;
115
+ if (name === "mcp") return compact(args?.tool) || "mcp";
116
+ if (name.startsWith("mcp__")) {
117
+ const tool = compact(args?.tool);
118
+ return tool ? `${tool} @ ${name.slice("mcp__".length)}` : name;
119
+ }
120
+ return name;
121
+ }
122
+
123
+ function mcpDetails(args: any, budget: number, hardCap: number): string {
124
+ if (args.tool) return compact(args.tool, budget, hardCap);
125
+ for (const key of ["search", "describe", "connect", "action"] as const) {
126
+ if (args[key]) return `${key} ${compact(args[key], budget, hardCap)}`;
127
+ }
128
+ return "";
129
+ }
130
+
131
+ /**
132
+ * Argument keys worth showing for a tool we know nothing about, most
133
+ * identifying first. Without this a blacklist default would render every
134
+ * third-party tool as a bare name, which is less information than Pi's own
135
+ * verbose card — the compaction would be a downgrade rather than a cleanup.
136
+ */
137
+ const GENERIC_KEYS = [
138
+ "command", "path", "file", "filePath", "pattern", "query", "url", "name",
139
+ "tool", "action", "id", "target", "message", "text", "prompt",
140
+ ] as const;
141
+
142
+ function genericDetails(args: any, budget: number, hardCap: number): string {
143
+ for (const key of GENERIC_KEYS) {
144
+ const value = args[key];
145
+ // Only scalars: an object would serialize into noise on a one-line row.
146
+ if (value !== undefined && value !== null && typeof value !== "object") {
147
+ const text = compact(value, budget, hardCap);
148
+ if (text) return text;
149
+ }
150
+ }
151
+ return "";
152
+ }
153
+
154
+ function builtInDetails(name: ToolName, args: any, options: DescribeOptions, hardCap: number): string {
155
+ const expanded = options.expanded === true;
156
+ const budget = options.budget ?? Number.POSITIVE_INFINITY;
157
+ // Paths are shown verbatim (no ~/ home abbreviation — it was decorative and
158
+ // cost a homedir() call per render).
159
+ const path = (value: unknown) => String(value ?? "");
160
+
161
+ switch (name) {
162
+ case "bash":
163
+ // ONE field, so no budget split is needed: CompactLine truncates at the
164
+ // real viewport width. An earlier version also capped this at 100
165
+ // characters, which threw away ~89 usable columns on a 200-column
166
+ // terminal. Newlines still collapse so a heredoc cannot escape the row.
167
+ return compact(args.command, expanded ? Number.POSITIVE_INFINITY : budget, hardCap);
168
+ case "read": {
169
+ // Show the line range when offset/limit were used, mirroring the built-in
170
+ // read tool's "path:start-end" notation.
171
+ const start = args.offset ?? 1;
172
+ const range = args.offset !== undefined || args.limit !== undefined
173
+ ? `:${start}${args.limit ? `-${start + args.limit - 1}` : ""}`
174
+ : "";
175
+ return `${path(args.path)}${range}`;
176
+ }
177
+ case "edit":
178
+ case "write":
179
+ return path(args.path);
180
+ case "grep":
181
+ case "find": {
182
+ // COMPOSED detail: two fields competing for one row. The path identifies
183
+ // the search, so it keeps its columns and the pattern takes the rest;
184
+ // otherwise a long regex pushed the path off the end of the line.
185
+ const where = path(args.path ?? ".");
186
+ const wrapper = name === "grep" ? 2 : 0; // the two slashes in /pattern/
187
+ const overhead = where.length + " in ".length + wrapper;
188
+ const room = Number.isFinite(budget) ? Math.max(MIN_FIELD, budget - overhead) : Number.POSITIVE_INFINITY;
189
+ const pattern = compact(args.pattern, room, hardCap);
190
+ return name === "grep" ? `/${pattern}/ in ${where}` : `${pattern} in ${where}`;
191
+ }
192
+ case "ls":
193
+ return path(args.path ?? ".");
194
+ }
195
+ }