pi-tui-footer 1.2.8 → 1.3.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/README.md CHANGED
@@ -17,7 +17,7 @@ pi install git:github.com/onenora/pi-tui-footer
17
17
  固定版本:
18
18
 
19
19
  ```bash
20
- pi install git:github.com/onenora/pi-tui-footer@v1.2.4
20
+ pi install git:github.com/onenora/pi-tui-footer@v1.3.0
21
21
  ```
22
22
 
23
23
  更新:
@@ -43,24 +43,22 @@ pi update --extensions
43
43
 
44
44
  也可在 pi 内通过 `/pi-tui` 命令调整。
45
45
 
46
- ### 圆角工具框与第三方工具
46
+ ### 圆角工具框
47
47
 
48
- `roundedTools` 开启时,除 8 个内置工具(read、write、edit、bash、powershell、grep、find、ls)外,也会自动拦截并为第三方扩展注册的工具(如 pi-fff 的 `ffgrep`、`fffind`、`fff-multi-grep`,或其 override 模式接管的 `grep`/`find`)添加圆角边框。
48
+ 需要 pi >= 1.0.4(依赖 `pi.registerToolRenderer()`)。旧版本不会报错,但圆角工具框不生效。
49
49
 
50
- **加载顺序**:需要在 `~/.pi/agent/settings.json` 的 `packages` 中将 `pi-tui-footer` 排在第三方扩展(如 `@ff-labs/pi-fff`)**之前**,以确保 proxy 在工具注册前生效:
50
+ `roundedTools` 通过渲染器解析器包装工具调用/结果的显示,不重新注册工具,不改变工具定义、执行逻辑和激活状态。覆盖范围:
51
51
 
52
- ```json
53
- {
54
- "packages": [
55
- "npm:pi-tui-footer",
56
- "npm:@ff-labs/pi-fff"
57
- ]
58
- }
59
- ```
52
+ - 内置工具:read、write、bash、powershell、grep、find、ls(edit 自带外框,保持原样)
53
+ - pi-fff 工具:ffgrep、fffind、fff-multi-grep
54
+
55
+ 按工具名匹配,与扩展加载顺序无关。其他第三方工具不会自动添加圆角边框;需要时将工具名加入 `extensions/rounded-tools.ts` 的 `ROUNDED_TOOL_NAMES`。
56
+
57
+ 切换 `roundedTools` 后,新的工具调用立即生效,已渲染的历史记录在下次重建(`/reload`、恢复会话、压缩)时更新。
60
58
 
61
59
  ## 致谢
62
60
 
63
- 本项目是以下两个开源项目的深度融合集成:
61
+ 本项目can以下两个开源项目的集成:
64
62
 
