@runminton/pi-tidy-tools 0.1.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 +67 -0
- package/README.zh-CN.md +65 -0
- package/package.json +28 -0
- package/tidy-tools.ts +492 -0
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# pi-clean-tool-render
|
|
2
|
+
|
|
3
|
+
A compact tool-rendering extension for [Pi](https://github.com/earendil-works/pi-mono) (the pi-coding-agent TUI). It overrides the rendering of `bash`, `edit` and `write` so tool output stays out of your way: collapsed into a tiny summary by default, expanded on demand per tool.
|
|
4
|
+
|
|
5
|
+
[中文版](README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Collapsed by default** — each tool shows a short summary (at most 3 lines) with an ellipsis for omitted content.
|
|
10
|
+
- **Per-tool expansion** — expand exactly the tool you care about, with one shortcut each:
|
|
11
|
+
|
|
12
|
+
| Shortcut | Tool |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `Ctrl+Alt+E` | `edit` |
|
|
15
|
+
| `Ctrl+Alt+W` | `write` |
|
|
16
|
+
| `Ctrl+Alt+B` | `bash` |
|
|
17
|
+
|
|
18
|
+
- **Independent of the global `Ctrl+O`** — Pi's built-in "expand everything" toggle does not affect these three tools. Each tool has exactly two states: collapsed summary ↔ full official rendering.
|
|
19
|
+
- **Official renderers on expansion** — expanded views delegate to Pi's built-in renderers, so the full diff preview and syntax highlighting still work.
|
|
20
|
+
- **Fast** — rendering caches wrapped output and only processes the preview lines: roughly 13µs per frame for a 2000-line output, instead of ~3ms before.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Load the extension:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi -e ./tidy-tools.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install as a pi package:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pi install pi-tidy-tools
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or copy `tidy-tools.ts` into `~/.pi/agent/extensions/` (global) or `.pi/extensions/` (project) and restart Pi.
|
|
37
|
+
|
|
38
|
+
Then toggle each tool with `Ctrl+Alt+E` / `Ctrl+Alt+W` / `Ctrl+Alt+B`. A notification shows the current state; press again to collapse.
|
|
39
|
+
|
|
40
|
+
## Keyboard protocol notes
|
|
41
|
+
|
|
42
|
+
Pi negotiates the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) (CSI-u sequences) when the terminal supports it — Windows Terminal, WezTerm, Kitty, Ghostty and others do. Under that protocol, `Ctrl+Alt+letter` is encoded with the **control-character codepoint**: `Ctrl+Alt+E` arrives as `\x1b[5;3:1u` (5 = Ctrl+E, modifier = Alt). Pi's keybinding layer, however, recognizes the ASCII-codepoint form (`\x1b[101;7u`, ASCII `e` + Ctrl|Alt modifier). If the terminal sends the former and Pi only understands the latter, the shortcut silently does nothing.
|
|
43
|
+
|
|
44
|
+
This extension ships a small input bridge: it rewrites the three shortcuts' sequences into the form Pi understands **before** they reach the keybinding layer, no matter which encoding your terminal sends:
|
|
45
|
+
|
|
46
|
+
- Kitty control-char form (`\x1b[5;3:1u`)
|
|
47
|
+
- Legacy form (`ESC` + control char, e.g. `\x1b\x05`)
|
|
48
|
+
- ASCII-codepoint form (`\x1b[101;7u`)
|
|
49
|
+
|
|
50
|
+
All other input passes through untouched.
|
|
51
|
+
|
|
52
|
+
If a shortcut still does nothing on your machine, check:
|
|
53
|
+
|
|
54
|
+
1. **Terminal or input method swallows the key.** Some terminals and IMEs (e.g. Chinese input methods, Windows Alt-menu accelerators) intercept `Ctrl+Alt+...` before Pi ever sees it. Try a different combination or a different terminal.
|
|
55
|
+
2. **tmux / screen.** If Pi runs inside tmux with the default configuration, tmux's own key handling may interfere.
|
|
56
|
+
3. **Custom keys.** The bindings are registered in `tidy-tools.ts` via `pi.registerShortcut(...)` — edit the key strings there to rebind. Pi's built-in global expansion (`Ctrl+O`, `app.tools.expand`) is configured separately via `~/.pi/agent/keybindings.json`.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Type-check with the repo tsconfig (resolves Pi types via the symlinked
|
|
62
|
+
# global install in node_modules/)
|
|
63
|
+
tsc --noEmit
|
|
64
|
+
|
|
65
|
+
# Smoke test: renders collapsed/expanded states with a fake extension host, and
|
|
66
|
+
# benchmarks per-frame cost. See the scripts in the repo's test notes.
|
|
67
|
+
```
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# pi-clean-tool-render
|
|
2
|
+
|
|
3
|
+
为 [Pi](https://github.com/earendil-works/pi-mono)(pi-coding-agent 的 TUI)打造的精简工具渲染扩展。它覆盖了 `bash`、`edit`、`write` 的渲染方式:默认折叠成一行小摘要,需要时按工具单独展开,让工具输出不打扰你。
|
|
4
|
+
|
|
5
|
+
[English](README.md)
|
|
6
|
+
|
|
7
|
+
## 特性
|
|
8
|
+
|
|
9
|
+
- **默认折叠** —— 每个工具只显示简短摘要(最多 3 行),省略的内容用 `...` 标记。
|
|
10
|
+
- **按工具单独展开** —— 只展开你关心的那一个工具,每个工具一个快捷键:
|
|
11
|
+
|
|
12
|
+
| 快捷键 | 工具 |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `Ctrl+Alt+E` | `edit` |
|
|
15
|
+
| `Ctrl+Alt+W` | `write` |
|
|
16
|
+
| `Ctrl+Alt+B` | `bash` |
|
|
17
|
+
|
|
18
|
+
- **与全局 `Ctrl+O` 互不影响** —— Pi 内置的"全部展开"开关不会影响这三个工具。每个工具只有两种状态:折叠摘要 ↔ 官方完整渲染。
|
|
19
|
+
- **展开时使用官方渲染器** —— 完整展开的内容委托给 Pi 内置渲染器,完整的 diff 预览和语法高亮依然可用。
|
|
20
|
+
- **性能好** —— 渲染结果有缓存,且只处理预览行的文本:2000 行输出每帧约 13 微秒,修复前是约 3 毫秒。
|
|
21
|
+
|
|
22
|
+
## 使用方法
|
|
23
|
+
|
|
24
|
+
加载扩展:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi -e ./tidy-tools.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
或者作为 pi 包安装:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pi install pi-tidy-tools
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
或者把 `tidy-tools.ts` 复制到 `~/.pi/agent/extensions/`(全局)或 `.pi/extensions/`(项目级),然后重启 Pi。
|
|
37
|
+
|
|
38
|
+
之后用 `Ctrl+Alt+E` / `Ctrl+Alt+W` / `Ctrl+Alt+B` 切换对应工具的展开状态。按下时会弹通知提示当前状态;再按一次收起。
|
|
39
|
+
|
|
40
|
+
## 键盘协议说明(重要)
|
|
41
|
+
|
|
42
|
+
Pi 会在终端支持时协商 [Kitty 键盘协议](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)(CSI-u 序列)——Windows Terminal、WezTerm、Kitty、Ghostty 等都支持。在该协议下,`Ctrl+Alt+字母` 使用**控制字符码**编码:`Ctrl+Alt+E` 到达时是 `\x1b[5;3:1u`(5 = Ctrl+E 的控制字符,修饰位 = Alt)。而 Pi 的按键匹配层只识别 **ASCII 码形式**(`\x1b[101;7u`,即 ASCII `e` + Ctrl|Alt 修饰位)。如果终端发送前者、Pi 只认后者,快捷键就会无声失效——这就是不同终端行为差异的来源。
|
|
43
|
+
|
|
44
|
+
本扩展内置了一个小的输入桥:在按键到达 Pi 的匹配层**之前**,把这三个快捷键的序列改写成 Pi 能识别的形式。无论你的终端发送哪种编码都能工作:
|
|
45
|
+
|
|
46
|
+
- Kitty 控制字符形式(`\x1b[5;3:1u`)
|
|
47
|
+
- 传统形式(`ESC` + 控制字符,如 `\x1b\x05`)
|
|
48
|
+
- ASCII 码形式(`\x1b[101;7u`)
|
|
49
|
+
|
|
50
|
+
其他所有输入原样通过,不受影响。
|
|
51
|
+
|
|
52
|
+
如果你的快捷键仍然没反应,请依次检查:
|
|
53
|
+
|
|
54
|
+
1. **终端或输入法吞掉了按键。** 有些终端和输入法(例如中文输入法、Windows 的 Alt 菜单加速键)会在 Pi 收到按键之前把它拦截。换一个组合键或换一个终端试试。
|
|
55
|
+
2. **tmux / screen。** 如果 Pi 运行在 tmux 默认配置里,tmux 自己的按键处理可能干扰。
|
|
56
|
+
3. **自定义按键。** 绑定在 `tidy-tools.ts` 里通过 `pi.registerShortcut(...)` 注册——改那里的按键字符串即可重新绑定。Pi 内置的全局展开(`Ctrl+O`,即 `app.tools.expand`)则单独通过 `~/.pi/agent/keybindings.json` 配置。
|
|
57
|
+
|
|
58
|
+
## 开发
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# 类型检查(通过仓库 tsconfig 解析 Pi 类型,依赖 node_modules/ 中指向全局安装的软链)
|
|
62
|
+
tsc --noEmit
|
|
63
|
+
|
|
64
|
+
# 冒烟测试与性能基准:用假扩展宿主渲染折叠/展开状态并测量每帧开销(见仓库测试说明)
|
|
65
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runminton/pi-tidy-tools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tidy tool rendering for the pi TUI: collapse bash/edit/write into compact summaries and expand on demand with Ctrl+Alt+B/W/E.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"tidy-tools.ts",
|
|
9
|
+
"README.md",
|
|
10
|
+
"README.zh-CN.md"
|
|
11
|
+
],
|
|
12
|
+
"pi": {
|
|
13
|
+
"extensions": [
|
|
14
|
+
"./tidy-tools.ts"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
19
|
+
"@earendil-works/pi-tui": ">=0.84.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"pi",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"tui",
|
|
25
|
+
"tools",
|
|
26
|
+
"rendering"
|
|
27
|
+
]
|
|
28
|
+
}
|
package/tidy-tools.ts
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact Tool Rendering Extension
|
|
3
|
+
*
|
|
4
|
+
* Overrides the TUI rendering of bash, edit and write while delegating
|
|
5
|
+
* execution to the official implementations:
|
|
6
|
+
* - Collapsed: show a small summary with an ellipsis for long content.
|
|
7
|
+
* - Expanded: Ctrl+Alt+E (edit), Ctrl+Alt+W (write), Ctrl+Alt+B (bash)
|
|
8
|
+
* toggle each tool between collapsed and fully expanded rendering.
|
|
9
|
+
* - The global Ctrl+O tool expansion does not affect these three tools.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* pi -e ./tidy-tools.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
createBashTool,
|
|
18
|
+
createBashToolDefinition,
|
|
19
|
+
createEditToolDefinition,
|
|
20
|
+
createWriteToolDefinition,
|
|
21
|
+
} from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import type { EditToolDetails } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import {
|
|
24
|
+
Box,
|
|
25
|
+
Container,
|
|
26
|
+
Text,
|
|
27
|
+
type Component,
|
|
28
|
+
sliceByColumn,
|
|
29
|
+
visibleWidth,
|
|
30
|
+
wrapTextWithAnsi,
|
|
31
|
+
} from "@earendil-works/pi-tui";
|
|
32
|
+
|
|
33
|
+
const MAX_COLLAPSED_COMMAND_LINES = 1;
|
|
34
|
+
const MAX_COLLAPSED_CONTENT_LINES = 3;
|
|
35
|
+
// bash 折叠输出:1 行预览 + 1 行计数提示(共 2 行)。
|
|
36
|
+
const MAX_COLLAPSED_BASH_OUTPUT_LINES = 2;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Truncate rendered rows to at most maxLines, keeping the beginning.
|
|
40
|
+
* The last line is shortened and an ellipsis marks omitted content.
|
|
41
|
+
*/
|
|
42
|
+
function truncateRows(rows: string[], maxLines: number, ellipsis: string, width: number): string[] {
|
|
43
|
+
if (rows.length <= maxLines) {
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lines = rows.slice(0, maxLines);
|
|
48
|
+
const lastIndex = lines.length - 1;
|
|
49
|
+
const lastLine = lines[lastIndex] ?? "";
|
|
50
|
+
const ellipsisWidth = visibleWidth(ellipsis);
|
|
51
|
+
|
|
52
|
+
if (ellipsisWidth >= width) {
|
|
53
|
+
lines[lastIndex] = sliceByColumn(ellipsis, 0, Math.max(1, width), true);
|
|
54
|
+
} else {
|
|
55
|
+
const contentWidth = width - ellipsisWidth;
|
|
56
|
+
lines[lastIndex] = `${sliceByColumn(lastLine, 0, contentWidth, true)}${ellipsis}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstLine(text: string | undefined): string {
|
|
63
|
+
if (!text) return "";
|
|
64
|
+
const line = text.split("\n")[0] ?? "";
|
|
65
|
+
return line.length > 80 ? `${line.slice(0, 77)}...` : line;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function errorText(result: { content: { type: string; text?: string }[] }): string {
|
|
69
|
+
return result.content
|
|
70
|
+
.filter((c) => c.type === "text")
|
|
71
|
+
.map((c) => c.text ?? "")
|
|
72
|
+
.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function diffStats(diff: string): { additions: number; removals: number } {
|
|
76
|
+
let additions = 0;
|
|
77
|
+
let removals = 0;
|
|
78
|
+
for (const line of diff.split("\n")) {
|
|
79
|
+
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
80
|
+
if (line.startsWith("-") && !line.startsWith("---")) removals++;
|
|
81
|
+
}
|
|
82
|
+
return { additions, removals };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Render text with a width-aware maximum number of lines.
|
|
87
|
+
* The beginning is kept and an ellipsis marks omitted content.
|
|
88
|
+
*/
|
|
89
|
+
class LimitedLinesText implements Component {
|
|
90
|
+
constructor(
|
|
91
|
+
private readonly text: string,
|
|
92
|
+
private readonly maxLines: number,
|
|
93
|
+
private readonly ellipsis: string,
|
|
94
|
+
) {}
|
|
95
|
+
|
|
96
|
+
private cachedWidth?: number;
|
|
97
|
+
private cachedLines?: string[];
|
|
98
|
+
|
|
99
|
+
render(width: number): string[] {
|
|
100
|
+
// The TUI calls render() on every frame. Cache the wrapped result so
|
|
101
|
+
// a frame only re-wraps when the width actually changes.
|
|
102
|
+
if (this.cachedLines !== undefined && this.cachedWidth === width) {
|
|
103
|
+
return this.cachedLines;
|
|
104
|
+
}
|
|
105
|
+
const lines = truncateRows(
|
|
106
|
+
wrapTextWithAnsi(this.text, Math.max(1, width)),
|
|
107
|
+
this.maxLines,
|
|
108
|
+
this.ellipsis,
|
|
109
|
+
width,
|
|
110
|
+
);
|
|
111
|
+
this.cachedWidth = width;
|
|
112
|
+
this.cachedLines = lines;
|
|
113
|
+
return lines;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
invalidate(): void {
|
|
117
|
+
this.cachedWidth = undefined;
|
|
118
|
+
this.cachedLines = undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class CollapsedToolShell extends Box {
|
|
123
|
+
private callComponent?: Component;
|
|
124
|
+
private resultComponent?: Component;
|
|
125
|
+
|
|
126
|
+
constructor(theme: any, background: string) {
|
|
127
|
+
super(1, 1, (text) => theme.bg(background, text));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
setBackground(theme: any, background: string): void {
|
|
131
|
+
this.setBgFn((text) => theme.bg(background, text));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setCall(component: Component): void {
|
|
135
|
+
this.callComponent = component;
|
|
136
|
+
this.rebuild();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setResult(component: Component): void {
|
|
140
|
+
this.resultComponent = component;
|
|
141
|
+
this.rebuild();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private rebuild(): void {
|
|
145
|
+
this.clear();
|
|
146
|
+
if (this.callComponent) this.addChild(this.callComponent);
|
|
147
|
+
if (this.resultComponent) this.addChild(this.resultComponent);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getCollapsedToolShell(state: any, theme: any, background: string): CollapsedToolShell {
|
|
152
|
+
const shell = (state.collapsedToolShell as CollapsedToolShell | undefined) ?? new CollapsedToolShell(theme, background);
|
|
153
|
+
state.collapsedToolShell = shell;
|
|
154
|
+
shell.setBackground(theme, background);
|
|
155
|
+
return shell;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getCollapsedBackground(isPartial: boolean, isError: boolean): string {
|
|
159
|
+
if (isPartial) return "toolPendingBg";
|
|
160
|
+
return isError ? "toolErrorBg" : "toolSuccessBg";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Per-tool expansion state, toggled by Ctrl+Alt+E/W/B. Independent of the
|
|
164
|
+
// global tool-output expansion (Ctrl+O).
|
|
165
|
+
const toolExpanded = new Map<string, boolean>([
|
|
166
|
+
["bash", false],
|
|
167
|
+
["edit", true],
|
|
168
|
+
["write", true],
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
// Some terminals (Kitty keyboard protocol flag-1 mode) encode Ctrl+letter as
|
|
172
|
+
// a CSI-u sequence using the control character code without a Ctrl modifier
|
|
173
|
+
// bit, e.g. Ctrl+Alt+E arrives as \x1b[5;3:1u (5 = Ctrl+E, mod = Alt). Pi
|
|
174
|
+
// only recognizes the ASCII-code + full-modifier form (\x1b[101;7u), so we
|
|
175
|
+
// rewrite the three shortcuts into the form Pi understands. All other input
|
|
176
|
+
// passes through unchanged.
|
|
177
|
+
const CTRL_CODE_TO_KEY: Record<number, string> = {
|
|
178
|
+
5: "e", // Ctrl+E
|
|
179
|
+
23: "w", // Ctrl+W
|
|
180
|
+
2: "b", // Ctrl+B
|
|
181
|
+
};
|
|
182
|
+
const ALT_MODIFIER = 2;
|
|
183
|
+
|
|
184
|
+
function convertCtrlAltSequence(data: string): string | undefined {
|
|
185
|
+
const match = data.match(/^\x1b\[(\d+);(\d+)(?::(\d+))?u$/);
|
|
186
|
+
if (!match) return undefined;
|
|
187
|
+
const code = Number(match[1]);
|
|
188
|
+
const modifier = Number(match[2]) - 1; // CSI-u modifiers are 1-indexed
|
|
189
|
+
const key = CTRL_CODE_TO_KEY[code];
|
|
190
|
+
if (!key || (modifier & ALT_MODIFIER) === 0) return undefined;
|
|
191
|
+
const eventSuffix = match[3] ? `:${match[3]}` : "";
|
|
192
|
+
return `\x1b[${key.charCodeAt(0)};7${eventSuffix}u`; // 7 = Ctrl|Alt (1-indexed)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toggleToolExpanded(ctx: ExtensionContext, name: string): void {
|
|
196
|
+
toolExpanded.set(name, !(toolExpanded.get(name) ?? false));
|
|
197
|
+
const globalExpanded = ctx.ui.getToolsExpanded();
|
|
198
|
+
// Force every tool row to re-render. The global value is restored
|
|
199
|
+
// immediately, so only the rows' renderers refresh, not the global state.
|
|
200
|
+
ctx.ui.setToolsExpanded(!globalExpanded);
|
|
201
|
+
ctx.ui.setToolsExpanded(globalExpanded);
|
|
202
|
+
ctx.ui.notify(`${name}: ${toolExpanded.get(name) ? "expanded" : "collapsed"}`, "info");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Cache the official tool implementation by working directory, as pi may run
|
|
206
|
+
// the same extension against more than one session cwd.
|
|
207
|
+
const bashToolCache = new Map<string, ReturnType<typeof createBashTool>>();
|
|
208
|
+
const bashDefCache = new Map<string, ReturnType<typeof createBashToolDefinition>>();
|
|
209
|
+
|
|
210
|
+
function getBashTool(cwd: string) {
|
|
211
|
+
let tool = bashToolCache.get(cwd);
|
|
212
|
+
if (!tool) {
|
|
213
|
+
tool = createBashTool(cwd);
|
|
214
|
+
bashToolCache.set(cwd, tool);
|
|
215
|
+
}
|
|
216
|
+
return tool;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getBashDef(cwd: string) {
|
|
220
|
+
let def = bashDefCache.get(cwd);
|
|
221
|
+
if (!def) {
|
|
222
|
+
def = createBashToolDefinition(cwd);
|
|
223
|
+
bashDefCache.set(cwd, def);
|
|
224
|
+
}
|
|
225
|
+
return def;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Re-register a built-in tool with collapsed rendering and official
|
|
230
|
+
* rendering when that tool is expanded via its own shortcut. The built-in
|
|
231
|
+
* render shell is preserved so expanded views keep their native layout.
|
|
232
|
+
*/
|
|
233
|
+
function registerCollapsibleTool(
|
|
234
|
+
pi: ExtensionAPI,
|
|
235
|
+
getDef: (cwd: string) => ToolDefinition<any, any, any>,
|
|
236
|
+
collapsedCall: (args: any, theme: any) => Component,
|
|
237
|
+
collapsedResult: (result: any, options: any, theme: any, context: any) => Component,
|
|
238
|
+
) {
|
|
239
|
+
const def = getDef(process.cwd());
|
|
240
|
+
const usesSelfShell = def.renderShell === "self";
|
|
241
|
+
pi.registerTool({
|
|
242
|
+
name: def.name,
|
|
243
|
+
label: def.label,
|
|
244
|
+
description: def.description,
|
|
245
|
+
promptSnippet: def.promptSnippet,
|
|
246
|
+
promptGuidelines: def.promptGuidelines,
|
|
247
|
+
parameters: def.parameters,
|
|
248
|
+
prepareArguments: def.prepareArguments,
|
|
249
|
+
constrainedSampling: def.constrainedSampling,
|
|
250
|
+
// Omit renderShell so Pi inherits each built-in tool's native shell.
|
|
251
|
+
// In particular, edit uses "self" for its original expanded layout.
|
|
252
|
+
|
|
253
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
254
|
+
return getDef(ctx.cwd).execute(toolCallId, params, signal, onUpdate, ctx);
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
renderCall(args, theme, context) {
|
|
258
|
+
const def = getDef(context.cwd);
|
|
259
|
+
if (toolExpanded.get(def.name) && def.renderCall) {
|
|
260
|
+
return def.renderCall(
|
|
261
|
+
args as never,
|
|
262
|
+
theme as never,
|
|
263
|
+
{ ...context, lastComponent: undefined } as never,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const component = collapsedCall(args, theme);
|
|
267
|
+
if (!usesSelfShell) return component;
|
|
268
|
+
|
|
269
|
+
const shell = getCollapsedToolShell(
|
|
270
|
+
context.state,
|
|
271
|
+
theme,
|
|
272
|
+
getCollapsedBackground(context.isPartial, context.isError),
|
|
273
|
+
);
|
|
274
|
+
shell.setCall(component);
|
|
275
|
+
return shell;
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
renderResult(result, options, theme, context) {
|
|
279
|
+
const def = getDef(context.cwd);
|
|
280
|
+
if (toolExpanded.get(def.name) && def.renderResult) {
|
|
281
|
+
return def.renderResult(
|
|
282
|
+
result as never,
|
|
283
|
+
options as never,
|
|
284
|
+
theme as never,
|
|
285
|
+
{ ...context, lastComponent: undefined } as never,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const component = collapsedResult(result, options, theme, context);
|
|
289
|
+
if (!usesSelfShell) return component;
|
|
290
|
+
|
|
291
|
+
const shell = getCollapsedToolShell(
|
|
292
|
+
context.state,
|
|
293
|
+
theme,
|
|
294
|
+
getCollapsedBackground(options.isPartial, context.isError),
|
|
295
|
+
);
|
|
296
|
+
shell.setResult(component);
|
|
297
|
+
return new Container();
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Cache the official tool definitions by working directory, as pi may run
|
|
303
|
+
// the same extension against more than one session cwd.
|
|
304
|
+
const editDefCache = new Map<string, ToolDefinition<any, any, any>>();
|
|
305
|
+
const writeDefCache = new Map<string, ToolDefinition<any, any, any>>();
|
|
306
|
+
|
|
307
|
+
function getEditDef(cwd: string) {
|
|
308
|
+
let def = editDefCache.get(cwd);
|
|
309
|
+
if (!def) {
|
|
310
|
+
def = createEditToolDefinition(cwd);
|
|
311
|
+
editDefCache.set(cwd, def);
|
|
312
|
+
}
|
|
313
|
+
return def;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function getWriteDef(cwd: string) {
|
|
317
|
+
let def = writeDefCache.get(cwd);
|
|
318
|
+
if (!def) {
|
|
319
|
+
def = createWriteToolDefinition(cwd);
|
|
320
|
+
writeDefCache.set(cwd, def);
|
|
321
|
+
}
|
|
322
|
+
return def;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export default function (pi: ExtensionAPI) {
|
|
326
|
+
pi.registerTool({
|
|
327
|
+
name: "bash",
|
|
328
|
+
label: "bash",
|
|
329
|
+
description:
|
|
330
|
+
"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first).",
|
|
331
|
+
parameters: getBashTool(process.cwd()).parameters,
|
|
332
|
+
|
|
333
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
334
|
+
const tool = getBashTool(ctx.cwd);
|
|
335
|
+
return tool.execute(toolCallId, params, signal, onUpdate);
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
renderCall(args, theme, context) {
|
|
339
|
+
if (toolExpanded.get("bash")) {
|
|
340
|
+
const def = getBashDef(context.cwd);
|
|
341
|
+
if (def.renderCall) {
|
|
342
|
+
return def.renderCall(
|
|
343
|
+
args as never,
|
|
344
|
+
theme as never,
|
|
345
|
+
// One-key full expansion for bash: skip the official
|
|
346
|
+
// preview so Ctrl+Alt+B shows the complete output.
|
|
347
|
+
{ ...context, expanded: true, lastComponent: undefined } as never,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const command = args.command || "...";
|
|
353
|
+
const timeout = args.timeout as number | undefined;
|
|
354
|
+
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
|
355
|
+
const renderedCommand = theme.fg("toolTitle", theme.bold(`$ ${command}`)) + timeoutSuffix;
|
|
356
|
+
|
|
357
|
+
return new LimitedLinesText(
|
|
358
|
+
renderedCommand,
|
|
359
|
+
MAX_COLLAPSED_COMMAND_LINES,
|
|
360
|
+
theme.fg("muted", "..."),
|
|
361
|
+
);
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
renderResult(result, options, theme, context) {
|
|
365
|
+
if (toolExpanded.get("bash")) {
|
|
366
|
+
const def = getBashDef(context.cwd);
|
|
367
|
+
if (def.renderResult) {
|
|
368
|
+
return def.renderResult(
|
|
369
|
+
result as never,
|
|
370
|
+
// One-key full expansion for bash: skip the official
|
|
371
|
+
// preview so Ctrl+Alt+B shows the complete output.
|
|
372
|
+
{ ...options, expanded: true } as never,
|
|
373
|
+
theme as never,
|
|
374
|
+
{ ...context, lastComponent: undefined } as never,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const output = errorText(result).trim().replace(/\r/g, "");
|
|
380
|
+
if (context.isError) {
|
|
381
|
+
return new LimitedLinesText(
|
|
382
|
+
theme.fg("error", firstLine(output) || "failed"),
|
|
383
|
+
MAX_COLLAPSED_CONTENT_LINES,
|
|
384
|
+
theme.fg("muted", "..."),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
// Preview only the first line. Wrapping the full output on every
|
|
388
|
+
// frame is what made the TUI lag with large outputs.
|
|
389
|
+
const allLines = output.split("\n");
|
|
390
|
+
const previewLines = allLines.slice(0, 1).map((line) => theme.fg("toolOutput", line));
|
|
391
|
+
if (allLines.length > 1) {
|
|
392
|
+
previewLines.push(
|
|
393
|
+
theme.fg("muted", `... (${allLines.length - 1} more lines)`),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return new LimitedLinesText(
|
|
397
|
+
previewLines.join("\n") || theme.fg("muted", "(no output)"),
|
|
398
|
+
MAX_COLLAPSED_BASH_OUTPUT_LINES,
|
|
399
|
+
theme.fg("muted", "..."),
|
|
400
|
+
);
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// --- Edit tool: collapsed diff summary ---
|
|
405
|
+
registerCollapsibleTool(
|
|
406
|
+
pi,
|
|
407
|
+
getEditDef,
|
|
408
|
+
(args, theme) => {
|
|
409
|
+
const path = String(args.path ?? "");
|
|
410
|
+
const edits: { oldText?: string; newText?: string }[] = Array.isArray(args.edits) ? args.edits : [];
|
|
411
|
+
let text = theme.fg("toolTitle", theme.bold("edit ")) + theme.fg("accent", path);
|
|
412
|
+
if (edits.length > 0) {
|
|
413
|
+
text += theme.fg("dim", ` (${edits.length} block${edits.length > 1 ? "s" : ""})`);
|
|
414
|
+
}
|
|
415
|
+
return new LimitedLinesText(text, 1, theme.fg("muted", "..."));
|
|
416
|
+
},
|
|
417
|
+
(result, _options, theme, context) => {
|
|
418
|
+
if (context.isError) {
|
|
419
|
+
return new LimitedLinesText(
|
|
420
|
+
theme.fg("error", `failed: ${firstLine(errorText(result))}`),
|
|
421
|
+
1,
|
|
422
|
+
theme.fg("muted", "..."),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const diff = (result.details as EditToolDetails | undefined)?.diff;
|
|
426
|
+
if (!diff) {
|
|
427
|
+
return new Text(theme.fg("success", "applied"), 0, 0);
|
|
428
|
+
}
|
|
429
|
+
const { additions, removals } = diffStats(diff);
|
|
430
|
+
return new Text(
|
|
431
|
+
theme.fg("success", "applied") + theme.fg("dim", ` +${additions} -${removals}`),
|
|
432
|
+
0,
|
|
433
|
+
0,
|
|
434
|
+
);
|
|
435
|
+
},
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// --- Write tool: collapsed shows path + first lines ---
|
|
439
|
+
registerCollapsibleTool(
|
|
440
|
+
pi,
|
|
441
|
+
getWriteDef,
|
|
442
|
+
(args, theme) => {
|
|
443
|
+
const path = String(args.path ?? "");
|
|
444
|
+
const content = String(args.content ?? "");
|
|
445
|
+
const lines = content.split("\n");
|
|
446
|
+
let text = theme.fg("toolTitle", theme.bold("write ")) + theme.fg("accent", path);
|
|
447
|
+
text += theme.fg("dim", ` (${lines.length} lines)`);
|
|
448
|
+
if (content) {
|
|
449
|
+
const preview = lines
|
|
450
|
+
.slice(0, MAX_COLLAPSED_CONTENT_LINES - 1)
|
|
451
|
+
.map((line) => theme.fg("toolOutput", line));
|
|
452
|
+
if (lines.length > MAX_COLLAPSED_CONTENT_LINES) {
|
|
453
|
+
preview.push(
|
|
454
|
+
theme.fg("muted", `... (${lines.length - (MAX_COLLAPSED_CONTENT_LINES - 1)} more lines)`),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
text += `\n${preview.join("\n")}`;
|
|
458
|
+
}
|
|
459
|
+
return new LimitedLinesText(text, MAX_COLLAPSED_CONTENT_LINES, theme.fg("muted", "..."));
|
|
460
|
+
},
|
|
461
|
+
(result, _options, theme, context) => {
|
|
462
|
+
if (context.isError) {
|
|
463
|
+
return new Text(theme.fg("error", `failed: ${firstLine(errorText(result))}`), 0, 0);
|
|
464
|
+
}
|
|
465
|
+
return new Text(theme.fg("success", "done"), 0, 0);
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// --- Per-tool expansion shortcuts, independent of the global Ctrl+O ---
|
|
470
|
+
pi.registerShortcut("ctrl+alt+e", {
|
|
471
|
+
description: "Toggle edit expansion",
|
|
472
|
+
handler: (ctx) => toggleToolExpanded(ctx, "edit"),
|
|
473
|
+
});
|
|
474
|
+
pi.registerShortcut("ctrl+alt+w", {
|
|
475
|
+
description: "Toggle write expansion",
|
|
476
|
+
handler: (ctx) => toggleToolExpanded(ctx, "write"),
|
|
477
|
+
});
|
|
478
|
+
pi.registerShortcut("ctrl+alt+b", {
|
|
479
|
+
description: "Toggle bash expansion",
|
|
480
|
+
handler: (ctx) => toggleToolExpanded(ctx, "bash"),
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Some terminals encode Ctrl+Alt+letter as a control-character CSI-u
|
|
484
|
+
// sequence. Rewrite those three sequences before they reach the editor.
|
|
485
|
+
pi.on("session_start", (_event, ctx) => {
|
|
486
|
+
ctx.ui.onTerminalInput?.((data) => {
|
|
487
|
+
const converted = convertCtrlAltSequence(data);
|
|
488
|
+
return converted ? { data: converted } : undefined;
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|