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.
@@ -1,27 +1,18 @@
1
1
  /**
2
- * Rounded tool frames (merged port) — rounded frames for tool calls and results.
2
+ * Rounded tool frames — rounded frames for tool calls and results.
3
3
  *
4
- * Merged 2026-08 from the standalone local extension rounded-tools.ts (a
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
- * No left color bar, no theme matching — just the corners.
16
- * Border color follows `theme.fg("border", …)` so it adapts to your theme.
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
- * Implementation note: we don't reimplement the inner rendering — we just
19
- * call the built-in `renderCall` / `renderResult` and wrap the returned
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
- * Disabling registers the plain built-in definitions again, restoring stock
24
- * rendering in the current session without a /reload.
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 readonly inner: Component,
53
- private readonly border: (text: string) => string,
54
- private readonly mode: FrameMode = "closed",
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
- ): Component => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode);
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
- * Wrap a tool definition in rounded frames.
236
- *
237
- * Preserves all metadata (promptSnippet, promptGuidelines, parameters, etc.)
238
- * while wrapping renderCall and renderResult with rounded borders.
239
- * Uses closed mode for tools without renderResult to avoid open bottom borders.
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
- return frame(inner, theme, callMode, borderColorFor(context));
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
- captured?: Map<string, ToolDef>,
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
- if (!captured?.has(name)) {
291
- register(enabled ? wrapBuiltin(factory()) : factory());
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
- private proxyInstalled = false;
312
- /** The real `pi.registerTool` before we installed the proxy. */
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.installProxy();
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.installProxy();
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
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tui-footer",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "pi extension: TUI footer/header with git status, telemetry, rounded tools, editor and settings enhancements",
5
5
  "type": "module",
6
6
  "keywords": [