pi-tui-footer 1.2.7 → 1.2.8
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/extensions/rounded-tools.ts +123 -166
- package/package.json +1 -1
|
@@ -1,27 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rounded tool frames
|
|
2
|
+
* Rounded tool frames — rounded frames for tool calls and results.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* localized copy of npm:pi-rounded-tools@0.1.3, MIT, by OrionPax,
|
|
6
|
-
* https://github.com/orionpax1997/pi-rounded-tools), integrated into pi-tui
|
|
7
|
-
* (based on https://github.com/OldSuns/pi-open-tui v0.3.11, MIT, by
|
|
8
|
-
* OldSun) and gated behind `roundedTools` in pi-tui config
|
|
9
|
-
* (~/.pi/agent/pi-tui.json, default on).
|
|
10
|
-
*
|
|
11
|
-
* Re-registers the built-in tools (read, write, edit, bash, grep, find, ls)
|
|
4
|
+
* Re-registers built-in tools (read, write, bash, powershell, grep, find, ls)
|
|
12
5
|
* with `renderShell: "self"` and wraps each tool call / result in a frame
|
|
13
6
|
* drawn with Unicode rounded-corner characters (╭ ╮ ╰ ╯ ─ │).
|
|
14
7
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
8
|
+
* Tools that already render their own shell (e.g. `edit` in modern Pi versions)
|
|
9
|
+
* are preserved as-is to avoid duplicate nesting and UI glitches.
|
|
17
10
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* component in a `RoundedFrame`. That way bash's preview, read's syntax
|
|
21
|
-
* highlighting, edit's diff stats, etc. all stay exactly as pi ships them.
|
|
11
|
+
* Border color follows `theme.fg("border", ...)` so it adapts to your theme,
|
|
12
|
+
* using warning (yellow) during pending/streaming execution and error (red) on failure.
|
|
22
13
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
14
|
+
* Non-default tools (grep, find, ls, powershell) preserve `defaultActive: false`
|
|
15
|
+
* to avoid polluting the model's active toolset.
|
|
25
16
|
*/
|
|
26
17
|
|
|
27
18
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
@@ -41,23 +32,48 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
|
|
41
32
|
// ─── Rounded-corner frame ────────────────────────────────────────────────
|
|
42
33
|
//
|
|
43
34
|
// Three modes let call + result stack into one continuous frame:
|
|
44
|
-
// - "closed" → ╭ ── ╮ / │ x │ / ╰ ── ╯ (standalone box)
|
|
35
|
+
// - "closed" → ╭ ── ╮ / │ x │ / ╰ ── ╯ (standalone box / pending call)
|
|
45
36
|
// - "open-bottom" → ╭ ── ╮ / │ x │ (top of a stacked frame)
|
|
46
37
|
// - "open-top" → │ x │ / ╰ ── ╯ (bottom of a stacked frame)
|
|
47
38
|
|
|
48
|
-
type FrameMode = "closed" | "open-bottom" | "open-top";
|
|
39
|
+
export type FrameMode = "closed" | "open-bottom" | "open-top";
|
|
40
|
+
|
|
41
|
+
export class RoundedFrame implements Component {
|
|
42
|
+
private cachedWidth: number | undefined;
|
|
43
|
+
private cachedInnerLines: string[] | undefined;
|
|
44
|
+
private cachedLines: string[] | undefined;
|
|
49
45
|
|
|
50
|
-
class RoundedFrame implements Component {
|
|
51
46
|
constructor(
|
|
52
|
-
private
|
|
53
|
-
private
|
|
54
|
-
private
|
|
47
|
+
private inner: Component,
|
|
48
|
+
private border: (text: string) => string,
|
|
49
|
+
private mode: FrameMode = "closed",
|
|
55
50
|
) {}
|
|
56
51
|
|
|
57
52
|
getInner(): Component {
|
|
58
53
|
return this.inner;
|
|
59
54
|
}
|
|
60
55
|
|
|
56
|
+
getMode(): FrameMode {
|
|
57
|
+
return this.mode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setMode(mode: FrameMode): void {
|
|
61
|
+
if (this.mode !== mode) {
|
|
62
|
+
this.mode = mode;
|
|
63
|
+
this.invalidate();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
update(inner: Component, border: (text: string) => string, mode: FrameMode): this {
|
|
68
|
+
if (this.inner !== inner || this.mode !== mode) {
|
|
69
|
+
this.inner = inner;
|
|
70
|
+
this.mode = mode;
|
|
71
|
+
this.invalidate();
|
|
72
|
+
}
|
|
73
|
+
this.border = border;
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
invalidate(): void {
|
|
62
78
|
this.inner.invalidate?.();
|
|
63
79
|
this.cachedWidth = undefined;
|
|
@@ -91,17 +107,6 @@ class RoundedFrame implements Component {
|
|
|
91
107
|
});
|
|
92
108
|
}
|
|
93
109
|
|
|
94
|
-
// pi's built-in tool renderers cache the previously-returned component
|
|
95
|
-
// via `context.lastComponent` and call mutating methods on it
|
|
96
|
-
// (`Text.setText`, `Container.clear`/`addChild`, `invalidate`) so they
|
|
97
|
-
// can re-render in place during streaming. Once we wrap the inner in a
|
|
98
|
-
// RoundedFrame, pi hands our wrapper back to the inner renderer on the
|
|
99
|
-
// next render — but pi's try/catch around `callRenderer(...)` falls
|
|
100
|
-
// back to a plain `createCallFallback()` text node if the inner
|
|
101
|
-
// renderer throws, which would make the border vanish on every
|
|
102
|
-
// Ctrl+O toggle. Forward these methods to the inner so the original
|
|
103
|
-
// renderers work unchanged; calls on a wrapper whose inner lacks the
|
|
104
|
-
// method are no-ops thanks to optional chaining.
|
|
105
110
|
setText(text: string): void {
|
|
106
111
|
(this.inner as any).setText?.(text);
|
|
107
112
|
}
|
|
@@ -122,21 +127,6 @@ class RoundedFrame implements Component {
|
|
|
122
127
|
const innerWidth = Math.max(1, width - 4); // │ + space + content + space + │
|
|
123
128
|
const innerLines = this.inner.render(innerWidth);
|
|
124
129
|
|
|
125
|
-
// pi's TUI re-renders every transcript component on every render
|
|
126
|
-
// cycle (each keystroke), but built-in components below us (e.g.
|
|
127
|
-
// `Text`, `Box`) cache their rendered line arrays and return the
|
|
128
|
-
// same array reference when content and width haven't changed.
|
|
129
|
-
// Without a cache here, every keystroke re-runs `visibleWidth()`
|
|
130
|
-
// (ANSI parsing) and string building for every line of every
|
|
131
|
-
// framed tool entry — O(N_session) per keypress, visible as input
|
|
132
|
-
// lag on long sessions.
|
|
133
|
-
//
|
|
134
|
-
// Cache key: `width` plus the array reference returned by
|
|
135
|
-
// `inner.render()`. Built-in Text/Box return the same reference on
|
|
136
|
-
// cache hits and a fresh array whenever their content changes
|
|
137
|
-
// (e.g. Ctrl+O expand), so reference equality is a reliable change
|
|
138
|
-
// signal. `invalidate()` (theme changes etc.) clears the cache
|
|
139
|
-
// explicitly, and so does an actual `width` change.
|
|
140
130
|
if (
|
|
141
131
|
this.cachedLines &&
|
|
142
132
|
this.cachedWidth === width &&
|
|
@@ -161,10 +151,6 @@ class RoundedFrame implements Component {
|
|
|
161
151
|
out.push(side + " " + line + pad + " " + side);
|
|
162
152
|
}
|
|
163
153
|
} else if (this.mode === "closed") {
|
|
164
|
-
// Standalone closed frame with no content: draw a placeholder row
|
|
165
|
-
// so the box still has visual presence. Skipped for open-top /
|
|
166
|
-
// open-bottom since they pair with another half-frame and a fake
|
|
167
|
-
// empty row would show up as a stray blank line in the middle.
|
|
168
154
|
out.push(side + " ".repeat(width - 2) + side);
|
|
169
155
|
}
|
|
170
156
|
|
|
@@ -176,10 +162,6 @@ class RoundedFrame implements Component {
|
|
|
176
162
|
this.cachedLines = out;
|
|
177
163
|
return out;
|
|
178
164
|
}
|
|
179
|
-
|
|
180
|
-
private cachedWidth: number | undefined;
|
|
181
|
-
private cachedInnerLines: string[] | undefined;
|
|
182
|
-
private cachedLines: string[] | undefined;
|
|
183
165
|
}
|
|
184
166
|
|
|
185
167
|
const frame = (
|
|
@@ -187,24 +169,8 @@ const frame = (
|
|
|
187
169
|
theme: { fg: (color: string, text: string) => string },
|
|
188
170
|
mode: FrameMode = "closed",
|
|
189
171
|
colorKey: string = "border",
|
|
190
|
-
):
|
|
172
|
+
): RoundedFrame => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode);
|
|
191
173
|
|
|
192
|
-
/**
|
|
193
|
-
* Pick a border color key based on the tool's runtime state.
|
|
194
|
-
*
|
|
195
|
-
* - still running (`context.isPartial`) → warning (yellow)
|
|
196
|
-
* - finished with an error (`context.isError`) → error (red)
|
|
197
|
-
* - finished successfully → border (theme default)
|
|
198
|
-
*
|
|
199
|
-
* Both `renderCall` and `renderResult` go through this picker, so when
|
|
200
|
-
* they stack into one continuous frame, the borders match. pi re-renders
|
|
201
|
-
* the whole component on every state transition (args streaming → done,
|
|
202
|
-
* partial → final), and `context.isPartial` flips accordingly, so call
|
|
203
|
-
* and result always pick the same color at any given moment.
|
|
204
|
-
*
|
|
205
|
-
* Mirrors pi's own 3-state scheme (`toolPendingBg` / `toolSuccessBg` /
|
|
206
|
-
* `toolErrorBg`) — see `theme.js`.
|
|
207
|
-
*/
|
|
208
174
|
function borderColorFor(
|
|
209
175
|
context: { isPartial?: boolean; isError?: boolean } | undefined,
|
|
210
176
|
): string {
|
|
@@ -219,11 +185,6 @@ type ToolDef = ToolDefinition<any, any, any>;
|
|
|
219
185
|
|
|
220
186
|
const EMPTY_COMPONENT: Component = { render: () => [], invalidate: () => {} };
|
|
221
187
|
|
|
222
|
-
/**
|
|
223
|
-
* Unwrap inner component from a RoundedFrame if context.lastComponent is wrapped,
|
|
224
|
-
* allowing underlying renderers (e.g. edit, bash, read) to preserve their component
|
|
225
|
-
* cache across renders instead of recreating on every tick.
|
|
226
|
-
*/
|
|
227
188
|
function unwrapInner(component: unknown): Component | undefined {
|
|
228
189
|
if (component instanceof RoundedFrame) {
|
|
229
190
|
return component.getInner();
|
|
@@ -231,15 +192,12 @@ function unwrapInner(component: unknown): Component | undefined {
|
|
|
231
192
|
return component as Component | undefined;
|
|
232
193
|
}
|
|
233
194
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
*/
|
|
241
|
-
function wrapBuiltin(def: ToolDef) {
|
|
242
|
-
const callMode: FrameMode = def.renderResult ? "open-bottom" : "closed";
|
|
195
|
+
export function wrapBuiltin(def: ToolDef): ToolDef {
|
|
196
|
+
// Tools that already manage their own shell (e.g. edit) should remain untouched.
|
|
197
|
+
if (def.renderShell === "self") {
|
|
198
|
+
return def;
|
|
199
|
+
}
|
|
200
|
+
|
|
243
201
|
return {
|
|
244
202
|
...def,
|
|
245
203
|
renderShell: "self" as const,
|
|
@@ -248,125 +206,124 @@ function wrapBuiltin(def: ToolDef) {
|
|
|
248
206
|
const inner: Component = def.renderCall
|
|
249
207
|
? def.renderCall(args, theme, { ...context, lastComponent: unwrapped })
|
|
250
208
|
: EMPTY_COMPONENT;
|
|
251
|
-
|
|
209
|
+
|
|
210
|
+
// If the tool is still pending/streaming and no result has arrived yet,
|
|
211
|
+
// close the bottom border so it renders as a complete box instead of an open frame.
|
|
212
|
+
const isPendingWithoutResult = Boolean(context?.isPartial && !context?.state?.__hasResult);
|
|
213
|
+
const mode: FrameMode = def.renderResult && !isPendingWithoutResult ? "open-bottom" : "closed";
|
|
214
|
+
const borderFn = (t: string) => theme.fg(borderColorFor(context), t);
|
|
215
|
+
|
|
216
|
+
let callFrame: RoundedFrame;
|
|
217
|
+
if (context?.lastComponent instanceof RoundedFrame) {
|
|
218
|
+
callFrame = context.lastComponent.update(inner, borderFn, mode);
|
|
219
|
+
} else {
|
|
220
|
+
callFrame = frame(inner, theme, mode, borderColorFor(context));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (context?.state) {
|
|
224
|
+
context.state.__callFrame = callFrame;
|
|
225
|
+
}
|
|
226
|
+
return callFrame;
|
|
252
227
|
},
|
|
253
228
|
renderResult: (result: any, options: any, theme: any, context: any) => {
|
|
229
|
+
if (context?.state) {
|
|
230
|
+
context.state.__hasResult = true;
|
|
231
|
+
// Connect seamlessly with the call frame by opening its bottom border.
|
|
232
|
+
const callFrame = context.state.__callFrame;
|
|
233
|
+
if (callFrame instanceof RoundedFrame && callFrame.getMode() !== "open-bottom") {
|
|
234
|
+
callFrame.setMode("open-bottom");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
254
238
|
const unwrapped = unwrapInner(context?.lastComponent);
|
|
255
239
|
const inner: Component = def.renderResult
|
|
256
240
|
? def.renderResult(result, options, theme, { ...context, lastComponent: unwrapped })
|
|
257
241
|
: EMPTY_COMPONENT;
|
|
242
|
+
const borderFn = (t: string) => theme.fg(borderColorFor(context), t);
|
|
243
|
+
|
|
244
|
+
if (context?.lastComponent instanceof RoundedFrame) {
|
|
245
|
+
return context.lastComponent.update(inner, borderFn, "open-top");
|
|
246
|
+
}
|
|
258
247
|
return frame(inner, theme, "open-top", borderColorFor(context));
|
|
259
248
|
},
|
|
260
249
|
};
|
|
261
250
|
}
|
|
262
251
|
|
|
263
|
-
/**
|
|
264
|
-
* Register built-in tools (read, write, edit, bash, powershell, grep, find, ls),
|
|
265
|
-
* either with rounded frames (`enabled`) or with stock definitions (`!enabled`).
|
|
266
|
-
*
|
|
267
|
-
* If a third-party extension has already replaced a built-in tool (e.g. pi-fff
|
|
268
|
-
* overriding grep/find), that tool is skipped here to prevent clobbering.
|
|
269
|
-
*/
|
|
270
252
|
export function registerRoundedTools(
|
|
271
253
|
target: ExtensionAPI | ((tool: ToolDef) => void),
|
|
272
254
|
enabled: boolean,
|
|
273
255
|
cwd: string,
|
|
274
|
-
|
|
256
|
+
_captured?: Map<string, ToolDef>,
|
|
275
257
|
): void {
|
|
276
258
|
const register = typeof target === "function" ? target : target.registerTool.bind(target);
|
|
277
259
|
|
|
260
|
+
let settings: any;
|
|
261
|
+
if (typeof target !== "function" && target.getSettings) {
|
|
262
|
+
try {
|
|
263
|
+
settings = target.getSettings();
|
|
264
|
+
} catch {
|
|
265
|
+
// runtime not initialized yet during early load
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const autoResizeImages = settings?.images?.autoResize ?? true;
|
|
269
|
+
const shellCommandPrefix = settings?.shellCommandPrefix;
|
|
270
|
+
const shellPath = settings?.shellPath;
|
|
271
|
+
|
|
272
|
+
let activeToolNames: Set<string> | undefined;
|
|
273
|
+
if (typeof target !== "function" && target.getActiveTools) {
|
|
274
|
+
try {
|
|
275
|
+
const active = target.getActiveTools();
|
|
276
|
+
if (Array.isArray(active) && active.length > 0) {
|
|
277
|
+
activeToolNames = new Set(active);
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
// runtime not initialized yet during early load
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const defaultActiveNames = activeToolNames ?? new Set(["read", "bash", "edit", "write"]);
|
|
284
|
+
|
|
278
285
|
const builtins: Record<string, () => ToolDef> = {
|
|
279
|
-
read: () => createReadToolDefinition(cwd),
|
|
286
|
+
read: () => createReadToolDefinition(cwd, { autoResizeImages }),
|
|
280
287
|
write: () => createWriteToolDefinition(cwd),
|
|
281
288
|
edit: () => createEditToolDefinition(cwd),
|
|
282
|
-
bash: () => createBashToolDefinition(cwd),
|
|
283
|
-
powershell: () => createPowerShellToolDefinition(cwd),
|
|
289
|
+
bash: () => createBashToolDefinition(cwd, { commandPrefix: shellCommandPrefix, shellPath }),
|
|
290
|
+
powershell: () => createPowerShellToolDefinition(cwd, { shellPath }),
|
|
284
291
|
ls: () => createLsToolDefinition(cwd),
|
|
285
292
|
grep: () => createGrepToolDefinition(cwd),
|
|
286
293
|
find: () => createFindToolDefinition(cwd),
|
|
287
294
|
};
|
|
288
295
|
|
|
289
296
|
for (const [name, factory] of Object.entries(builtins)) {
|
|
290
|
-
|
|
291
|
-
|
|
297
|
+
const rawDef = factory();
|
|
298
|
+
// Skip tools that already manage their own shell framing (e.g. edit)
|
|
299
|
+
if (rawDef.renderShell === "self") {
|
|
300
|
+
continue;
|
|
292
301
|
}
|
|
302
|
+
|
|
303
|
+
const shouldBeActive = defaultActiveNames.has(name);
|
|
304
|
+
const baseDef: ToolDef = {
|
|
305
|
+
...rawDef,
|
|
306
|
+
defaultActive: shouldBeActive,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
register(enabled ? wrapBuiltin(baseDef) : baseDef);
|
|
293
310
|
}
|
|
294
311
|
}
|
|
295
312
|
|
|
296
|
-
/**
|
|
297
|
-
* Manager class encapsulating rounded tools registration and lifecycle updates.
|
|
298
|
-
*
|
|
299
|
-
* In addition to wrapping built-in tools, it installs a `pi.registerTool` proxy
|
|
300
|
-
* so that any tool registered by third-party extensions (e.g. pi-fff's ffgrep,
|
|
301
|
-
* fffind, fff-multi-grep, or overridden grep/find) automatically receives rounded
|
|
302
|
-
* frames when `roundedTools` is enabled, without maintaining a fragile hardcoded
|
|
303
|
-
* name whitelist.
|
|
304
|
-
*
|
|
305
|
-
* Load-order note: For extensions that register tools at load time, place
|
|
306
|
-
* `pi-tui-footer` before those packages in `~/.pi/agent/settings.json` so the
|
|
307
|
-
* proxy is active when they load.
|
|
308
|
-
*/
|
|
309
313
|
export class RoundedToolsManager {
|
|
310
314
|
private enabled = false;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
private originalRegisterTool!: (tool: ToolDef) => void;
|
|
314
|
-
/** Original (unwrapped) definitions captured from third-party tools. */
|
|
315
|
-
private readonly captured = new Map<string, ToolDef>();
|
|
316
|
-
|
|
317
|
-
constructor(private readonly pi: ExtensionAPI) {
|
|
318
|
-
this.installProxy();
|
|
319
|
-
}
|
|
315
|
+
|
|
316
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
320
317
|
|
|
321
318
|
/** Register initial tools before session_start with fallback cwd. */
|
|
322
319
|
init(enabled: boolean, cwd = process.cwd()): void {
|
|
323
320
|
this.enabled = enabled;
|
|
324
|
-
this.
|
|
325
|
-
registerRoundedTools(this.originalRegisterTool, enabled, cwd, this.captured);
|
|
326
|
-
this.reRegisterCaptured();
|
|
321
|
+
registerRoundedTools(this.pi, enabled, cwd);
|
|
327
322
|
}
|
|
328
323
|
|
|
329
324
|
/** Apply tools with current session cwd. */
|
|
330
325
|
apply(enabled: boolean, cwd: string): void {
|
|
331
326
|
this.enabled = enabled;
|
|
332
|
-
this.
|
|
333
|
-
registerRoundedTools(this.originalRegisterTool, enabled, cwd, this.captured);
|
|
334
|
-
this.reRegisterCaptured();
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Install a one-time proxy on `pi.registerTool` to intercept third-party
|
|
339
|
-
* tool registrations.
|
|
340
|
-
*/
|
|
341
|
-
private installProxy(): void {
|
|
342
|
-
if (this.proxyInstalled) return;
|
|
343
|
-
this.proxyInstalled = true;
|
|
344
|
-
|
|
345
|
-
this.originalRegisterTool = this.pi.registerTool.bind(this.pi);
|
|
346
|
-
const original = this.originalRegisterTool;
|
|
347
|
-
const self = this;
|
|
348
|
-
|
|
349
|
-
this.pi.registerTool = function proxyRegisterTool(tool: ToolDef) {
|
|
350
|
-
// Don't intercept tools that already render their own shell
|
|
351
|
-
if (tool.renderShell === "self") {
|
|
352
|
-
original(tool);
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Capture the unwrapped original definition
|
|
357
|
-
self.captured.set(tool.name, tool);
|
|
358
|
-
original(self.enabled ? wrapBuiltin(tool) : tool);
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Re-register all captured third-party tools with or without rounded
|
|
364
|
-
* frames based on the current `enabled` state.
|
|
365
|
-
*/
|
|
366
|
-
private reRegisterCaptured(): void {
|
|
367
|
-
for (const def of this.captured.values()) {
|
|
368
|
-
this.originalRegisterTool(this.enabled ? wrapBuiltin(def) : def);
|
|
369
|
-
}
|
|
327
|
+
registerRoundedTools(this.pi, enabled, cwd);
|
|
370
328
|
}
|
|
371
329
|
}
|
|
372
|
-
|