@liuxincuit/pi-compact-tools 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +56 -2
- package/index.ts +567 -0
- package/package.json +34 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 liuxincuit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# compact-tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
覆盖内置的 `read` / `bash` / `edit` / `write` 四个工具,换成自绘的紧凑渲染;顺带给 bash 注入默认超时与 `.env` 环境。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@liuxincuit/pi-compact-tools
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui` 由 pi 宿主提供,不需要额外安装。
|
|
12
|
+
装完 `/reload` 后生效。
|
|
13
|
+
|
|
14
|
+
注意它不只改渲染,还会改 bash 的行为:默认 120 秒超时、注入 `~/.pi/agent/.env`、移除代理变量。细节与理由见下节。
|
|
15
|
+
|
|
16
|
+
从源码调试时用本地路径安装(在仓库根执行):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pi install ./packages/compact-tools
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 做什么
|
|
23
|
+
|
|
24
|
+
- **紧凑渲染**:四个工具改为 `renderShell: "self"`,自绘调用行与结果行。成功命令无输出时折叠成一行,失败或展开时才铺开全文。执行期间用 spinner + 耗时提示(按 `toolCallId` 分别计状态,并发工具互不干扰)。
|
|
25
|
+
- **技能读取折叠**:路径末段是 `SKILL.md` 时(pi 把技能当普通文件读,只能靠文件名认),未展开渲染成单行 `│ [skill] <父目录名> (ctrl+o to expand)`,颜色跟随成功/失败;展开后退回 `read <路径>`,保留文件位置。普通文件不受影响,仍是原来的两行 `read <路径>` + `↳ loaded N lines`。判定只看文件名、不校验目录:任何叫 `SKILL.md` 的文件都这样显示。
|
|
26
|
+
- **ANSI 清洗**:剥掉工具输出里的背景色 SGR 参数,保留前景色,避免主题色串到整个终端。
|
|
27
|
+
- **bash 默认超时**:LLM 没传 `timeout` 时按 `DEFAULT_BASH_TIMEOUT_SECONDS`(120 秒)执行,防止 `find` 全盘搜索之类的长驻命令无限阻塞。
|
|
28
|
+
- **bash 环境注入**:从 `~/.pi/agent/.env` 读 `KEY=VALUE` 注入 bash 环境,同时移除 `HTTP_PROXY` / `HTTPS_PROXY` / `http_proxy` / `https_proxy` / `all_proxy`。
|
|
29
|
+
|
|
30
|
+
## 配置
|
|
31
|
+
|
|
32
|
+
| 项 | 位置 | 说明 |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| 环境变量 | `~/.pi/agent/.env` | `KEY=VALUE`,支持 `#` 注释、空行、单双引号;文件不存在时不改动 env |
|
|
35
|
+
| 默认超时 | 源码导出的 `DEFAULT_BASH_TIMEOUT_SECONDS` | 改动需编辑源码后 `/reload` |
|
|
36
|
+
|
|
37
|
+
## 调试
|
|
38
|
+
|
|
39
|
+
改完必须 `/reload`。
|
|
40
|
+
|
|
41
|
+
技能折叠:让模型读一个 `SKILL.md`(如本仓库的 `skills/supermap-jira/SKILL.md`),未展开应是单行 `│ [skill] supermap-jira (ctrl+o to expand)`;按 expand 键后首行退回 `│ read <绝对路径>` 并铺开正文。读不存在的技能文件时整行转红,不出现展开提示。读普通文件应保持 `│ read <路径>` + ` ↳ loaded N lines` 两行。
|
|
42
|
+
|
|
43
|
+
bash 超时:不传 `timeout` 跑 `sleep 200`,应在 120 秒时中断,且错误消息里附带默认超时的说明——用来区分“LLM 自己设的超时”和“扩展注入的默认超时”。LLM 显式传入的 `timeout` 原样透传,并保持 pi 原生的错误格式。
|
|
44
|
+
|
|
45
|
+
## 陷阱
|
|
46
|
+
|
|
47
|
+
**超时改不动 `spawnHook`。** `spawnHook` 只能改 `command` / `cwd` / `env`,注入默认超时得整体包装 `operations`(`createLocalBashOperations()` 保持原生行为)。
|
|
48
|
+
|
|
49
|
+
**技能折叠的判定分散在两处,必须同步改。** `getDetail` 把标题行换成 `[skill] <名字>` 并挂展开提示,`getSummary` 对同一路径返回 `undefined` 省掉第二行摘要;两处共用 `skillNameFromPath`。只改一处会出现“只有名字没提示”或“提示重复两遍”。展开提示只在终态挂:`isPartial` 为真(参数流式、执行中、结果流式)不挂,`isError` 为真(没内容可展开)也不挂。
|
|
50
|
+
|
|
51
|
+
**`getDetail` 的第三个参数是 pi 的渲染上下文。** `expanded` / `isPartial` / `isError` / `cwd` 都在里面,技能折叠靠它判断;返回值多出的 `label` 字段可覆盖工具默认名,其余渲染分支照用。
|
|
52
|
+
|
|
53
|
+
**`renderCall` 与 `renderResult` 有明确分工。** 只在参数流式期间出调用行(那时还没有 partial result,`renderResult` 不会被调,否则模型产出参数时界面整段空白);执行开始或已有结果时让 `renderResult` 独占,包括 `/reload` 重放历史的情况——那时不会再发 `tool_execution_start`。
|
|
54
|
+
|
|
55
|
+
**要主动发一次空 partial。** `renderResult` 只在有 result 时被调用,read/edit/write 这类不产生流式输出的工具执行期间会整行不可见,所以 `execute` 开头手动 `onUpdate({ content: [], details: undefined })`。
|
|
56
|
+
|
|
57
|
+
**覆盖的是工具名而非实现细节**:`name` / `description` / `parameters` 全部沿用内置工具的定义,只替换渲染与执行包装。改完必须 `/reload` 才生效。
|
package/index.ts
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import {
|
|
6
|
+
createBashTool,
|
|
7
|
+
createEditTool,
|
|
8
|
+
createLocalBashOperations,
|
|
9
|
+
createReadTool,
|
|
10
|
+
createWriteTool,
|
|
11
|
+
formatSize,
|
|
12
|
+
keyHint,
|
|
13
|
+
type BashOperations,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
const BORDER = "\u2502 "; // │
|
|
17
|
+
|
|
18
|
+
const ENV_FILE = join(homedir(), ".pi", "agent", ".env");
|
|
19
|
+
|
|
20
|
+
/** 解析 KEY=VALUE 格式,支持 # 注释、空行、引号;文件不存在或为空返回 {} */
|
|
21
|
+
function parseEnvFile(file: string): Record<string, string> {
|
|
22
|
+
const out: Record<string, string> = {};
|
|
23
|
+
if (!existsSync(file)) return out;
|
|
24
|
+
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
25
|
+
const t = line.trim();
|
|
26
|
+
if (!t || t.startsWith("#")) continue;
|
|
27
|
+
const eq = t.indexOf("=");
|
|
28
|
+
if (eq <= 0) continue;
|
|
29
|
+
const key = t.slice(0, eq).trim();
|
|
30
|
+
let value = t.slice(eq + 1).trim();
|
|
31
|
+
if (
|
|
32
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
33
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
34
|
+
) {
|
|
35
|
+
value = value.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
out[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** bash 工具专属环境:移除代理变量并注入 ~/.pi/agent/.env;无 .env 时不改变 env */
|
|
43
|
+
function bashEnvHook({ command, cwd, env }: { command: string; cwd: string; env: Record<string, string> }) {
|
|
44
|
+
if (!existsSync(ENV_FILE)) return { command, cwd, env };
|
|
45
|
+
const clean = { ...env };
|
|
46
|
+
for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "all_proxy"]) {
|
|
47
|
+
delete clean[key];
|
|
48
|
+
}
|
|
49
|
+
Object.assign(clean, parseEnvFile(ENV_FILE));
|
|
50
|
+
return { command, cwd, env: clean };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* bash 默认超时:LLM 未显式传 timeout 时按默认时长执行,防止 find 全盘搜索
|
|
55
|
+
* 之类的长驻命令无限阻塞;LLM 显式传入的 timeout 原样透传并保持 pi 原生
|
|
56
|
+
* 错误格式,只有默认注入触发的超时消息附加说明。
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_BASH_TIMEOUT_SECONDS = 120;
|
|
59
|
+
|
|
60
|
+
export function createBashTimeoutOps(
|
|
61
|
+
base: BashOperations,
|
|
62
|
+
defaultTimeoutSeconds = DEFAULT_BASH_TIMEOUT_SECONDS,
|
|
63
|
+
): BashOperations {
|
|
64
|
+
return {
|
|
65
|
+
exec: async (command, cwd, options) => {
|
|
66
|
+
const injected = options.timeout === undefined;
|
|
67
|
+
const timeout = injected ? defaultTimeoutSeconds : options.timeout;
|
|
68
|
+
try {
|
|
69
|
+
return await base.exec(command, cwd, { ...options, timeout });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// pi 层把 "timeout:<n>" 错误渲染为 "Command timed out after <n> seconds",
|
|
72
|
+
// 在 <n> 后附带默认超时说明,让 LLM 区分自己设的超时和默认超时。
|
|
73
|
+
if (injected && err instanceof Error && err.message.startsWith("timeout:")) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`timeout:${defaultTimeoutSeconds} (default timeout; no explicit timeout was provided)`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
85
|
+
const SPINNER_INTERVAL_MS = 200;
|
|
86
|
+
|
|
87
|
+
interface SpinnerState {
|
|
88
|
+
frame: number;
|
|
89
|
+
start: number;
|
|
90
|
+
timer?: ReturnType<typeof setInterval>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Per-tool-call spinner state; keyed by toolCallId so concurrent tools spin independently.
|
|
94
|
+
const spinnerStates = new Map<string, SpinnerState>();
|
|
95
|
+
|
|
96
|
+
const MAX_TITLE_LEN = 100;
|
|
97
|
+
const MAX_CONTENT_LINE_LEN = 80;
|
|
98
|
+
|
|
99
|
+
// Commands whose successful completion normally produces no output.
|
|
100
|
+
const QUIET_COMMAND_PREFIXES = [
|
|
101
|
+
"cd", "mkdir", "rmdir", "rm", "mv", "cp", "touch", "chmod", "chown",
|
|
102
|
+
"git add", "git checkout", "git switch", "git restore", "git reset", "git clean",
|
|
103
|
+
"npm install", "pnpm install", "yarn install", "bun install",
|
|
104
|
+
"pip install", "poetry install", "cargo fetch", "go mod tidy",
|
|
105
|
+
] as const;
|
|
106
|
+
|
|
107
|
+
function formatElapsed(ms: number): string {
|
|
108
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
109
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
110
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
111
|
+
const seconds = totalSeconds % 60;
|
|
112
|
+
if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`;
|
|
113
|
+
return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getOrCreateSpinnerState(toolCallId: string): SpinnerState {
|
|
117
|
+
let state = spinnerStates.get(toolCallId);
|
|
118
|
+
if (!state) {
|
|
119
|
+
state = { frame: 0, start: Date.now() };
|
|
120
|
+
spinnerStates.set(toolCallId, state);
|
|
121
|
+
}
|
|
122
|
+
return state;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stopSpinner(toolCallId: string | undefined): void {
|
|
126
|
+
if (!toolCallId) return;
|
|
127
|
+
const state = spinnerStates.get(toolCallId);
|
|
128
|
+
if (!state) return;
|
|
129
|
+
if (state.timer) clearInterval(state.timer);
|
|
130
|
+
spinnerStates.delete(toolCallId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function titleLine(
|
|
134
|
+
label: string,
|
|
135
|
+
detail: string,
|
|
136
|
+
t: any,
|
|
137
|
+
color: string,
|
|
138
|
+
parts?: Array<{ text: string; color?: string }>,
|
|
139
|
+
): string {
|
|
140
|
+
const border = t.fg(color, t.bold(BORDER));
|
|
141
|
+
const suffix = (parts ?? []).map((p) => " " + t.fg(p.color ?? "muted", p.text)).join("");
|
|
142
|
+
if (color === "error") {
|
|
143
|
+
return border + t.fg(color, t.bold(label + " ")) + t.fg(color, detail) + suffix;
|
|
144
|
+
}
|
|
145
|
+
return border + t.fg(color, t.bold(label + " ")) + detail + suffix;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function addBorder(text: string, t: any, color?: string): string {
|
|
149
|
+
const borderColor = color ? t.fg(color, BORDER) : t.fg("border", BORDER);
|
|
150
|
+
return text.split("\n").map((l: string) => borderColor + l).join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function truncateDetail(detail: string): string {
|
|
154
|
+
const firstLine = detail.split("\n")[0] || "";
|
|
155
|
+
if (firstLine.length <= MAX_TITLE_LEN) return firstLine;
|
|
156
|
+
return firstLine.slice(0, MAX_TITLE_LEN - 1) + "…";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Ensure every line in text is at most maxLen characters, hard-break if needed */
|
|
160
|
+
function hardWrap(text: string, maxLen: number): string {
|
|
161
|
+
return text.split("\n").map((line) => {
|
|
162
|
+
if (line.length <= maxLen) return line;
|
|
163
|
+
const chunks: string[] = [];
|
|
164
|
+
for (let i = 0; i < line.length; i += maxLen) {
|
|
165
|
+
chunks.push(line.slice(i, i + maxLen));
|
|
166
|
+
}
|
|
167
|
+
return chunks.join("\n");
|
|
168
|
+
}).join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function splitLines(text: string): string[] {
|
|
172
|
+
if (!text) return [];
|
|
173
|
+
return text.replace(/\r/g, "").split("\n").map((line) => line.replace(/\t/g, " "));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function trimTrailingEmptyLines(lines: string[]): string[] {
|
|
177
|
+
const next = [...lines];
|
|
178
|
+
while (next.length > 0 && next[next.length - 1]?.trim().length === 0) {
|
|
179
|
+
next.pop();
|
|
180
|
+
}
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function collapseConsecutiveEmptyLines(lines: string[], maxAllowed: number): string[] {
|
|
185
|
+
if (maxAllowed <= 0) {
|
|
186
|
+
return lines.filter((line) => line.trim().length > 0);
|
|
187
|
+
}
|
|
188
|
+
const compacted: string[] = [];
|
|
189
|
+
let consecutiveEmpty = 0;
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
if (line.trim().length === 0) {
|
|
192
|
+
consecutiveEmpty++;
|
|
193
|
+
if (consecutiveEmpty > maxAllowed) continue;
|
|
194
|
+
} else {
|
|
195
|
+
consecutiveEmpty = 0;
|
|
196
|
+
}
|
|
197
|
+
compacted.push(line);
|
|
198
|
+
}
|
|
199
|
+
return compacted;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function prepareOutputLines(text: string, expanded: boolean): string[] {
|
|
203
|
+
const lines = trimTrailingEmptyLines(splitLines(text));
|
|
204
|
+
return expanded ? lines : collapseConsecutiveEmptyLines(lines, 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function countNonEmptyLines(lines: string[]): number {
|
|
208
|
+
return lines.filter((line) => line.trim().length > 0).length;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
|
|
212
|
+
return count === 1 ? singular : plural;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function shortenPath(inputPath: string | undefined): string {
|
|
216
|
+
if (!inputPath) return "";
|
|
217
|
+
const home = homedir();
|
|
218
|
+
return inputPath.startsWith(home) ? `~${inputPath.slice(home.length)}` : inputPath;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 末段为 SKILL.md 时返回技能名(父目录名)。pi 把技能当普通文件读,
|
|
223
|
+
* 只能靠文件名区分,路径里没有别的线索;不叫 SKILL.md 的文件一律不算技能。
|
|
224
|
+
*/
|
|
225
|
+
function skillNameFromPath(inputPath: unknown): string | undefined {
|
|
226
|
+
if (typeof inputPath !== "string" || !inputPath) return undefined;
|
|
227
|
+
const segments = inputPath.replace(/\\/g, "/").split("/").filter((s) => s.length > 0);
|
|
228
|
+
if (segments[segments.length - 1] !== "SKILL.md") return undefined;
|
|
229
|
+
return segments[segments.length - 2] || "skill";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function extractTextOutput(result: any): string {
|
|
233
|
+
const blocks = Array.isArray(result?.content) ? result.content : [];
|
|
234
|
+
return blocks
|
|
235
|
+
.filter((block: any) => block?.type === "text" && typeof block.text === "string")
|
|
236
|
+
.map((block: any) => block.text)
|
|
237
|
+
.join("\n");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function toRecord(value: unknown): Record<string, any> {
|
|
241
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- ANSI sanitization (adapted from pi-tool-display, MIT) ---
|
|
245
|
+
// Strip background SGR parameters from raw tool output so themed output
|
|
246
|
+
// does not get background-color artifacts, while foreground colors survive.
|
|
247
|
+
|
|
248
|
+
const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
|
|
249
|
+
const STYLE_RESET_PARAMS = [39, 22, 23, 24, 25, 27, 28, 29, 59] as const;
|
|
250
|
+
|
|
251
|
+
function expandSgrReset(param: number): readonly number[] | undefined {
|
|
252
|
+
return param === 0 ? STYLE_RESET_PARAMS : undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function toSgrParams(rawParams: string): number[] {
|
|
256
|
+
if (!rawParams.trim()) return [0];
|
|
257
|
+
return rawParams
|
|
258
|
+
.split(";")
|
|
259
|
+
.map((token) => Number.parseInt(token, 10))
|
|
260
|
+
.filter((value) => Number.isFinite(value));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readSgrColorSequence(params: readonly number[], index: number): number[] | undefined {
|
|
264
|
+
const param = params[index];
|
|
265
|
+
if (param !== 38 && param !== 48) return undefined;
|
|
266
|
+
const colorMode = params[index + 1];
|
|
267
|
+
if (colorMode === 5) {
|
|
268
|
+
const colorValue = params[index + 2];
|
|
269
|
+
return typeof colorValue === "number" && Number.isFinite(colorValue) ? [param, colorMode, colorValue] : undefined;
|
|
270
|
+
}
|
|
271
|
+
if (colorMode === 2) {
|
|
272
|
+
const [red, green, blue] = [params[index + 2], params[index + 3], params[index + 4]];
|
|
273
|
+
return [red, green, blue].every((v) => typeof v === "number" && Number.isFinite(v))
|
|
274
|
+
? [param, colorMode, red, green, blue]
|
|
275
|
+
: undefined;
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function stripBackgroundSgrParams(params: readonly number[]): number[] {
|
|
281
|
+
const sanitized: number[] = [];
|
|
282
|
+
for (let index = 0; index < params.length; index++) {
|
|
283
|
+
const param = params[index] ?? 0;
|
|
284
|
+
if (param === 0) {
|
|
285
|
+
sanitized.push(...(expandSgrReset(param) ?? []));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (param === 49 || (param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (param === 38 || param === 48) {
|
|
292
|
+
const sequence = readSgrColorSequence(params, index);
|
|
293
|
+
if (sequence) {
|
|
294
|
+
if (param === 38) sanitized.push(...sequence);
|
|
295
|
+
index += sequence.length - 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const advance = params[index + 1] === 5 ? 2 : params[index + 1] === 2 ? 4 : 0;
|
|
299
|
+
if (advance > 0) {
|
|
300
|
+
index += advance;
|
|
301
|
+
if (param === 38) sanitized.push(param);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
sanitized.push(param);
|
|
306
|
+
}
|
|
307
|
+
return sanitized;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sanitizeAnsiForThemedOutput(text: string): string {
|
|
311
|
+
if (!text || !text.includes("\x1b[")) return text;
|
|
312
|
+
return text.replace(ANSI_SGR_PATTERN, (_sequence, rawParams: string) => {
|
|
313
|
+
const parsed = toSgrParams(rawParams);
|
|
314
|
+
if (parsed.length === 0) return "";
|
|
315
|
+
const sanitized = stripBackgroundSgrParams(parsed);
|
|
316
|
+
if (sanitized.length === 0) return "";
|
|
317
|
+
return `\x1b[${sanitized.join(";")}m`;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isLikelyQuietCommand(command: string | undefined): boolean {
|
|
322
|
+
if (!command) return false;
|
|
323
|
+
const normalized = command.trim().toLowerCase();
|
|
324
|
+
const primarySegment = normalized
|
|
325
|
+
.split(/&&|\|\||;/)
|
|
326
|
+
.map((segment) => segment.trim())
|
|
327
|
+
.find((segment) => segment.length > 0);
|
|
328
|
+
if (!primarySegment) return false;
|
|
329
|
+
for (const prefix of QUIET_COMMAND_PREFIXES) {
|
|
330
|
+
if (primarySegment === prefix || primarySegment.startsWith(`${prefix} `)) return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function countTextLines(value: unknown): number {
|
|
336
|
+
return typeof value === "string" ? splitLines(value).length : 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function countEditLines(args: any): number {
|
|
340
|
+
const record = toRecord(args);
|
|
341
|
+
const edits = Array.isArray(record.edits) ? record.edits : [];
|
|
342
|
+
if (edits.length > 0) {
|
|
343
|
+
return edits.reduce<number>((total, edit) => total + countTextLines(toRecord(edit).newText), 0);
|
|
344
|
+
}
|
|
345
|
+
return countTextLines(record.newText);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function countWriteContentLines(value: unknown): number {
|
|
349
|
+
if (typeof value !== "string") return 0;
|
|
350
|
+
const lines = value.replace(/\r/g, "").split("\n");
|
|
351
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
352
|
+
return lines.length;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function getWriteContentSizeBytes(value: unknown): number {
|
|
356
|
+
return typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function expandHint(): string {
|
|
360
|
+
return keyHint("app.tools.expand", "to expand");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isToolError(r: any, c: any): boolean {
|
|
364
|
+
return c?.isError === true || r?.isError === true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function isTruncated(r: any): boolean {
|
|
368
|
+
return toRecord(toRecord(r?.details).truncation).truncated === true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Partial (in-progress) header: spinner + elapsed while executing, streamed text on expansion. */
|
|
372
|
+
function renderPartialText(
|
|
373
|
+
label: string,
|
|
374
|
+
detail: string,
|
|
375
|
+
parts: Array<{ text: string; color?: string }> | undefined,
|
|
376
|
+
t: any,
|
|
377
|
+
c: any,
|
|
378
|
+
): Text {
|
|
379
|
+
const text = c?.lastComponent instanceof Text ? c.lastComponent : new Text("", 0, 0);
|
|
380
|
+
const toolCallId = typeof c?.toolCallId === "string" ? c.toolCallId : undefined;
|
|
381
|
+
const animating = toolCallId !== undefined && c?.executionStarted === true;
|
|
382
|
+
|
|
383
|
+
const build = () => {
|
|
384
|
+
let spinnerAndElapsed = "";
|
|
385
|
+
if (animating && toolCallId !== undefined) {
|
|
386
|
+
const state = getOrCreateSpinnerState(toolCallId);
|
|
387
|
+
spinnerAndElapsed = t.fg("warning", SPINNER_CHARS[state.frame]) + " " + t.fg("muted", "· " + formatElapsed(Date.now() - state.start)) + " ";
|
|
388
|
+
}
|
|
389
|
+
const suffix = (parts ?? []).map((p) => " " + t.fg(p.color ?? "muted", p.text)).join("");
|
|
390
|
+
const det = detail ? truncateDetail(detail) : "";
|
|
391
|
+
text.setText(t.fg("dim", BORDER) + spinnerAndElapsed + t.bold(label + " ") + det + suffix);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
if (animating && toolCallId !== undefined) {
|
|
395
|
+
const state = getOrCreateSpinnerState(toolCallId);
|
|
396
|
+
if (!state.timer && typeof c?.invalidate === "function") {
|
|
397
|
+
state.timer = setInterval(() => {
|
|
398
|
+
if (spinnerStates.get(toolCallId) !== state) {
|
|
399
|
+
clearInterval(state.timer);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
state.frame = (state.frame + 1) % SPINNER_CHARS.length;
|
|
403
|
+
build();
|
|
404
|
+
c.invalidate();
|
|
405
|
+
}, SPINNER_INTERVAL_MS);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
build();
|
|
410
|
+
return text;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export default function (pi: any) {
|
|
414
|
+
const cwd = process.cwd();
|
|
415
|
+
|
|
416
|
+
// label 是工具的默认名;getDetail 可以按参数与渲染状态覆盖它(技能读取要显示成 [skill] <名字>)
|
|
417
|
+
type ToolDetail = { label?: string; detail: string; parts?: Array<{ text: string; color?: string }> };
|
|
418
|
+
|
|
419
|
+
function registerTool(
|
|
420
|
+
tool: any,
|
|
421
|
+
label: string,
|
|
422
|
+
getDetail: (a: any, t: any, c: any) => ToolDetail,
|
|
423
|
+
getSummary?: (r: any, o: any, t: any, a: any, isError: boolean) => string | undefined,
|
|
424
|
+
) {
|
|
425
|
+
pi.registerTool({
|
|
426
|
+
name: tool.name, label: tool.name,
|
|
427
|
+
description: tool.description,
|
|
428
|
+
parameters: tool.parameters,
|
|
429
|
+
renderShell: "self",
|
|
430
|
+
async execute(toolCallId: string, ...args: any[]) {
|
|
431
|
+
// renderResult 只在有 result 时被调用,不主动发一次空 partial 的话,
|
|
432
|
+
// 不产生流式输出的工具(read/edit/write)执行期间整行不可见。
|
|
433
|
+
// 内置 bash 工具在自己的 execute 开头做同样的事。
|
|
434
|
+
const onUpdate = args[2];
|
|
435
|
+
if (typeof onUpdate === "function") {
|
|
436
|
+
onUpdate({ content: [], details: undefined });
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
return await tool.execute(toolCallId, ...args);
|
|
440
|
+
} finally {
|
|
441
|
+
stopSpinner(toolCallId);
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
renderCall(a: any, t: any, c: any) {
|
|
445
|
+
// 只在参数流式期间出调用行:那时还没有 partial result,renderResult 不会被调,
|
|
446
|
+
// 否则模型产出参数时界面整段空白。执行开始或已有 result 时让 renderResult 独占,
|
|
447
|
+
// 也包括 /reload 重放历史(那时不会再发 tool_execution_start)。
|
|
448
|
+
if (c?.executionStarted || !c?.isPartial) return new Container();
|
|
449
|
+
const { label: customLabel, detail, parts } = getDetail(a || {}, t, c);
|
|
450
|
+
const text = c?.lastComponent instanceof Text ? c.lastComponent : new Text("", 0, 0);
|
|
451
|
+
const suffix = (parts ?? []).map((p) => " " + t.fg(p.color ?? "muted", p.text)).join("");
|
|
452
|
+
text.setText(
|
|
453
|
+
t.fg("dim", BORDER) + t.bold((customLabel ?? label) + " ") + (detail ? truncateDetail(detail) : "") + suffix,
|
|
454
|
+
);
|
|
455
|
+
return text;
|
|
456
|
+
},
|
|
457
|
+
renderResult(r: any, o: any, t: any, c: any) {
|
|
458
|
+
const { label: customLabel, detail, parts } = getDetail(c?.args || {}, t, c);
|
|
459
|
+
const shownLabel = customLabel ?? label;
|
|
460
|
+
const err = isToolError(r, c);
|
|
461
|
+
// Partial (in-progress): spinner + any streamed text
|
|
462
|
+
if (o.isPartial) {
|
|
463
|
+
const streamed = r.content?.filter((x: any) => x?.type === "text").map((x: any) => x.text).join("") || "";
|
|
464
|
+
if (!o.expanded || (!detail && !streamed)) {
|
|
465
|
+
return renderPartialText(shownLabel, detail, parts, t, c);
|
|
466
|
+
}
|
|
467
|
+
const lines = [];
|
|
468
|
+
if (detail) {
|
|
469
|
+
lines.push(addBorder(t.bold(shownLabel + " ") + hardWrap(detail, MAX_CONTENT_LINE_LEN), t, "dim"));
|
|
470
|
+
}
|
|
471
|
+
if (streamed) {
|
|
472
|
+
lines.push(addBorder(sanitizeAnsiForThemedOutput(streamed), t, "dim"));
|
|
473
|
+
}
|
|
474
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
475
|
+
}
|
|
476
|
+
stopSpinner(c?.toolCallId); // ensure no timer survives a cancelled call
|
|
477
|
+
const tailParts: Array<{ text: string; color?: string }> = [...(parts ?? [])];
|
|
478
|
+
if (isTruncated(r)) tailParts.push({ text: "• truncated", color: "warning" });
|
|
479
|
+
const color = err ? "error" : "success";
|
|
480
|
+
if (!o.expanded) {
|
|
481
|
+
const title = titleLine(shownLabel, truncateDetail(detail), t, color, tailParts);
|
|
482
|
+
const summary = getSummary?.(r, o, t, c?.args || {}, err);
|
|
483
|
+
if (summary) {
|
|
484
|
+
return new Text(title + "\n" + t.fg("muted", " " + summary), 0, 0);
|
|
485
|
+
}
|
|
486
|
+
return new Text(title, 0, 0);
|
|
487
|
+
}
|
|
488
|
+
// Expanded: full detail + output, all lines use state color (dim/success/error)
|
|
489
|
+
const contentLines = [];
|
|
490
|
+
if (detail) {
|
|
491
|
+
contentLines.push(addBorder(t.bold(shownLabel + " ") + hardWrap(detail, MAX_CONTENT_LINE_LEN), t, color));
|
|
492
|
+
}
|
|
493
|
+
const partsText = r.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text) || [];
|
|
494
|
+
if (r.details?.diff) partsText.push(r.details.diff);
|
|
495
|
+
for (const p of partsText) {
|
|
496
|
+
contentLines.push(addBorder(prepareOutputLines(sanitizeAnsiForThemedOutput(p), o.expanded).join("\n"), t, color));
|
|
497
|
+
}
|
|
498
|
+
return new Text(contentLines.join("\n"), 0, 0);
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
registerTool(createReadTool(cwd), "read", (a, _t, c) => {
|
|
504
|
+
const rawPath = typeof a.path === "string" ? a.path : "";
|
|
505
|
+
const offset = typeof a.offset === "number" ? a.offset : undefined;
|
|
506
|
+
const limit = typeof a.limit === "number" ? a.limit : undefined;
|
|
507
|
+
let range: string | undefined;
|
|
508
|
+
if (offset !== undefined || limit !== undefined) {
|
|
509
|
+
const from = offset ?? 1;
|
|
510
|
+
const to = limit !== undefined ? from + limit - 1 : undefined;
|
|
511
|
+
range = to !== undefined ? `:${from}-${to}` : `:${from}`;
|
|
512
|
+
}
|
|
513
|
+
const skill = skillNameFromPath(rawPath);
|
|
514
|
+
// 技能折叠成单行 [skill] <名字>,第二行摘要由 getSummary 一起省掉。展开后退回 read + 路径,
|
|
515
|
+
// 保留文件位置信息。展开提示只挂终态:参数流式、执行中、结果流式都不显示;
|
|
516
|
+
// 出错时没有内容可展开,也不显示。行范围照旧沿用警告色显示。
|
|
517
|
+
if (skill && c?.expanded !== true) {
|
|
518
|
+
const finalState = c?.isPartial !== true && c?.isError !== true;
|
|
519
|
+
const parts = [
|
|
520
|
+
...(range ? [{ text: range }] : []),
|
|
521
|
+
...(finalState ? [{ text: `(${expandHint()})` }] : []),
|
|
522
|
+
];
|
|
523
|
+
return { label: `[skill] ${skill}`, detail: "", parts: parts.length > 0 ? parts : undefined };
|
|
524
|
+
}
|
|
525
|
+
return { detail: shortenPath(rawPath), parts: range ? [{ text: range }] : undefined };
|
|
526
|
+
}, (r, o, t, _a, _err) => {
|
|
527
|
+
if (_err) return undefined;
|
|
528
|
+
// 技能的展开提示已经在标题行里,不再出第二行摘要
|
|
529
|
+
if (skillNameFromPath(_a?.path)) return undefined;
|
|
530
|
+
const lineCount = prepareOutputLines(extractTextOutput(r), true).length;
|
|
531
|
+
if (lineCount === 0) return "↳ (empty)";
|
|
532
|
+
return `↳ loaded ${lineCount} ${pluralize(lineCount, "line")} · ${expandHint()}`;
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
registerTool(createBashTool(cwd, {
|
|
536
|
+
spawnHook: bashEnvHook,
|
|
537
|
+
operations: createBashTimeoutOps(createLocalBashOperations()),
|
|
538
|
+
}), "$", (a) => {
|
|
539
|
+
return { detail: a.command || "" };
|
|
540
|
+
}, (r, o, t, a, err) => {
|
|
541
|
+
if (err) return "↳ command failed";
|
|
542
|
+
const lines = prepareOutputLines(extractTextOutput(r), false);
|
|
543
|
+
if (lines.length === 0) {
|
|
544
|
+
return isLikelyQuietCommand(toRecord(a).command)
|
|
545
|
+
? "↳ command completed (no output)"
|
|
546
|
+
: "↳ (no output)";
|
|
547
|
+
}
|
|
548
|
+
const count = countNonEmptyLines(lines);
|
|
549
|
+
return `↳ ${count} ${pluralize(count, "line")} returned · ${expandHint()}`;
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
registerTool(createEditTool(cwd), "edit", (a) => {
|
|
553
|
+
const lineCount = countEditLines(a);
|
|
554
|
+
const parts = lineCount > 0 ? [{ text: `(${lineCount} ${pluralize(lineCount, "line")})` }] : undefined;
|
|
555
|
+
return { detail: shortenPath(a.path || ""), parts };
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
registerTool(createWriteTool(cwd), "write", (a) => {
|
|
559
|
+
const content = typeof a.content === "string" ? a.content : undefined;
|
|
560
|
+
let parts;
|
|
561
|
+
if (content !== undefined) {
|
|
562
|
+
const lineCount = countWriteContentLines(content);
|
|
563
|
+
parts = [{ text: `(${lineCount} ${pluralize(lineCount, "line")} • ${formatSize(getWriteContentSizeBytes(content))})` }];
|
|
564
|
+
}
|
|
565
|
+
return { detail: shortenPath(a.path || ""), parts };
|
|
566
|
+
});
|
|
567
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liuxincuit/pi-compact-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "覆盖内置 read/bash/edit/write,提供紧凑渲染与默认超时",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"renderer",
|
|
8
|
+
"compact",
|
|
9
|
+
"tui"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "liuxincuit",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/liuxincuit/pi-packages.git",
|
|
16
|
+
"directory": "packages/compact-tools"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
20
|
+
"@earendil-works/pi-tui": "*"
|
|
21
|
+
},
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./index.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.ts",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"registry": "https://registry.npmjs.org/",
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|