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.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/index.ts +132 -0
- package/package.json +21 -0
- package/src/bridge.ts +124 -0
- package/src/components.ts +173 -0
- package/src/config-file.ts +277 -0
- package/src/config-ui.ts +208 -0
- package/src/config.ts +246 -0
- package/src/core-patch.ts +510 -0
- package/src/row.ts +193 -0
- package/src/run-grouping.ts +378 -0
- package/src/thinking.ts +100 -0
- package/src/tool-renderer.ts +260 -0
- package/src/tools.ts +195 -0
|
@@ -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
|
+
}
|