pi-tui-footer 1.2.6 → 1.2.7

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/README.md CHANGED
@@ -43,6 +43,21 @@ pi update --extensions
43
43
 
44
44
  也可在 pi 内通过 `/pi-tui` 命令调整。
45
45
 
46
+ ### 圆角工具框与第三方工具
47
+
48
+ `roundedTools` 开启时,除 8 个内置工具(read、write、edit、bash、powershell、grep、find、ls)外,也会自动拦截并为第三方扩展注册的工具(如 pi-fff 的 `ffgrep`、`fffind`、`fff-multi-grep`,或其 override 模式接管的 `grep`/`find`)添加圆角边框。
49
+
50
+ **加载顺序**:需要在 `~/.pi/agent/settings.json` 的 `packages` 中将 `pi-tui-footer` 排在第三方扩展(如 `@ff-labs/pi-fff`)**之前**,以确保 proxy 在工具注册前生效:
51
+
52
+ ```json
53
+ {
54
+ "packages": [
55
+ "npm:pi-tui-footer",
56
+ "npm:@ff-labs/pi-fff"
57
+ ]
58
+ }
59
+ ```
60
+
46
61
  ## 致谢
47
62
 
48
63
  本项目是以下两个开源项目的深度融合集成:
@@ -31,10 +31,11 @@ import {
31
31
  createFindToolDefinition,
32
32
  createGrepToolDefinition,
33
33
  createLsToolDefinition,
34
+ createPowerShellToolDefinition,
34
35
  createReadToolDefinition,
35
36
  createWriteToolDefinition,
36
37
  } from "@earendil-works/pi-coding-agent";
37
- import type { Component } from "@earendil-works/pi-tui";
38
+ import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
38
39
  import { visibleWidth } from "@earendil-works/pi-tui";
39
40
 
40
41
  // ─── Rounded-corner frame ────────────────────────────────────────────────
@@ -53,6 +54,10 @@ class RoundedFrame implements Component {
53
54
  private readonly mode: FrameMode = "closed",
54
55
  ) {}
55
56
 
57
+ getInner(): Component {
58
+ return this.inner;
59
+ }
60
+
56
61
  invalidate(): void {
57
62
  this.inner.invalidate?.();
58
63
  this.cachedWidth = undefined;
@@ -60,6 +65,32 @@ class RoundedFrame implements Component {
60
65
  this.cachedLines = undefined;
61
66
  }
62
67
 
68
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
69
+ if (!this.inner.handleMouse) return undefined;
70
+
71
+ const width = this.cachedWidth ?? event.width;
72
+ if (width < 4) {
73
+ return this.inner.handleMouse(event);
74
+ }
75
+
76
+ const innerWidth = Math.max(1, width - 4);
77
+ const paddingTop = this.mode !== "open-top" ? 1 : 0;
78
+ const contentX = event.x - 2;
79
+ const contentY = event.y - paddingTop;
80
+ const innerHeight = this.cachedInnerLines?.length ?? 0;
81
+
82
+ if (contentX < 0 || contentX >= innerWidth || contentY < 0 || contentY >= innerHeight) {
83
+ return undefined;
84
+ }
85
+
86
+ return this.inner.handleMouse({
87
+ ...event,
88
+ x: contentX,
89
+ y: contentY,
90
+ width: innerWidth,
91
+ });
92
+ }
93
+
63
94
  // pi's built-in tool renderers cache the previously-returned component
64
95
  // via `context.lastComponent` and call mutating methods on it
65
96
  // (`Text.setText`, `Container.clear`/`addChild`, `invalidate`) so they
