pi-tui-footer 1.2.6 → 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/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
  本项目是以下两个开源项目的深度融合集成:
@@ -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";
@@ -31,28 +22,58 @@ import {
31
22
  createFindToolDefinition,
32
23
  createGrepToolDefinition,
33
24
  createLsToolDefinition,
25
+ createPowerShellToolDefinition,
34
26
  createReadToolDefinition,
35
27
  createWriteToolDefinition,
36
28
  } from "@earendil-works/pi-coding-agent";
37
- import type { Component } from "@earendil-works/pi-tui";
29
+ import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
38
30
  import { visibleWidth } from "@earendil-works/pi-tui";
39
31
 
40
32
  // ─── Rounded-corner frame ────────────────────────────────────────────────
41
33
  //
42
34
  // Three modes let call + result stack into one continuous frame:
43
- // - "closed" → ╭ ── ╮ / │ x │ / ╰ ── ╯ (standalone box)
35
+ // - "closed" → ╭ ── ╮ / │ x │ / ╰ ── ╯ (standalone box / pending call)
44
36
  // - "open-bottom" → ╭ ── ╮ / │ x │ (top of a stacked frame)
45
37
  // - "open-top" → │ x │ / ╰ ── ╯ (bottom of a stacked frame)
46
38
 
47
- 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;
48
45
 
49
- class RoundedFrame implements Component {
50
46
  constructor(
51
- private readonly inner: Component,
52
- private readonly border: (text: string) => string,
53
- private readonly mode: FrameMode = "closed",
47
+ private inner: Component,
48
+ private border: (text: string) => string,
49
+ private mode: FrameMode = "closed",
54
50
  ) {}
55
51
 
52
+ getInner(): Component {
53
+ return this.inner;
54
+ }
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
+
56
77
  invalidate(): void {
57
78
  this.inner.invalidate?.();
58
79
  this.cachedWidth = undefined;
@@ -60,17 +81,32 @@ class RoundedFrame implements Component {
60
81
  this.cachedLines = undefined;
61
82
  }
62
83
 
63
- // pi's built-in tool renderers cache the previously-returned component
64
- // via `context.lastComponent` and call mutating methods on it
65
- // (`Text.setText`, `Container.clear`/`addChild`, `invalidate`) so they
66
- // can re-render in place during streaming. Once we wrap the inner in a
67
- // RoundedFrame, pi hands our wrapper back to the inner renderer on the
68
- // next render — but pi's try/catch around `callRenderer(...)` falls
69
- // back to a plain `createCallFallback()` text node if the inner
70
- // renderer throws, which would make the border vanish on every
71
- // Ctrl+O toggle. Forward these methods to the inner so the original
72
- // renderers work unchanged; calls on a wrapper whose inner lacks the
73
- // method are no-ops thanks to optional chaining.
84
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
85
+ if (!this.inner.handleMouse) return undefined;
86
+
87
+ const width = this.cachedWidth ?? event.width;
88
+ if (width < 4) {
89
+ return this.inner.handleMouse(event);
90
+ }
91
+
92
+ const innerWidth = Math.max(1, width - 4);
93
+ const paddingTop = this.mode !== "open-top" ? 1 : 0;
94
+ const contentX = event.x - 2;
95
+ const contentY = event.y - paddingTop;
96
+ const innerHeight = this.cachedInnerLines?.length ?? 0;
97
+
98
+ if (contentX < 0 || contentX >= innerWidth || contentY < 0 || contentY >= innerHeight) {
99
+ return undefined;
100
+ }
101
+
102
+ return this.inner.handleMouse({
103
+ ...event,
104
+ x: contentX,
105
+ y: contentY,
106
+ width: innerWidth,
107
+ });
108
+ }
109
+
74
110
  setText(text: string): void {
75
111
  (this.inner as any).setText?.(text);
76
112
  }
@@ -91,21 +127,6 @@ class RoundedFrame implements Component {
91
127
  const innerWidth = Math.max(1, width - 4); // │ + space + content + space + │
92
128
  const innerLines = this.inner.render(innerWidth);
93
129
 
94
- // pi's TUI re-renders every transcript component on every render
95
- // cycle (each keystroke), but built-in components below us (e.g.
96
- // `Text`, `Box`) cache their rendered line arrays and return the
97
- // same array reference when content and width haven't changed.
98
- // Without a cache here, every keystroke re-runs `visibleWidth()`
99
- // (ANSI parsing) and string building for every line of every
100
- // framed tool entry — O(N_session) per keypress, visible as input
101
- // lag on long sessions.
102
- //
103
- // Cache key: `width` plus the array reference returned by
104
- // `inner.render()`. Built-in Text/Box return the same reference on
105
- // cache hits and a fresh array whenever their content changes
106
- // (e.g. Ctrl+O expand), so reference equality is a reliable change
107
- // signal. `invalidate()` (theme changes etc.) clears the cache
108
- // explicitly, and so does an actual `width` change.
109
130
  if (
110
131
  this.cachedLines &&
111
132
  this.cachedWidth === width &&
@@ -130,10 +151,6 @@ class RoundedFrame implements Component {
130
151
  out.push(side + " " + line + pad + " " + side);
131
152
  }
132
153
  } else if (this.mode === "closed") {
133
- // Standalone closed frame with no content: draw a placeholder row
134
- // so the box still has visual presence. Skipped for open-top /
135
- // open-bottom since they pair with another half-frame and a fake
136
- // empty row would show up as a stray blank line in the middle.
137
154
  out.push(side + " ".repeat(width - 2) + side);
138
155
  }
139
156
 
@@ -145,10 +162,6 @@ class RoundedFrame implements Component {
145
162
  this.cachedLines = out;
146
163
  return out;
147
164
  }
148
-
149
- private cachedWidth: number | undefined;
150
- private cachedInnerLines: string[] | undefined;
151
- private cachedLines: string[] | undefined;
152
165
  }
153
166
 
154
167
  const frame = (
@@ -156,29 +169,13 @@ const frame = (
156
169
  theme: { fg: (color: string, text: string) => string },
157
170
  mode: FrameMode = "closed",
158
171
  colorKey: string = "border",
159
- ): Component => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode);
172
+ ): RoundedFrame => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode);
160
173
 