65
63
  - [pi-open-tui](https://github.com/OldSuns/pi-open-tui) — 头部、底部、编辑器、遥测和设置界面的整体结构
66
64
  - [pi-rounded-tools](https://github.com/orionpax1997/pi-rounded-tools) — 圆角工具调用/结果框
@@ -224,21 +224,16 @@ export default function (pi: ExtensionAPI) {
224
224
  requestFooterRender?.();
225
225
  };
226
226
 
227
- const applyRoundedTools = (ctx: ExtensionContext) => {
228
- roundedTools.apply(config.enabled && config.roundedTools, ctx.cwd);
227
+ const applyRoundedTools = () => {
228
+ roundedTools.setEnabled(config.enabled && config.roundedTools);
229
229
  };
230
230
 
231
- // Register rounded tool renderers at load time (before session_start) so a
232
- // /reload transcript rebuild — which pi runs BEFORE emitting session_start —
233
- // constructs historical tool-call components with the wrapped renderers.
234
- // session_start re-registers with the real session cwd afterwards, which
235
- // corrects the execute closure for relative-path tools. Toggling the
236
- // setting re-registers for new components; already-rendered historical
237
- // components keep their current frames until the next transcript rebuild
238
- // (compaction / resume / tree navigation / thinking-block toggle).
239
- const initialConfig = loadConfig();
240
- config = initialConfig;
241
- roundedTools.init(initialConfig.enabled && initialConfig.roundedTools);
231
+ // The renderer resolver is installed when RoundedToolsManager is constructed
232
+ // (before session_start), so a /reload transcript rebuild already uses it.
233
+ // Toggling the setting affects components created afterwards; already-rendered
234
+ // historical components keep their frames until the next transcript rebuild.
235
+ config = loadConfig();
236
+ applyRoundedTools();
242
237
 
243
238
  const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
244
239
  if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
@@ -280,7 +275,7 @@ export default function (pi: ExtensionAPI) {
280
275
  clearPeekLabel(ctx);
281
276
 
282
277
  applyUi(ctx);
283
- applyRoundedTools(ctx);
278
+ applyRoundedTools();
284
279
 
285
280
  refreshInteractiveState(ctx, true);
286
281
  });
@@ -437,8 +432,8 @@ export default function (pi: ExtensionAPI) {
437
432
  if (lastCtx) {
438
433
  pendingUiChange = getPendingUiChange(newConfig.enabled, active);
439
434
  }
440
- if (lastCtx && (wasRoundedTools !== newConfig.roundedTools || wasEnabled !== newConfig.enabled)) {
441
- applyRoundedTools(lastCtx);
435
+ if (wasRoundedTools !== newConfig.roundedTools || wasEnabled !== newConfig.enabled) {
436
+ applyRoundedTools();
442
437
  }
443
438
  const gitNeeded = newConfig.footerSegments.gitBranch || newConfig.footerSegments.gitStatus || newConfig.footerSegments.gitCommit;
444
439
  if (lastCtx && gitNeeded) {
@@ -1,31 +1,20 @@
1
1
  /**
2
2
  * Rounded tool frames — rounded frames for tool calls and results.
3
3
  *
4
- * Re-registers built-in tools (read, write, bash, powershell, grep, find, ls)
5
- * with `renderShell: "self"` and wraps each tool call / result in a frame
6
- * drawn with Unicode rounded-corner characters (╭ ╮ ╰ ╯ ─ │).
4
+ * Uses `pi.registerToolRenderer()` to wrap the renderers of built-in tools
5
+ * (read, write, bash, powershell, grep, find, ls, plus pi-fff's ffgrep, fffind,
6
+ * fff-multi-grep) with `renderShell: "self"`
7
+ * and a frame drawn with Unicode rounded-corner characters (╭ ╮ ╰ ╯ ─ │).
8
+ * Tool definitions, execution and active-tool state are left untouched.
7
9
  *
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.
10
+ * Tools that already render their own shell (e.g. `edit`) are preserved as-is
11
+ * to avoid duplicate nesting and UI glitches.
10
12
  *
11
13
  * Border color follows `theme.fg("border", ...)` so it adapts to your theme,
12
14
  * using warning (yellow) during pending/streaming execution and error (red) on failure.
13
- *
14
- * Non-default tools (grep, find, ls, powershell) preserve `defaultActive: false`
15
- * to avoid polluting the model's active toolset.
16
15
  */
17
16
 
18
- import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
19
- import {
20
- createBashToolDefinition,
21
- createEditToolDefinition,
22
- createFindToolDefinition,
23
- createGrepToolDefinition,
24
- createLsToolDefinition,
25
- createPowerShellToolDefinition,
26
- createReadToolDefinition,
27
- createWriteToolDefinition,
28
- } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionAPI, ToolRenderers } from "@earendil-works/pi-coding-agent";
29
18
  import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
30
19
  import { visibleWidth } from "@earendil-works/pi-tui";
31
20
 
@@ -38,6 +27,18 @@ import { visibleWidth } from "@earendil-works/pi-tui";
38
27
 
39
28
  export type FrameMode = "closed" | "open-bottom" | "open-top";
40
29
 
30
+ // ─── Border color ────────────────────────────────────────────────────────
31
+
32
+ type RenderContext = { isPartial?: boolean; isError?: boolean };
33
+
34
+ function borderColorFor(context: RenderContext | undefined): string {
35
+ if (context?.isError) return "error";
36
+ if (context?.isPartial) return "warning";
37
+ return "border";
38
+ }
39
+
40
+ // ─── RoundedFrame component ─────────────────────────────────────────────
41
+
41
42
  export class RoundedFrame implements Component {
42
43
  private cachedWidth: number | undefined;
43
44
  private cachedInnerLines: string[] | undefined;
@@ -47,6 +48,7 @@ export class RoundedFrame implements Component {
47
48
  private inner: Component,
48
49
  private border: (text: string) => string,
49
50
  private mode: FrameMode = "closed",
51
+ private colorKey: string = "border",
50
52
  ) {}
51
53
 
52
54
  getInner(): Component {
@@ -64,10 +66,16 @@ export class RoundedFrame implements Component {
64
66
  }
65
67
  }
66
68
 
67
- update(inner: Component, border: (text: string) => string, mode: FrameMode): this {
68
- if (this.inner !== inner || this.mode !== mode) {
69
+ update(
70
+ inner: Component,
71
+ border: (text: string) => string,
72
+ mode: FrameMode,
73
+ colorKey: string,
74
+ ): this {
75
+ if (this.inner !== inner || this.mode !== mode || this.colorKey !== colorKey) {
69
76
  this.inner = inner;
70
77
  this.mode = mode;
78
+ this.colorKey = colorKey;
71
79
  this.invalidate();
72
80
  }
73
81
  this.border = border;
@@ -135,9 +143,8 @@ export class RoundedFrame implements Component {
135
143
  return this.cachedLines;
136
144
  }
137
145
 
138
- const horizontal = "─".repeat(Math.max(0, width - 2));
146
+ const horizontal = "─".repeat(width - 2);
139
147
  const side = this.border("│");
140
-
141
148
  const out: string[] = [];
142
149
 
143
150
  if (this.mode !== "open-top") {
@@ -146,8 +153,7 @@ export class RoundedFrame implements Component {
146
153
 
147
154
  if (innerLines.length > 0) {
148
155
  for (const line of innerLines) {
149
- const vis = visibleWidth(line);
150
- const pad = " ".repeat(Math.max(0, innerWidth - vis));
156
+ const pad = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
151
157
  out.push(side + " " + line + pad + " " + side);
152
158
  }
153
159
  } else if (this.mode === "closed") {
@@ -157,6 +163,7 @@ export class RoundedFrame implements Component {
157
163
  if (this.mode !== "open-bottom") {
158
164
  out.push(this.border("╰" + horizontal + "╯"));
159
165
  }
166
+
160
167
  this.cachedWidth = width;
161
168
  this.cachedInnerLines = innerLines;
162
169
  this.cachedLines = out;
@@ -164,35 +171,38 @@ export class RoundedFrame implements Component {
164
171
  }
165
172
  }
166
173
 
167
- const frame = (
174
+ // ─── Frame factories ────────────────────────────────────────────────────
175
+
176
+ const createFrame = (
168
177
  inner: Component,
169
178
  theme: { fg: (color: string, text: string) => string },
170
179
  mode: FrameMode = "closed",
171
180
  colorKey: string = "border",
172
- ): RoundedFrame => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode);
173
-
174
- function borderColorFor(
175
- context: { isPartial?: boolean; isError?: boolean } | undefined,
176
- ): string {
177
- if (context?.isError) return "error";
178
- if (context?.isPartial) return "warning";
179
- return "border";
180
- }
181
-
182
- // ─── Helpers ─────────────────────────────────────────────────────────────
183
-
184
- type ToolDef = ToolDefinition<any, any, any>;
181
+ ): RoundedFrame => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode, colorKey);
185
182
 
186
183
  const EMPTY_COMPONENT: Component = { render: () => [], invalidate: () => {} };
187
184
 
185
+ // ─── Tool wrapping ──────────────────────────────────────────────────────
186
+
187
+ const ROUNDED_TOOL_NAMES: ReadonlySet<string> = new Set([
188
+ "read",
189
+ "write",
190
+ "bash",
191
+ "powershell",
192
+ "grep",
193
+ "find",
194
+ "ls",
195
+ // pi-fff (@ff-labs/pi-fff) tools; resolvers match by name, so load order is irrelevant.
196
+ "ffgrep",
197
+ "fffind",
198
+ "fff-multi-grep",
199
+ ]);
200
+
188
201
  function unwrapInner(component: unknown): Component | undefined {
189
- if (component instanceof RoundedFrame) {
190
- return component.getInner();
191
- }
192
- return component as Component | undefined;
202
+ return component instanceof RoundedFrame ? component.getInner() : (component as Component | undefined);
193
203
  }
194
204
 
195
- export function wrapBuiltin(def: ToolDef): ToolDef {
205
+ export function wrapRenderers(def: ToolRenderers): ToolRenderers {
196
206
  // Tools that already manage their own shell (e.g. edit) should remain untouched.
197
207
  if (def.renderShell === "self") {
198
208
  return def;
@@ -211,13 +221,14 @@ export function wrapBuiltin(def: ToolDef): ToolDef {
211
221
  // close the bottom border so it renders as a complete box instead of an open frame.
212
222
  const isPendingWithoutResult = Boolean(context?.isPartial && !context?.state?.__hasResult);
213
223
  const mode: FrameMode = def.renderResult && !isPendingWithoutResult ? "open-bottom" : "closed";
214
- const borderFn = (t: string) => theme.fg(borderColorFor(context), t);
224
+ const colorKey = borderColorFor(context);
225
+ const borderFn = (t: string) => theme.fg(colorKey, t);
215
226
 
216
227
  let callFrame: RoundedFrame;
217
228
  if (context?.lastComponent instanceof RoundedFrame) {
218
- callFrame = context.lastComponent.update(inner, borderFn, mode);
229
+ callFrame = context.lastComponent.update(inner, borderFn, mode, colorKey);
219
230
  } else {
220
- callFrame = frame(inner, theme, mode, borderColorFor(context));
231
+ callFrame = createFrame(inner, theme, mode, colorKey);
221
232
  }
222
233
 
223
234
  if (context?.state) {
@@ -239,91 +250,37 @@ export function wrapBuiltin(def: ToolDef): ToolDef {
239
250
  const inner: Component = def.renderResult
240
251
  ? def.renderResult(result, options, theme, { ...context, lastComponent: unwrapped })
241
252
  : EMPTY_COMPONENT;
242
- const borderFn = (t: string) => theme.fg(borderColorFor(context), t);
253
+ const colorKey = borderColorFor(context);
254
+ const borderFn = (t: string) => theme.fg(colorKey, t);
243
255
 
244
256
  if (context?.lastComponent instanceof RoundedFrame) {
245
- return context.lastComponent.update(inner, borderFn, "open-top");
257
+ return context.lastComponent.update(inner, borderFn, "open-top", colorKey);
246
258
  }
247
- return frame(inner, theme, "open-top", borderColorFor(context));
259
+ return createFrame(inner, theme, "open-top", colorKey);
248
260
  },
249
261
  };
250
262
  }
251
263
 
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);
310
- }
311
- }
264
+ // ─── Manager ─────────────────────────────────────────────────────────────
312
265
 
313
266
  export class RoundedToolsManager {
314
267
  private enabled = false;
315
268
 
316
- constructor(private readonly pi: ExtensionAPI) {}
317
-
318
- /** Register initial tools before session_start with fallback cwd. */
319
- init(enabled: boolean, cwd = process.cwd()): void {
320
- this.enabled = enabled;
321
- registerRoundedTools(this.pi, enabled, cwd);
269
+ /**
270
+ * Install the renderer resolver once at extension load. Toggling `enabled`
271
+ * affects tool components created afterwards (resume / reload / new calls).
272
+ */
273
+ constructor(pi: ExtensionAPI) {
274
+ pi.registerToolRenderer?.((toolName, next) => {
275
+ const base = next();
276
+ if (!base || !this.enabled || !ROUNDED_TOOL_NAMES.has(toolName)) return base;
277
+ // Nothing to frame: let pi use its own fallback rendering.
278
+ if (!base.renderCall && !base.renderResult) return base;
279
+ return wrapRenderers(base);
280
+ });
322
281
  }
323
282
 
324
- /** Apply tools with current session cwd. */
325
- apply(enabled: boolean, cwd: string): void {
283
+ setEnabled(enabled: boolean): void {
326
284
  this.enabled = enabled;
327
- registerRoundedTools(this.pi, enabled, cwd);
328
285
  }
329
286
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tui-footer",
3
- "version": "1.2.8",
3
+ "version": "1.3.0",
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": [