@@ -177,8 +208,8 @@ const frame = (
177
208
  function borderColorFor(
178
209
  context: { isPartial?: boolean; isError?: boolean } | undefined,
179
210
  ): string {
180
- if (context?.isPartial) return "warning";
181
211
  if (context?.isError) return "error";
212
+ if (context?.isPartial) return "warning";
182
213
  return "border";
183
214
  }
184
215
 
@@ -189,81 +220,153 @@ type ToolDef = ToolDefinition<any, any, any>;
189
220
  const EMPTY_COMPONENT: Component = { render: () => [], invalidate: () => {} };
190
221
 
191
222
  /**
192
- * Wrap a built-in tool definition in rounded frames.
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
+ function unwrapInner(component: unknown): Component | undefined {
228
+ if (component instanceof RoundedFrame) {
229
+ return component.getInner();
230
+ }
231
+ return component as Component | undefined;
232
+ }
233
+
234
+ /**
235
+ * Wrap a tool definition in rounded frames.
193
236
  *
194
- * We spread the original definition first so we preserve every metadata
195
- * field (promptSnippet / promptGuidelines / prepareArguments /
196
- * constrainedSampling / executionMode / …) — only the renderers and
197
- * `renderShell` are overwritten. This keeps the tool's LLM-facing prompt
198
- * data intact, edit's argument pre-processing intact, etc.
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.
199
240
  */
200
241
  function wrapBuiltin(def: ToolDef) {
242
+ const callMode: FrameMode = def.renderResult ? "open-bottom" : "closed";
201
243
  return {
202
244
  ...def,
203
245
  renderShell: "self" as const,
204
246
  renderCall: (args: any, theme: any, context: any) => {
205
- // Hide our (or any) previous wrapper from the inner renderer so it
206
- // doesn't treat a RoundedFrame as its own lastComponent — that
207
- // would either throw (`Text.setText` is missing) or nest frames
208
- // (`inner = previous RoundedFrame` → we re-wrap it). Forcing the
209
- // inner to allocate fresh costs one Text/Container per render but
210
- // keeps every render independent and CC-protect against future
211
- // tool changes.
247
+ const unwrapped = unwrapInner(context?.lastComponent);
212
248
  const inner: Component = def.renderCall
213
- ? def.renderCall(args, theme, { ...context, lastComponent: undefined })
249
+ ? def.renderCall(args, theme, { ...context, lastComponent: unwrapped })
214
250
  : EMPTY_COMPONENT;
215
- // During execution pi re-renders every tick (e.g. bash's elapsed
216
- // timer). Skip the frame then — keeping its borders stable avoids
217
- // redraw flicker — and only wrap it once the tool settles.
218
- if (context?.isPartial) return inner;
219
- return frame(inner, theme, "open-bottom", borderColorFor(context));
251
+ return frame(inner, theme, callMode, borderColorFor(context));
220
252
  },
221
253
  renderResult: (result: any, options: any, theme: any, context: any) => {
254
+ const unwrapped = unwrapInner(context?.lastComponent);
222
255
  const inner: Component = def.renderResult
223
- ? def.renderResult(result, options, theme, { ...context, lastComponent: undefined })
256
+ ? def.renderResult(result, options, theme, { ...context, lastComponent: unwrapped })
224
257
  : EMPTY_COMPONENT;
225
- if (context?.isPartial) return inner;
226
258
  return frame(inner, theme, "open-top", borderColorFor(context));
227
259
  },
228
260
  };
229
261
  }
230
262
 
231
263
  /**
232
- * Register the 7 built-in file/bash tools, either with rounded frames
233
- * (`enabled`) or with their plain stock definitions (`!enabled`, restores
234
- * original rendering). Registration refreshes the active tool set in the
235
- * current session, so toggling the setting takes effect immediately.
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.
236
269
  */
237
- export function registerRoundedTools(pi: ExtensionAPI, enabled: boolean, cwd: string): void {
238
- const tools: ToolDef[] = [
239
- createReadToolDefinition(cwd),
240
- createWriteToolDefinition(cwd),
241
- createEditToolDefinition(cwd),
242
- createBashToolDefinition(cwd),
243
- createGrepToolDefinition(cwd),
244
- createFindToolDefinition(cwd),
245
- createLsToolDefinition(cwd),
246
- ];
247
-
248
- for (const def of tools) {
249
- pi.registerTool(enabled ? wrapBuiltin(def) : def);
270
+ export function registerRoundedTools(
271
+ target: ExtensionAPI | ((tool: ToolDef) => void),
272
+ enabled: boolean,
273
+ cwd: string,
274
+ captured?: Map<string, ToolDef>,
275
+ ): void {
276
+ const register = typeof target === "function" ? target : target.registerTool.bind(target);
277
+
278
+ const builtins: Record<string, () => ToolDef> = {
279
+ read: () => createReadToolDefinition(cwd),
280
+ write: () => createWriteToolDefinition(cwd),
281
+ edit: () => createEditToolDefinition(cwd),
282
+ bash: () => createBashToolDefinition(cwd),
283
+ powershell: () => createPowerShellToolDefinition(cwd),
284
+ ls: () => createLsToolDefinition(cwd),
285
+ grep: () => createGrepToolDefinition(cwd),
286
+ find: () => createFindToolDefinition(cwd),
287
+ };
288
+
289
+ for (const [name, factory] of Object.entries(builtins)) {
290
+ if (!captured?.has(name)) {
291
+ register(enabled ? wrapBuiltin(factory()) : factory());
292
+ }
250
293
  }
251
294
  }
252
295
 
253
296
  /**
254
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.
255
308
  */
256
309
  export class RoundedToolsManager {
257
- constructor(private readonly pi: ExtensionAPI) {}
310
+ 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
+ }
258
320
 
259
321
  /** Register initial tools before session_start with fallback cwd. */
260
322
  init(enabled: boolean, cwd = process.cwd()): void {
261
- registerRoundedTools(this.pi, enabled, cwd);
323
+ this.enabled = enabled;
324
+ this.installProxy();
325
+ registerRoundedTools(this.originalRegisterTool, enabled, cwd, this.captured);
326
+ this.reRegisterCaptured();
262
327
  }
263
328
 
264
329
  /** Apply tools with current session cwd. */
265
330
  apply(enabled: boolean, cwd: string): void {
266
- registerRoundedTools(this.pi, enabled, cwd);
331
+ 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
+ }
267
370
  }
268
371
  }
269
372
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tui-footer",
3
- "version": "1.2.6",
3
+ "version": "1.2.7",
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": [