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 +15 -0
- package/extensions/rounded-tools.ts +184 -124
- package/package.json +1 -1
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
|
|
2
|
+
* Rounded tool frames — rounded frames for tool calls and results.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
|
52
|
-
private
|
|
53
|
-
private
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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:
|
|
207
|
+
? def.renderCall(args, theme, { ...context, lastComponent: unwrapped })
|
|
214
208
|
: EMPTY_COMPONENT;
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
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:
|
|
240
|
+
? def.renderResult(result, options, theme, { ...context, lastComponent: unwrapped })
|
|
224
241
|
: EMPTY_COMPONENT;
|
|
225
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|