161
- /**
162
- * Pick a border color key based on the tool's runtime state.
163
- *
164
- * - still running (`context.isPartial`) → warning (yellow)
165
- * - finished with an error (`context.isError`) → error (red)
166
- * - finished successfully → border (theme default)
167
- *
168
- * Both `renderCall` and `renderResult` go through this picker, so when
169
- * they stack into one continuous frame, the borders match. pi re-renders
170
- * the whole component on every state transition (args streaming → done,
171
- * partial → final), and `context.isPartial` flips accordingly, so call
172
- * and result always pick the same color at any given moment.
173
- *
174
- * Mirrors pi's own 3-state scheme (`toolPendingBg` / `toolSuccessBg` /
175
- * `toolErrorBg`) — see `theme.js`.
176
- */
177
174
  function borderColorFor(
178
175
  context: { isPartial?: boolean; isError?: boolean } | undefined,
179
176
  ): string {
180
- if (context?.isPartial) return "warning";
181
177
  if (context?.isError) return "error";
178
+ if (context?.isPartial) return "warning";
182
179
  return "border";
183
180
  }
184
181
 
@@ -188,82 +185,145 @@ type ToolDef = ToolDefinition<any, any, any>;
188
185
 
189
186
  const EMPTY_COMPONENT: Component = { render: () => [], invalidate: () => {} };
190
187
 
191
- /**
192
- * Wrap a built-in tool definition in rounded frames.
193
- *
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.
199
- */
200
- function wrapBuiltin(def: ToolDef) {
188
+ function unwrapInner(component: unknown): Component | undefined {
189
+ if (component instanceof RoundedFrame) {
190
+ return component.getInner();
191
+ }
192
+ return component as Component | undefined;
193
+ }
194
+
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
+
201
201
  return {
202
202
  ...def,
203
203
  renderShell: "self" as const,
204
204
  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.
205
+ const unwrapped = unwrapInner(context?.lastComponent);
212
206
  const inner: Component = def.renderCall
213
- ? def.renderCall(args, theme, { ...context, lastComponent: undefined })
207
+ ? def.renderCall(args, theme, { ...context, lastComponent: unwrapped })
214
208
  : 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));
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;
220
227
  },
221
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
+
238
+ const unwrapped = unwrapInner(context?.lastComponent);
222
239
  const inner: Component = def.renderResult
223
- ? def.renderResult(result, options, theme, { ...context, lastComponent: undefined })
240
+ ? def.renderResult(result, options, theme, { ...context, lastComponent: unwrapped })
224
241
  : EMPTY_COMPONENT;
225
- if (context?.isPartial) return inner;
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
+ }
226
247
  return frame(inner, theme, "open-top", borderColorFor(context));
227
248
  },
228
249
  };
229
250
  }
230
251
 
231
- /**
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.
236
- */
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);
252
+ export function registerRoundedTools(
253
+ target: ExtensionAPI | ((tool: ToolDef) => void),
254
+ enabled: boolean,
255
+ cwd: string,
256
+ _captured?: Map<string, ToolDef>,
257
+ ): void {
258
+ const register = typeof target === "function" ? target : target.registerTool.bind(target);
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
+
285
+ const builtins: Record<string, () => ToolDef> = {
286
+ read: () => createReadToolDefinition(cwd, { autoResizeImages }),
287
+ write: () => createWriteToolDefinition(cwd),
288
+ edit: () => createEditToolDefinition(cwd),
289
+ bash: () => createBashToolDefinition(cwd, { commandPrefix: shellCommandPrefix, shellPath }),
290
+ powershell: () => createPowerShellToolDefinition(cwd, { shellPath }),
291
+ ls: () => createLsToolDefinition(cwd),
292
+ grep: () => createGrepToolDefinition(cwd),
293
+ find: () => createFindToolDefinition(cwd),
294
+ };
295
+
296
+ for (const [name, factory] of Object.entries(builtins)) {
297
+ const rawDef = factory();
298
+ // Skip tools that already manage their own shell framing (e.g. edit)
299
+ if (rawDef.renderShell === "self") {
300
+ continue;
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);
250
310
  }
251
311
  }
252
312
 
253
- /**
254
- * Manager class encapsulating rounded tools registration and lifecycle updates.
255
- */
256
313
  export class RoundedToolsManager {
314
+ private enabled = false;
315
+
257
316
  constructor(private readonly pi: ExtensionAPI) {}
258
317
 
259
318
  /** Register initial tools before session_start with fallback cwd. */
260
319
  init(enabled: boolean, cwd = process.cwd()): void {
320
+ this.enabled = enabled;
261
321
  registerRoundedTools(this.pi, enabled, cwd);
262
322
  }
263
323
 
264
324
  /** Apply tools with current session cwd. */
265
325
  apply(enabled: boolean, cwd: string): void {
326
+ this.enabled = enabled;
266
327
  registerRoundedTools(this.pi, enabled, cwd);
267
328
  }
268
329
  }
269
-
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.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": [