pi-extensions-tool-display 0.1.1 → 0.2.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 +13 -3
- package/README.zh-CN.md +14 -4
- package/config.example.json +40 -0
- package/index.ts +6 -0
- package/package.json +16 -5
- package/src/agent-dir.ts +32 -0
- package/src/ansi-utils.ts +116 -0
- package/src/bash-display.ts +191 -0
- package/src/capabilities.ts +85 -0
- package/src/config-modal.ts +540 -0
- package/src/config-store.ts +303 -0
- package/src/debug-logger.ts +150 -0
- package/src/diff-presentation.ts +72 -0
- package/src/diff-renderer.ts +2517 -0
- package/src/disposable.ts +34 -0
- package/src/extension-lifecycle.ts +14 -0
- package/src/index.ts +126 -86
- package/src/line-width-safety.ts +93 -0
- package/src/modal-icons.ts +57 -0
- package/src/pending-diff-preview.ts +371 -0
- package/src/presets.ts +108 -0
- package/src/render-utils.ts +186 -0
- package/src/result-render-middleware.ts +98 -0
- package/src/settings-inspector-modal.ts +475 -0
- package/src/thinking-label.ts +343 -0
- package/src/tool-metadata.ts +139 -0
- package/src/tool-overrides.ts +2237 -0
- package/src/types.ts +105 -0
- package/src/user-message-box-markdown.ts +64 -0
- package/src/user-message-box-native.ts +66 -0
- package/src/user-message-box-patch.ts +72 -0
- package/src/user-message-box-renderer.ts +419 -0
- package/src/user-message-box-utils.ts +106 -0
- package/src/write-display-utils.ts +31 -0
- package/src/zellij-modal.ts +1027 -0
- package/tool-display-api-consumer.d.ts +68 -0
- package/tool-display-api-consumer.js +82 -0
- package/tsconfig.json +1 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-extensions-tool-display
|
|
2
2
|
|
|
3
|
-
`pi-extensions-tool-display` provides the shared tool-result rendering protocol and component helpers used by Pi extensions.
|
|
3
|
+
`pi-extensions-tool-display` provides the Pi tool-display host plus the shared tool-result rendering protocol and component helpers used by Pi extensions.
|
|
4
4
|
|
|
5
5
|
It provides:
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ It provides:
|
|
|
8
8
|
- a pending registration queue for when the Pi display host loads later;
|
|
9
9
|
- safe component detection and a helper for appending an audit panel to the original tool result.
|
|
10
10
|
|
|
11
|
-
It is
|
|
11
|
+
It is also a standalone Pi extension. Install or include this package in Pi's package list to load the actual tool-display host. Feature packages such as `pi-distill` and `pi-tool-supervisor` initialize the same host through this runtime dependency, so installing either feature package is sufficient and does not require a second host package.
|
|
12
12
|
|
|
13
13
|
## Boundary
|
|
14
14
|
|
|
@@ -18,7 +18,17 @@ This package owns the display protocol and generic component composition only. E
|
|
|
18
18
|
|
|
19
19
|
The display integration is designed to work with the MIT-licensed [`MasuRii/pi-tool-display`](https://github.com/MasuRii/pi-tool-display), the original full-featured Pi tool-display project. We thank MasuRii for that work.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The host implementation is based on the MIT-licensed upstream project and is now maintained in this package so consumers do not need a second separately installed host package.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Install this package directly when you want only the tool-display host:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install npm:pi-extensions-tool-display
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`pi-distill` and `pi-tool-supervisor` also initialize the host automatically when they are loaded.
|
|
22
32
|
|
|
23
33
|
## Development
|
|
24
34
|
|
package/README.zh-CN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-extensions-tool-display
|
|
2
2
|
|
|
3
|
-
`pi-extensions-tool-display`
|
|
3
|
+
`pi-extensions-tool-display` 提供 Pi 工具展示宿主,以及扩展共享的工具结果展示协议和组件工具包。
|
|
4
4
|
|
|
5
5
|
它提供:
|
|
6
6
|
|
|
@@ -8,17 +8,27 @@
|
|
|
8
8
|
- Pi 展示宿主尚未加载时的 pending 注册队列;
|
|
9
9
|
- 安全识别组件,以及把审计面板追加到原始工具结果后的通用组件组合。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
它同时是一个独立的 Pi 扩展。可以把这个包直接加入 Pi 的 package 列表加载工具展示宿主;`pi-distill`、`pi-tool-supervisor` 等功能包加载时也会通过这个运行时依赖自动初始化同一个宿主,不需要额外安装第二份宿主包。
|
|
12
12
|
|
|
13
13
|
## 设计边界
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
这个包负责实际工具展示、展示协议和通用组件组合。具体业务的审计数据提取、文案、状态和布局仍由调用方维护,避免把不同扩展耦合在一起。
|
|
16
16
|
|
|
17
17
|
## 上游引用
|
|
18
18
|
|
|
19
19
|
本包的展示集成设计用于兼容 MIT 许可的 [`MasuRii/pi-tool-display`](https://github.com/MasuRii/pi-tool-display),这是 Pi 生态中原始的完整工具展示项目。感谢 MasuRii 的工作。
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
宿主实现基于 MIT 许可的上游项目,并迁移到本包中统一维护,消费者不再需要额外安装第二个宿主包。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
如果只需要工具展示宿主,可以直接安装:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install npm:pi-extensions-tool-display
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
安装 `pi-distill` 或 `pi-tool-supervisor` 时,宿主也会由共享运行时自动初始化。
|
|
22
32
|
|
|
23
33
|
## 开发
|
|
24
34
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"debug": false,
|
|
4
|
+
"registerToolOverrides": {
|
|
5
|
+
"read": true,
|
|
6
|
+
"grep": true,
|
|
7
|
+
"find": true,
|
|
8
|
+
"ls": true,
|
|
9
|
+
"bash": true,
|
|
10
|
+
"edit": true,
|
|
11
|
+
"write": true
|
|
12
|
+
},
|
|
13
|
+
"customToolOverrides": {
|
|
14
|
+
"ide_find_symbol": {
|
|
15
|
+
"enabled": false,
|
|
16
|
+
"kind": "generic",
|
|
17
|
+
"outputMode": "summary"
|
|
18
|
+
},
|
|
19
|
+
"custom_mcp_gateway": {
|
|
20
|
+
"enabled": false,
|
|
21
|
+
"kind": "mcp",
|
|
22
|
+
"outputMode": "summary"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"enableNativeUserMessageBox": true,
|
|
26
|
+
"readOutputMode": "hidden",
|
|
27
|
+
"searchOutputMode": "hidden",
|
|
28
|
+
"mcpOutputMode": "hidden",
|
|
29
|
+
"previewLines": 8,
|
|
30
|
+
"expandedPreviewMaxLines": 4000,
|
|
31
|
+
"bashOutputMode": "opencode",
|
|
32
|
+
"bashCollapsedLines": 10,
|
|
33
|
+
"diffViewMode": "auto",
|
|
34
|
+
"diffIndicatorMode": "bars",
|
|
35
|
+
"diffSplitMinWidth": 120,
|
|
36
|
+
"diffCollapsedLines": 24,
|
|
37
|
+
"diffWordWrap": true,
|
|
38
|
+
"showTruncationHints": false,
|
|
39
|
+
"showRtkCompactionHints": false
|
|
40
|
+
}
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-extensions-tool-display",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pi tool display host and shared result-rendering protocol for extensions",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
6
|
+
"main": "./index.ts",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./
|
|
8
|
+
".": "./index.ts"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"tool-display-api-consumer.js",
|
|
13
|
+
"tool-display-api-consumer.d.ts",
|
|
11
14
|
"src",
|
|
15
|
+
"config.example.json",
|
|
12
16
|
"README.md",
|
|
13
17
|
"README.zh-CN.md",
|
|
14
18
|
"tsconfig.json"
|
|
15
19
|
],
|
|
16
20
|
"scripts": {
|
|
17
|
-
"test": "tsx --test tests
|
|
21
|
+
"test": "tsx --test tests/*.test.ts",
|
|
18
22
|
"typecheck": "tsc --noEmit --pretty false",
|
|
19
23
|
"build": "npm run typecheck",
|
|
20
24
|
"check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
|
|
@@ -43,9 +47,16 @@
|
|
|
43
47
|
"tool-display"
|
|
44
48
|
],
|
|
45
49
|
"peerDependencies": {
|
|
50
|
+
"@earendil-works/pi-coding-agent": ">=0.80.0 <0.81.0",
|
|
46
51
|
"@earendil-works/pi-tui": ">=0.80.0 <0.81.0"
|
|
47
52
|
},
|
|
53
|
+
"pi": {
|
|
54
|
+
"extensions": [
|
|
55
|
+
"./index.ts"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
48
58
|
"devDependencies": {
|
|
59
|
+
"@earendil-works/pi-coding-agent": "0.80.10",
|
|
49
60
|
"@earendil-works/pi-tui": "0.80.10",
|
|
50
61
|
"@types/node": "24.12.4",
|
|
51
62
|
"tsx": "4.23.1",
|
package/src/agent-dir.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const PI_AGENT_DIR_ENV_VAR = "PI_CODING_AGENT_DIR";
|
|
5
|
+
|
|
6
|
+
interface AgentDirEnvironment {
|
|
7
|
+
[name: string]: string | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function expandHomeDirectory(configuredDir: string, homeDirectory: string): string {
|
|
11
|
+
if (configuredDir === "~") {
|
|
12
|
+
return homeDirectory;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (configuredDir.startsWith("~/") || configuredDir.startsWith("~\\")) {
|
|
16
|
+
return join(homeDirectory, configuredDir.slice(2));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return configuredDir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolvePiAgentDir(
|
|
23
|
+
env: AgentDirEnvironment = process.env,
|
|
24
|
+
homeDirectory = homedir(),
|
|
25
|
+
): string {
|
|
26
|
+
const configuredDir = env[PI_AGENT_DIR_ENV_VAR];
|
|
27
|
+
if (!configuredDir) {
|
|
28
|
+
return join(homeDirectory, ".pi", "agent");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return expandHomeDirectory(configuredDir, homeDirectory);
|
|
32
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
|
|
2
|
+
const STYLE_RESET_PARAMS = [39, 22, 23, 24, 25, 27, 28, 29, 59] as const;
|
|
3
|
+
|
|
4
|
+
export { ANSI_SGR_PATTERN, STYLE_RESET_PARAMS };
|
|
5
|
+
|
|
6
|
+
export function expandSgrReset(param: number): readonly number[] | undefined {
|
|
7
|
+
return param === 0 ? STYLE_RESET_PARAMS : undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function toSgrParams(rawParams: string): number[] {
|
|
11
|
+
if (!rawParams.trim()) {
|
|
12
|
+
return [0];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parsed = rawParams
|
|
16
|
+
.split(";")
|
|
17
|
+
.map((token) => Number.parseInt(token, 10))
|
|
18
|
+
.filter((value) => Number.isFinite(value));
|
|
19
|
+
|
|
20
|
+
return parsed.length > 0 ? parsed : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isFiniteSgrParam(value: number | undefined): value is number {
|
|
24
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readSgrColorSequence(params: number[], index: number): number[] | undefined {
|
|
28
|
+
const param = params[index];
|
|
29
|
+
if (param !== 38 && param !== 48) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const colorMode = params[index + 1];
|
|
34
|
+
if (colorMode === 5) {
|
|
35
|
+
const colorValue = params[index + 2];
|
|
36
|
+
return isFiniteSgrParam(colorValue) ? [param, colorMode, colorValue] : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (colorMode === 2) {
|
|
40
|
+
const red = params[index + 2];
|
|
41
|
+
const green = params[index + 3];
|
|
42
|
+
const blue = params[index + 4];
|
|
43
|
+
return isFiniteSgrParam(red) && isFiniteSgrParam(green) && isFiniteSgrParam(blue)
|
|
44
|
+
? [param, colorMode, red, green, blue]
|
|
45
|
+
: undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stripBackgroundSgrParams(params: readonly number[]): number[] {
|
|
52
|
+
const sanitized: number[] = [];
|
|
53
|
+
|
|
54
|
+
for (let index = 0; index < params.length; index++) {
|
|
55
|
+
const param = params[index] ?? 0;
|
|
56
|
+
|
|
57
|
+
if (param === 0) {
|
|
58
|
+
sanitized.push(...expandSgrReset(param)!);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (param === 49 || (param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (param === 38 || param === 48) {
|
|
67
|
+
const sequence = readSgrColorSequence(params as number[], index);
|
|
68
|
+
if (sequence) {
|
|
69
|
+
if (param === 38) {
|
|
70
|
+
sanitized.push(...sequence);
|
|
71
|
+
}
|
|
72
|
+
index += sequence.length - 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const advance = params[index + 1] === 5 ? 2 : params[index + 1] === 2 ? 4 : 0;
|
|
76
|
+
if (advance > 0) {
|
|
77
|
+
index += advance;
|
|
78
|
+
if (param === 38) {
|
|
79
|
+
sanitized.push(param);
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
sanitized.push(param);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return sanitized;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function filterSgrSequences(
|
|
92
|
+
text: string,
|
|
93
|
+
filter: (params: number[]) => number[],
|
|
94
|
+
): string {
|
|
95
|
+
if (!text || !text.includes("\x1b[")) {
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return text.replace(ANSI_SGR_PATTERN, (_sequence, rawParams: string) => {
|
|
100
|
+
const parsed = toSgrParams(rawParams);
|
|
101
|
+
if (parsed.length === 0) {
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sanitized = filter(parsed);
|
|
106
|
+
if (sanitized.length === 0) {
|
|
107
|
+
return "";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return `\x1b[${sanitized.join(";")}m`;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function sanitizeAnsiForThemedOutput(text: string): string {
|
|
115
|
+
return filterSgrSequences(text, stripBackgroundSgrParams);
|
|
116
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { registerCleanup, registerTimer } from "./disposable.js";
|
|
3
|
+
|
|
4
|
+
const BASH_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
5
|
+
const BASH_SPINNER_INTERVAL_MS = 200;
|
|
6
|
+
const BASH_SPINNER_STATE_KEY = "__piToolDisplayBashSpinner";
|
|
7
|
+
const BASH_SPINNER_TOOL_CALL_ID_KEY = "__piToolDisplayBashSpinnerToolCallId";
|
|
8
|
+
|
|
9
|
+
interface BashCallArgs {
|
|
10
|
+
command?: string;
|
|
11
|
+
commandPrefix?: string;
|
|
12
|
+
shellPath?: string;
|
|
13
|
+
timeout?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface BashCallRenderTheme {
|
|
17
|
+
fg(color: string, text: string): string;
|
|
18
|
+
bold(text: string): string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface BashSpinnerState {
|
|
22
|
+
frameIndex: number;
|
|
23
|
+
startedAt?: number;
|
|
24
|
+
timer?: ReturnType<typeof setInterval>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface BashSpinnerStateCarrier {
|
|
28
|
+
[BASH_SPINNER_STATE_KEY]?: BashSpinnerState;
|
|
29
|
+
[BASH_SPINNER_TOOL_CALL_ID_KEY]?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface BashCallRenderContextLike {
|
|
33
|
+
executionStarted: boolean;
|
|
34
|
+
isPartial: boolean;
|
|
35
|
+
invalidate?: () => void;
|
|
36
|
+
lastComponent?: unknown;
|
|
37
|
+
state?: unknown;
|
|
38
|
+
toolCallId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const spinnerStatesByToolCallId = new Map<string, BashSpinnerState>();
|
|
42
|
+
let nextSyntheticToolCallId = 0;
|
|
43
|
+
|
|
44
|
+
function toStateCarrier(value: unknown): BashSpinnerStateCarrier | undefined {
|
|
45
|
+
if (!value || typeof value !== "object") {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
return value as BashSpinnerStateCarrier;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getSyntheticToolCallId(carrier: BashSpinnerStateCarrier | undefined): string | undefined {
|
|
52
|
+
if (!carrier) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!carrier[BASH_SPINNER_TOOL_CALL_ID_KEY]) {
|
|
57
|
+
carrier[BASH_SPINNER_TOOL_CALL_ID_KEY] = `state:${++nextSyntheticToolCallId}`;
|
|
58
|
+
}
|
|
59
|
+
return carrier[BASH_SPINNER_TOOL_CALL_ID_KEY];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getToolCallId(context: BashCallRenderContextLike): string | undefined {
|
|
63
|
+
if (typeof context.toolCallId === "string" && context.toolCallId.trim().length > 0) {
|
|
64
|
+
return context.toolCallId;
|
|
65
|
+
}
|
|
66
|
+
return getSyntheticToolCallId(toStateCarrier(context.state));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getOrCreateSpinnerState(
|
|
70
|
+
toolCallId: string | undefined,
|
|
71
|
+
carrier: BashSpinnerStateCarrier | undefined,
|
|
72
|
+
): BashSpinnerState | undefined {
|
|
73
|
+
if (!toolCallId) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let state = spinnerStatesByToolCallId.get(toolCallId);
|
|
78
|
+
if (!state) {
|
|
79
|
+
state = { frameIndex: 0 };
|
|
80
|
+
spinnerStatesByToolCallId.set(toolCallId, state);
|
|
81
|
+
}
|
|
82
|
+
if (carrier) {
|
|
83
|
+
carrier[BASH_SPINNER_STATE_KEY] = state;
|
|
84
|
+
}
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stopSpinner(toolCallId: string | undefined, state: BashSpinnerState | undefined): void {
|
|
89
|
+
if (!state) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (state.timer) {
|
|
94
|
+
clearInterval(state.timer);
|
|
95
|
+
state.timer = undefined;
|
|
96
|
+
}
|
|
97
|
+
state.frameIndex = 0;
|
|
98
|
+
state.startedAt = undefined;
|
|
99
|
+
if (toolCallId) {
|
|
100
|
+
spinnerStatesByToolCallId.delete(toolCallId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatElapsed(elapsedMs: number): string {
|
|
105
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
106
|
+
if (totalSeconds < 60) {
|
|
107
|
+
return `${totalSeconds}s`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
111
|
+
const seconds = totalSeconds % 60;
|
|
112
|
+
if (totalMinutes < 60) {
|
|
113
|
+
return `${totalMinutes}m ${seconds}s`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
117
|
+
const minutes = totalMinutes % 60;
|
|
118
|
+
return `${hours}h ${minutes}m`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isDefaultShellPath(shellPath: string): boolean {
|
|
122
|
+
const normalized = shellPath.trim().replace(/\\/g, "/").toLowerCase();
|
|
123
|
+
const basename = normalized.split("/").pop() || normalized;
|
|
124
|
+
return basename === "bash" || basename === "cmd.exe";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildCommandDisplay(args: BashCallArgs): string {
|
|
128
|
+
const command =
|
|
129
|
+
typeof args.command === "string" && args.command.trim().length > 0
|
|
130
|
+
? args.command
|
|
131
|
+
: "...";
|
|
132
|
+
const prefix =
|
|
133
|
+
typeof args.commandPrefix === "string" && args.commandPrefix.trim().length > 0
|
|
134
|
+
? args.commandPrefix.trim()
|
|
135
|
+
: "";
|
|
136
|
+
return prefix ? `${prefix} ${command}` : command;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildBashCallText(
|
|
140
|
+
args: BashCallArgs,
|
|
141
|
+
theme: BashCallRenderTheme,
|
|
142
|
+
spinnerFrame?: string,
|
|
143
|
+
elapsedMs?: number,
|
|
144
|
+
): string {
|
|
145
|
+
const commandDisplay = buildCommandDisplay(args);
|
|
146
|
+
const shellSuffix =
|
|
147
|
+
typeof args.shellPath === "string" &&
|
|
148
|
+
args.shellPath.trim().length > 0 &&
|
|
149
|
+
!isDefaultShellPath(args.shellPath)
|
|
150
|
+
? theme.fg("muted", ` [shell: ${args.shellPath}]`)
|
|
151
|
+
: "";
|
|
152
|
+
const timeoutSuffix = args.timeout
|
|
153
|
+
? theme.fg("muted", ` (timeout ${args.timeout}s)`)
|
|
154
|
+
: "";
|
|
155
|
+
const spinnerPrefix = spinnerFrame ? `${theme.fg("warning", `${spinnerFrame} `)}` : "";
|
|
156
|
+
const elapsedSuffix =
|
|
157
|
+
spinnerFrame && elapsedMs !== undefined
|
|
158
|
+
? theme.fg("muted", ` · ${formatElapsed(elapsedMs)}`)
|
|
159
|
+
: "";
|
|
160
|
+
return `${spinnerPrefix}${theme.fg("toolTitle", theme.bold("$"))} ${theme.fg("accent", commandDisplay)}${shellSuffix}${timeoutSuffix}${elapsedSuffix}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function renderBashCall(
|
|
164
|
+
args: BashCallArgs,
|
|
165
|
+
theme: BashCallRenderTheme,
|
|
166
|
+
context: BashCallRenderContextLike,
|
|
167
|
+
): Text {
|
|
168
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
169
|
+
const carrier = toStateCarrier(context.state);
|
|
170
|
+
const toolCallId = getToolCallId(context);
|
|
171
|
+
const spinnerState = getOrCreateSpinnerState(toolCallId, carrier);
|
|
172
|
+
const shouldSpin = context.executionStarted && context.isPartial;
|
|
173
|
+
|
|
174
|
+
if (!shouldSpin) {
|
|
175
|
+
stopSpinner(toolCallId, spinnerState);
|
|
176
|
+
text.setText(buildBashCallText(args, theme));
|
|
177
|
+
return text;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Bash 总结模型执行期间工具会长时间保持 partial。动态 spinner 会不断修改
|
|
181
|
+
// Text,并触发 TUI 重绘,即使不显式 invalidate 也可能造成闪烁。因此只显示
|
|
182
|
+
// 一个静态执行标记,彻底避免定时器和动画带来的重绘。
|
|
183
|
+
if (spinnerState) {
|
|
184
|
+
spinnerState.startedAt ??= Date.now();
|
|
185
|
+
if (spinnerState.timer) {
|
|
186
|
+
stopSpinner(toolCallId, spinnerState);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
text.setText(buildBashCallText(args, theme, spinnerState ? "⏳" : undefined));
|
|
190
|
+
return text;
|
|
191
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolvePiAgentDir } from "./agent-dir.js";
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { logToolDisplayDebug } from "./debug-logger.js";
|
|
6
|
+
import { isMcpToolCandidate } from "./tool-metadata.js";
|
|
7
|
+
import type { ToolDisplayConfig } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export interface ToolDisplayCapabilities {
|
|
10
|
+
hasMcpTooling: boolean;
|
|
11
|
+
hasRtkOptimizer: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasMcpTooling(pi: ExtensionAPI): boolean {
|
|
15
|
+
try {
|
|
16
|
+
const allTools = pi.getAllTools();
|
|
17
|
+
return allTools.some((tool) => isMcpToolCandidate(tool));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
logToolDisplayDebug("MCP capability detection failed.", error);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasRtkCommand(pi: ExtensionAPI): boolean {
|
|
25
|
+
try {
|
|
26
|
+
const commands = pi.getCommands();
|
|
27
|
+
return commands.some((command) => typeof command.name === "string" && (command.name === "rtk" || command.name.startsWith("rtk-")));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
logToolDisplayDebug("RTK command capability detection failed.", error);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rtkPathProbeCache = new Map<string, { fingerprint: string; exists: boolean }>();
|
|
35
|
+
|
|
36
|
+
function getPathFingerprint(path: string): string {
|
|
37
|
+
try {
|
|
38
|
+
const stats = statSync(path);
|
|
39
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
40
|
+
} catch {
|
|
41
|
+
return "missing";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cachedPathExists(path: string): boolean {
|
|
46
|
+
const fingerprint = getPathFingerprint(path);
|
|
47
|
+
const cached = rtkPathProbeCache.get(path);
|
|
48
|
+
if (cached && cached.fingerprint === fingerprint) {
|
|
49
|
+
return cached.exists;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let exists = false;
|
|
53
|
+
try {
|
|
54
|
+
exists = existsSync(path);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logToolDisplayDebug(`RTK capability path probe failed for ${path}.`, error);
|
|
57
|
+
}
|
|
58
|
+
rtkPathProbeCache.set(path, { fingerprint, exists });
|
|
59
|
+
return exists;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hasRtkExtensionPath(cwd: string): boolean {
|
|
63
|
+
const candidates = [join(resolvePiAgentDir(), "extensions", "pi-rtk-optimizer"), join(cwd, ".pi", "extensions", "pi-rtk-optimizer")];
|
|
64
|
+
|
|
65
|
+
return candidates.some((candidate) => cachedPathExists(candidate));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function detectToolDisplayCapabilities(pi: ExtensionAPI, cwd: string): ToolDisplayCapabilities {
|
|
69
|
+
return {
|
|
70
|
+
hasMcpTooling: hasMcpTooling(pi),
|
|
71
|
+
hasRtkOptimizer: hasRtkCommand(pi) || hasRtkExtensionPath(cwd),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function applyCapabilityConfigGuards(
|
|
76
|
+
config: ToolDisplayConfig,
|
|
77
|
+
capabilities: ToolDisplayCapabilities,
|
|
78
|
+
): ToolDisplayConfig {
|
|
79
|
+
return {
|
|
80
|
+
...config,
|
|
81
|
+
registerToolOverrides: { ...config.registerToolOverrides },
|
|
82
|
+
mcpOutputMode: config.mcpOutputMode,
|
|
83
|
+
showRtkCompactionHints: capabilities.hasRtkOptimizer ? config.showRtkCompactionHints : false,
|
|
84
|
+
};
|
|
85
|
+
}
|