pi-tui-footer 1.2.9 → 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` 会为 Pi 内置工具(read、write、edit、bash、powershell、grep、find、ls)添加圆角边框。第三方扩展注册的自定义工具不会自动添加圆角边框。
48
+ 需要 pi >= 1.0.4(依赖 `pi.registerToolRenderer()`)。旧版本不会报错,但圆角工具框不生效。
49
49
 
50
- 若第三方扩展会覆盖同名内置工具,请在 `~/.pi/agent/settings.json` 的 `packages` 中将 `pi-tui-footer` 放在该扩展之后。Pi 按扩展加载顺序处理同名工具,先注册的定义生效;此顺序可保留第三方工具的执行实现:
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;
@@ -142,9 +143,8 @@ export class RoundedFrame implements Component {
142
143
  return this.cachedLines;
143
144
  }
144
145
 
145
- const horizontal = "─".repeat(Math.max(0, width - 2));
146
+ const horizontal = "─".repeat(width - 2);
146
147
  const side = this.border("│");
147
-
148
148
  const out: string[] = [];
149
149
 
150
150
  if (this.mode !== "open-top") {
@@ -153,8 +153,7 @@ export class RoundedFrame implements Component {
153
153
 
154
154
  if (innerLines.length > 0) {
155
155
  for (const line of innerLines) {
156
- const vis = visibleWidth(line);
157
- const pad = " ".repeat(Math.max(0, innerWidth - vis));
156
+ const pad = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
158
157
  out.push(side + " " + line + pad + " " + side);
159
158
  }
160
159
  } else if (this.mode === "closed") {
@@ -164,6 +163,7 @@ export class RoundedFrame implements Component {
164
163
  if (this.mode !== "open-bottom") {
165
164
  out.push(this.border("╰" + horizontal + "╯"));
166
165
  }
166
+
167
167
  this.cachedWidth = width;
168
168
  this.cachedInnerLines = innerLines;
169
169
  this.cachedLines = out;
@@ -171,35 +171,38 @@ export class RoundedFrame implements Component {
171
171
  }
172
172
  }
173
173
 
174
- const frame = (
174
+ // ─── Frame factories ────────────────────────────────────────────────────
175
+
176
+ const createFrame = (
175
177
  inner: Component,
176
178
  theme: { fg: (color: string, text: string) => string },
177
179
  mode: FrameMode = "closed",
178
180
  colorKey: string = "border",
179
181
  ): RoundedFrame => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode, colorKey);
180
182
 
181
- function borderColorFor(
182
- context: { isPartial?: boolean; isError?: boolean } | undefined,
183
- ): string {
184
- if (context?.isError) return "error";
185
- if (context?.isPartial) return "warning";
186
- return "border";
187
- }
188
-
189
- // ─── Helpers ─────────────────────────────────────────────────────────────
190
-
191
- type ToolDef = ToolDefinition<any, any, any>;
192
-
193
183
  const EMPTY_COMPONENT: Component = { render: () => [], invalidate: () => {} };
194
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
+
195
201
  function unwrapInner(component: unknown): Component | undefined {
196
- if (component instanceof RoundedFrame) {
197
- return component.getInner();
198
- }
199
- return component as Component | undefined;
202
+ return component instanceof RoundedFrame ? component.getInner() : (component as Component | undefined);
200
203
  }
201
204
 
202
- export function wrapBuiltin(def: ToolDef): ToolDef {
205
+ export function wrapRenderers(def: ToolRenderers): ToolRenderers {
203
206
  // Tools that already manage their own shell (e.g. edit) should remain untouched.
204
207
  if (def.renderShell === "self") {
205
208
  return def;
@@ -218,13 +221,14 @@ export function wrapBuiltin(def: ToolDef): ToolDef {
218
221
  // close the bottom border so it renders as a complete box instead of an open frame.
219
222
  const isPendingWithoutResult = Boolean(context?.isPartial && !context?.state?.__hasResult);
220
223
  const mode: FrameMode = def.renderResult && !isPendingWithoutResult ? "open-bottom" : "closed";
221
- const borderFn = (t: string) => theme.fg(borderColorFor(context), t);
224
+ const colorKey = borderColorFor(context);
225
+ const borderFn = (t: string) => theme.fg(colorKey, t);
222
226
 
223
227
  let callFrame: RoundedFrame;
224
228
  if (context?.lastComponent instanceof RoundedFrame) {
225
- callFrame = context.lastComponent.update(inner, borderFn, mode, borderColorFor(context));
229
+ callFrame = context.lastComponent.update(inner, borderFn, mode, colorKey);
226
230
  } else {
227
- callFrame = frame(inner, theme, mode, borderColorFor(context));
231
+ callFrame = createFrame(inner, theme, mode, colorKey);
228
232
  }
229
233
 
230
234
  if (context?.state) {
@@ -246,91 +250,37 @@ export function wrapBuiltin(def: ToolDef): ToolDef {
246
250
  const inner: Component = def.renderResult
247
251
  ? def.renderResult(result, options, theme, { ...context, lastComponent: unwrapped })
248
252
  : EMPTY_COMPONENT;
249
- const borderFn = (t: string) => theme.fg(borderColorFor(context), t);
253
+ const colorKey = borderColorFor(context);
254
+ const borderFn = (t: string) => theme.fg(colorKey, t);
250
255
 
251
256
  if (context?.lastComponent instanceof RoundedFrame) {
252
- return context.lastComponent.update(inner, borderFn, "open-top", borderColorFor(context));
257
+ return context.lastComponent.update(inner, borderFn, "open-top", colorKey);
253
258
  }
254
- return frame(inner, theme, "open-top", borderColorFor(context));
259
+ return createFrame(inner, theme, "open-top", colorKey);
255
260
  },
256
261
  };
257
262
  }
258
263
 
259
- export function registerRoundedTools(
260
- target: ExtensionAPI | ((tool: ToolDef) => void),
261
- enabled: boolean,
262
- cwd: string,
263
- _captured?: Map<string, ToolDef>,
264
- ): void {
265
- const register = typeof target === "function" ? target : target.registerTool.bind(target);
266
-
267
- let settings: any;
268
- if (typeof target !== "function" && target.getSettings) {
269
- try {
270
- settings = target.getSettings();
271
- } catch {
272
- // runtime not initialized yet during early load
273
- }
274
- }
275
- const autoResizeImages = settings?.images?.autoResize ?? true;
276
- const shellCommandPrefix = settings?.shellCommandPrefix;
277
- const shellPath = settings?.shellPath;
278
-
279
- let activeToolNames: Set<string> | undefined;
280
- if (typeof target !== "function" && target.getActiveTools) {
281
- try {
282
- const active = target.getActiveTools();
283
- if (Array.isArray(active)) {
284
- activeToolNames = new Set(active);
285
- }
286
- } catch {
287
- // runtime not initialized yet during early load
288
- }
289
- }
290
- const defaultActiveNames = activeToolNames ?? new Set(["read", "bash", "edit", "write"]);
291
-
292
- const builtins: Record<string, () => ToolDef> = {
293
- read: () => createReadToolDefinition(cwd, { autoResizeImages }),
294
- write: () => createWriteToolDefinition(cwd),
295
- edit: () => createEditToolDefinition(cwd),
296
- bash: () => createBashToolDefinition(cwd, { commandPrefix: shellCommandPrefix, shellPath }),
297
- powershell: () => createPowerShellToolDefinition(cwd),
298
- ls: () => createLsToolDefinition(cwd),
299
- grep: () => createGrepToolDefinition(cwd),
300
- find: () => createFindToolDefinition(cwd),
301
- };
302
-
303
- for (const [name, factory] of Object.entries(builtins)) {
304
- const rawDef = factory();
305
- // Skip tools that already manage their own shell framing (e.g. edit)
306
- if (rawDef.renderShell === "self") {
307
- continue;
308
- }
309
-
310
- const shouldBeActive = defaultActiveNames.has(name);
311
- const baseDef: ToolDef = {
312
- ...rawDef,
313
- defaultActive: shouldBeActive,
314
- };
315
-
316
- register(enabled ? wrapBuiltin(baseDef) : baseDef);
317
- }
318
- }
264
+ // ─── Manager ─────────────────────────────────────────────────────────────
319
265
 
320
266
  export class RoundedToolsManager {
321
267
  private enabled = false;
322
268
 
323
- constructor(private readonly pi: ExtensionAPI) {}
324
-
325
- /** Register initial tools before session_start with fallback cwd. */
326
- init(enabled: boolean, cwd = process.cwd()): void {
327
- this.enabled = enabled;
328
- 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
+ });
329
281
  }
330
282
 
331
- /** Apply tools with current session cwd. */
332
- apply(enabled: boolean, cwd: string): void {
283
+ setEnabled(enabled: boolean): void {
333
284
  this.enabled = enabled;
334
- registerRoundedTools(this.pi, enabled, cwd);
335
285
  }
336
286
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tui-footer",
3
- "version": "1.2.9",
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": [