pi-compact-tools 0.4.2 → 0.5.6
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 -21
- package/README.md +21 -14
- package/examples/compact-tools-2.json +0 -1
- package/examples/compact-tools.json +0 -1
- package/extensions/compact-tools-config.ts +163 -0
- package/extensions/compact-tools-core.ts +0 -1
- package/extensions/compact-tools-input.ts +8 -0
- package/extensions/compact-tools-invocation.ts +59 -0
- package/extensions/compact-tools-layout.ts +115 -0
- package/extensions/compact-tools-runtime.ts +187 -0
- package/extensions/compact-tools-types.ts +39 -0
- package/extensions/compact-tools.ts +163 -584
- package/package.json +64 -64
- package/schemas/compact-tools.schema.json +1 -8
- package/scripts/check-resources.mjs +6 -0
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Dong Hyeong Lee
|
|
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.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dong Hyeong Lee
|
|
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
|
@@ -22,7 +22,7 @@ The spinner animates during execution, then changes to a duration-aware completi
|
|
|
22
22
|
|
|
23
23
|
### Initially visible edit diffs
|
|
24
24
|
|
|
25
|
-
Completed `edit` rows show their full diff initially by default. The per-tool `auto_compact` setting controls which tools start
|
|
25
|
+
Completed `edit` rows show their full diff initially by default. The per-tool `auto_compact` setting controls which tools start with their result hidden without disabling click or `Ctrl+O` interaction.
|
|
26
26
|
|
|
27
27
|

|
|
28
28
|
|
|
@@ -31,9 +31,10 @@ Completed `edit` rows show their full diff initially by default. The per-tool `a
|
|
|
31
31
|
- Compact rendering for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`
|
|
32
32
|
- Animated configurable spinner in fullscreen TUI mode
|
|
33
33
|
- Millisecond-precision duration and configurable completion icons
|
|
34
|
-
- Per-tool initial compaction with `auto_compact: true` or `false`
|
|
35
|
-
-
|
|
36
|
-
-
|
|
34
|
+
- Per-tool initial compaction and global-toggle participation with `auto_compact: true` or `false`
|
|
35
|
+
- Tool invocation details are always fully visible
|
|
36
|
+
- Click toggles one result between hidden and fully visible; `Ctrl+O` toggles all participating rows
|
|
37
|
+
- Full output on expansion and initially visible edit diffs by default
|
|
37
38
|
- Dark `│` and `└─` visual grouping
|
|
38
39
|
- Distinct cool-blue thinking text
|
|
39
40
|
- Windows, Unix, and classic Mac line-ending support
|
|
@@ -86,7 +87,6 @@ Default configuration:
|
|
|
86
87
|
"edit": false,
|
|
87
88
|
"bash": true
|
|
88
89
|
},
|
|
89
|
-
"previewLines": 10,
|
|
90
90
|
"spinner": {
|
|
91
91
|
"frames": ["◐", "◓", "◑", "◒"],
|
|
92
92
|
"intervalMs": 120
|
|
@@ -104,13 +104,13 @@ These defaults use emoji and standard Unicode, so they work without a Nerd Font.
|
|
|
104
104
|
|
|
105
105
|
For a minimal blinking spinner that reuses the successful-tool circle, copy [`examples/compact-tools-2.json`](examples/compact-tools-2.json). Its frames use `● ● (blank) ● ●` at 120 ms intervals. The renderer's built-in frame dimming turns this into a fade-out/fade-in animation without Nerd Font glyphs. Duration indicators use a small `•` whose color changes by elapsed time; the fast tier uses the theme's warning color value `#E0A052` directly. Rename it to `compact-tools.json` or copy its contents to the active configuration path.
|
|
106
106
|
|
|
107
|
-
`auto_compact` controls each tool's initial state
|
|
107
|
+
`auto_compact` controls each tool's initial state and whether it participates in the global `Ctrl+O` toggle:
|
|
108
108
|
|
|
109
|
-
- `true` starts
|
|
110
|
-
- `false` starts
|
|
111
|
-
-
|
|
109
|
+
- `true` starts with the result hidden and participates in `Ctrl+O`.
|
|
110
|
+
- `false` starts with the full result visible and is unaffected by `Ctrl+O`.
|
|
111
|
+
- Clicking any compact-rendered row still toggles that row's result.
|
|
112
112
|
|
|
113
|
-
Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start compact, while `edit` starts expanded so code diffs
|
|
113
|
+
Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start compact and participate in the global toggle, while `edit` starts expanded and remains unaffected so code diffs stay visible.
|
|
114
114
|
|
|
115
115
|
`durationIndicators` must have ascending `underMs` values, with a final fallback entry that omits `underMs`. `color` is optional and accepts a supported theme color or six-digit hex value. Existing icon-only configurations remain compatible.
|
|
116
116
|
|
|
@@ -132,7 +132,6 @@ If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset ena
|
|
|
132
132
|
{
|
|
133
133
|
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json",
|
|
134
134
|
"tools": ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"],
|
|
135
|
-
"previewLines": 10,
|
|
136
135
|
"spinner": {
|
|
137
136
|
"frames": ["✽", "✻", "✲", "✢", "✲", "✻"],
|
|
138
137
|
"intervalMs": 80
|
|
@@ -150,13 +149,21 @@ The escaped code points remain readable on GitHub and are decoded to Nerd Font i
|
|
|
150
149
|
|
|
151
150
|
## Controls
|
|
152
151
|
|
|
153
|
-
|
|
152
|
+
Every tool's complete invocation is always visible, including paths, patterns, shell commands, and auxiliary arguments. Large payloads represented as results, such as write content and edit diffs, follow the result toggle.
|
|
153
|
+
|
|
154
|
+
A mouse click toggles one row's result:
|
|
154
155
|
|
|
155
156
|
```text
|
|
156
|
-
|
|
157
|
+
result hidden ↔ full result
|
|
157
158
|
```
|
|
158
159
|
|
|
159
|
-
|
|
160
|
+
`Ctrl+O` operates on all rows whose tool has `auto_compact: true`:
|
|
161
|
+
|
|
162
|
+
- If any participating row is not fully expanded, it expands all participating rows.
|
|
163
|
+
- If every participating row is fully expanded, it collapses all participating rows.
|
|
164
|
+
- Rows whose tool has `auto_compact: false` are left unchanged.
|
|
165
|
+
|
|
166
|
+
The extension handles and consumes `Ctrl+O` directly, so no Pi keybinding changes are required. With the default `edit: false`, edit diffs start visible and are excluded from the global toggle, while clicking an edit row still collapses or expands it.
|
|
160
167
|
|
|
161
168
|
## Notes
|
|
162
169
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { parseDurationIndicators } from "./compact-tools-core.ts";
|
|
5
|
+
import {
|
|
6
|
+
SUPPORTED_TOOL_SET,
|
|
7
|
+
type CompactToolName,
|
|
8
|
+
type CompactToolsConfig,
|
|
9
|
+
} from "./compact-tools-types.ts";
|
|
10
|
+
|
|
11
|
+
const CONFIG_FILE = "compact-tools.json";
|
|
12
|
+
type JsonObject = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_CONFIG: CompactToolsConfig = {
|
|
15
|
+
tools: ["read", "write", "edit", "bash"],
|
|
16
|
+
auto_compact: {
|
|
17
|
+
read: true,
|
|
18
|
+
write: true,
|
|
19
|
+
edit: false,
|
|
20
|
+
bash: true,
|
|
21
|
+
powershell: true,
|
|
22
|
+
grep: true,
|
|
23
|
+
find: true,
|
|
24
|
+
ls: true,
|
|
25
|
+
},
|
|
26
|
+
spinner: {
|
|
27
|
+
frames: ["◐", "◓", "◑", "◒"],
|
|
28
|
+
intervalMs: 120,
|
|
29
|
+
},
|
|
30
|
+
durationIndicators: [
|
|
31
|
+
{ underMs: 1_000, icon: "⚡️", color: "warning" },
|
|
32
|
+
{ underMs: 10_000, icon: "🔥", color: "#D95C3F" },
|
|
33
|
+
{ underMs: 30_000, icon: "○", color: "#79C0FF" },
|
|
34
|
+
{ icon: "⏳", color: "#D2A8FF" },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function isObject(value: unknown): value is JsonObject {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function warn(path: string, message: string): void {
|
|
43
|
+
console.error(`[compact-tools] ${path}: ${message}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isIntegerInRange(value: unknown, minimum: number, maximum: number): value is number {
|
|
47
|
+
return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isNonEmptyStringArray(value: unknown): value is string[] {
|
|
51
|
+
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseTools(value: unknown, path: string): CompactToolName[] | undefined {
|
|
55
|
+
if (!Array.isArray(value)) {
|
|
56
|
+
warn(path, "tools must be an array; using previous values");
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const tools: CompactToolName[] = [];
|
|
60
|
+
const invalid: unknown[] = [];
|
|
61
|
+
for (const item of value) {
|
|
62
|
+
if (typeof item === "string" && SUPPORTED_TOOL_SET.has(item)) tools.push(item as CompactToolName);
|
|
63
|
+
else invalid.push(item);
|
|
64
|
+
}
|
|
65
|
+
if (invalid.length > 0) warn(path, `ignoring unsupported tools: ${invalid.map(String).join(", ")}`);
|
|
66
|
+
return [...new Set(tools)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseAutoCompact(
|
|
70
|
+
base: CompactToolsConfig["auto_compact"],
|
|
71
|
+
value: unknown,
|
|
72
|
+
path: string,
|
|
73
|
+
): CompactToolsConfig["auto_compact"] {
|
|
74
|
+
if (value === undefined) return base;
|
|
75
|
+
if (!isObject(value)) {
|
|
76
|
+
warn(path, "auto_compact must be an object; using previous values");
|
|
77
|
+
return base;
|
|
78
|
+
}
|
|
79
|
+
const result = { ...base };
|
|
80
|
+
for (const [name, enabled] of Object.entries(value)) {
|
|
81
|
+
if (!SUPPORTED_TOOL_SET.has(name)) {
|
|
82
|
+
warn(path, `ignoring unsupported auto_compact tool: ${name}`);
|
|
83
|
+
} else if (typeof enabled !== "boolean") {
|
|
84
|
+
warn(path, `auto_compact.${name} must be true or false; using previous value`);
|
|
85
|
+
} else {
|
|
86
|
+
result[name as CompactToolName] = enabled;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseSpinner(
|
|
93
|
+
base: CompactToolsConfig["spinner"],
|
|
94
|
+
value: unknown,
|
|
95
|
+
path: string,
|
|
96
|
+
): CompactToolsConfig["spinner"] {
|
|
97
|
+
if (value === undefined) return base;
|
|
98
|
+
if (!isObject(value)) {
|
|
99
|
+
warn(path, "spinner must be an object; using previous values");
|
|
100
|
+
return base;
|
|
101
|
+
}
|
|
102
|
+
const frames = value.frames;
|
|
103
|
+
const intervalMs = value.intervalMs;
|
|
104
|
+
const validFrames = isNonEmptyStringArray(frames);
|
|
105
|
+
const validInterval = isIntegerInRange(intervalMs, 40, 5_000);
|
|
106
|
+
if (frames !== undefined && !validFrames) warn(path, "spinner.frames must be a non-empty string array");
|
|
107
|
+
if (intervalMs !== undefined && !validInterval) warn(path, "spinner.intervalMs must be 40–5000");
|
|
108
|
+
return {
|
|
109
|
+
frames: validFrames ? frames : base.frames,
|
|
110
|
+
intervalMs: validInterval ? intervalMs : base.intervalMs,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function mergeConfig(base: CompactToolsConfig, value: unknown, path: string): CompactToolsConfig {
|
|
115
|
+
if (value === undefined) return base;
|
|
116
|
+
if (!isObject(value)) {
|
|
117
|
+
warn(path, "expected a JSON object; using previous values");
|
|
118
|
+
return base;
|
|
119
|
+
}
|
|
120
|
+
const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
|
|
121
|
+
const auto_compact = parseAutoCompact(base.auto_compact, value.auto_compact, path);
|
|
122
|
+
const spinner = parseSpinner(base.spinner, value.spinner, path);
|
|
123
|
+
const parsedIndicators = value.durationIndicators === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: parseDurationIndicators(value.durationIndicators);
|
|
126
|
+
if (value.durationIndicators !== undefined && !parsedIndicators) {
|
|
127
|
+
warn(path, "invalid durationIndicators; using previous values");
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
tools,
|
|
131
|
+
auto_compact,
|
|
132
|
+
spinner,
|
|
133
|
+
durationIndicators: parsedIndicators ?? base.durationIndicators,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readJson(path: string): unknown {
|
|
138
|
+
if (!existsSync(path)) return undefined;
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
warn(path, error instanceof Error ? error.message : String(error));
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function loadConfig(cwd?: string, projectTrusted = false): CompactToolsConfig {
|
|
148
|
+
const globalPath = join(getAgentDir(), CONFIG_FILE);
|
|
149
|
+
const globalConfig = mergeConfig(DEFAULT_CONFIG, readJson(globalPath), globalPath);
|
|
150
|
+
if (!cwd || !projectTrusted) return globalConfig;
|
|
151
|
+
const projectPath = join(cwd, CONFIG_DIR_NAME, CONFIG_FILE);
|
|
152
|
+
return mergeConfig(globalConfig, readJson(projectPath), projectPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isFullscreenMode(argv = process.argv): boolean {
|
|
156
|
+
for (let index = 0; index < argv.length; index++) {
|
|
157
|
+
const argument = argv[index];
|
|
158
|
+
if (argument === "--tui-mode") return argv[index + 1] === "fullscreen";
|
|
159
|
+
if (argument?.startsWith("--tui-mode=")) return argument.slice("--tui-mode=".length) === "fullscreen";
|
|
160
|
+
}
|
|
161
|
+
const settings = readJson(join(getAgentDir(), "settings.json"));
|
|
162
|
+
return isObject(settings) && settings.tuiMode === "fullscreen";
|
|
163
|
+
}
|
|
@@ -13,7 +13,6 @@ export type DurationIndicatorConfig = {
|
|
|
13
13
|
color?: DurationIndicatorColor;
|
|
14
14
|
};
|
|
15
15
|
export type RowStatus = "pending" | "running" | "success" | "error";
|
|
16
|
-
|
|
17
16
|
const DURATION_INDICATOR_COLOR_SET = new Set<string>(DURATION_INDICATOR_COLORS);
|
|
18
17
|
export const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
19
18
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export type ToggleInput = "toggle" | "release";
|
|
4
|
+
|
|
5
|
+
export function classifyToggleInput(data: string): ToggleInput | undefined {
|
|
6
|
+
if (!matchesKey(data, Key.ctrl("o"))) return undefined;
|
|
7
|
+
return isKeyRelease(data) ? "release" : "toggle";
|
|
8
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
3
|
+
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
4
|
+
|
|
5
|
+
function pathFrom(args: ToolArgs): string {
|
|
6
|
+
const value = args.path ?? args.file_path;
|
|
7
|
+
return typeof value === "string" ? normalizeLineEndings(value) : "";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatReadCallDetails(path: string, args: ToolArgs): string {
|
|
11
|
+
const options: string[] = [];
|
|
12
|
+
if (typeof args.offset === "number") options.push(`offset: ${args.offset}`);
|
|
13
|
+
if (typeof args.limit === "number") options.push(`limit: ${args.limit}`);
|
|
14
|
+
return options.length === 0 ? path : `${path} (${options.join(", ")})`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getCallDetails(name: string, args: ToolArgs): string {
|
|
18
|
+
const path = pathFrom(args) || (name === "grep" || name === "find" || name === "ls" ? "." : "");
|
|
19
|
+
if (name === "read") return formatReadCallDetails(path, args);
|
|
20
|
+
if (name !== "grep" && name !== "find") return path;
|
|
21
|
+
const pattern = typeof args.pattern === "string" ? normalizeLineEndings(args.pattern) : "…";
|
|
22
|
+
return name === "grep" ? `/${pattern}/ in ${path}` : `${pattern} in ${path}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function omitArgument(name: string, key: string): boolean {
|
|
26
|
+
if (key === "path" || key === "file_path") return true;
|
|
27
|
+
if (name === "read" && (key === "offset" || key === "limit")) return true;
|
|
28
|
+
if (name === "write" && key === "content") return true;
|
|
29
|
+
return (name === "grep" || name === "find") && key === "pattern";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
33
|
+
if (name === "edit") return {};
|
|
34
|
+
const details: ToolArgs = {};
|
|
35
|
+
for (const [key, value] of Object.entries(args)) {
|
|
36
|
+
if (value !== undefined && !omitArgument(name, key)) details[key] = value;
|
|
37
|
+
}
|
|
38
|
+
return details;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getTextResult(result: AgentToolResult<unknown>): string {
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
for (const item of result.content) {
|
|
44
|
+
if (item.type === "text") parts.push(item.text);
|
|
45
|
+
}
|
|
46
|
+
return normalizeLineEndings(parts.join("\n")).trimEnd();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getFileOutput(
|
|
50
|
+
name: string,
|
|
51
|
+
args: ToolArgs,
|
|
52
|
+
result: AgentToolResult<unknown>,
|
|
53
|
+
isError: boolean,
|
|
54
|
+
): string {
|
|
55
|
+
if (isError || name === "read" || name === "grep" || name === "find" || name === "ls") {
|
|
56
|
+
return getTextResult(result);
|
|
57
|
+
}
|
|
58
|
+
return name === "write" ? String(args.content ?? "") : "";
|
|
59
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
sliceByColumn,
|
|
5
|
+
stripTerminalSequences,
|
|
6
|
+
visibleWidth,
|
|
7
|
+
wrapTextWithAnsi,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
10
|
+
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
11
|
+
|
|
12
|
+
class CachedComponent implements Component {
|
|
13
|
+
private cachedWidth?: number;
|
|
14
|
+
private cachedLines?: string[];
|
|
15
|
+
|
|
16
|
+
constructor(private readonly renderLines: (width: number) => string[]) {}
|
|
17
|
+
|
|
18
|
+
render(width: number): string[] {
|
|
19
|
+
if (this.cachedWidth !== width || !this.cachedLines) {
|
|
20
|
+
this.cachedWidth = width;
|
|
21
|
+
this.cachedLines = this.renderLines(width);
|
|
22
|
+
}
|
|
23
|
+
return this.cachedLines;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
invalidate(): void {
|
|
27
|
+
this.cachedWidth = undefined;
|
|
28
|
+
this.cachedLines = undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function prefixedText(text: string, firstPrefix: string, continuationPrefix = firstPrefix): Component {
|
|
33
|
+
const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
|
|
34
|
+
const normalized = text.replace(/\t/g, " ");
|
|
35
|
+
return new CachedComponent((width) => {
|
|
36
|
+
const lines = wrapTextWithAnsi(normalized, Math.max(1, width - prefixWidth));
|
|
37
|
+
return lines.map((line, index) => `${index === 0 ? firstPrefix : continuationPrefix}${line}`);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hardWrapTextWithAnsi(text: string, width: number): string[] {
|
|
42
|
+
const safeWidth = Math.max(1, width);
|
|
43
|
+
const wrapped: string[] = [];
|
|
44
|
+
for (const logicalLine of text.replace(/\t/g, " ").split("\n")) {
|
|
45
|
+
const lineWidth = visibleWidth(logicalLine);
|
|
46
|
+
if (lineWidth === 0) {
|
|
47
|
+
wrapped.push("");
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (let offset = 0; offset < lineWidth; offset += safeWidth) {
|
|
51
|
+
wrapped.push(sliceByColumn(logicalLine, offset, safeWidth, true));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return wrapped;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderToolCall(title: string, details: string | undefined, theme: Theme): Component {
|
|
58
|
+
const text = details ? `${title} ${details}` : title;
|
|
59
|
+
const firstPrefix = " ";
|
|
60
|
+
const continuationPrefix = theme.fg("border", " │ ");
|
|
61
|
+
const prefixWidth = visibleWidth(continuationPrefix);
|
|
62
|
+
return new CachedComponent((width) => {
|
|
63
|
+
const lines = hardWrapTextWithAnsi(text, width - prefixWidth);
|
|
64
|
+
return lines.map((line, index) => `${index === 0 ? firstPrefix : continuationPrefix}${line}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function wrapEditResult(component: Component, theme: Theme): Component {
|
|
69
|
+
return {
|
|
70
|
+
render(width: number) {
|
|
71
|
+
const lines = component.render(Math.max(1, width - 3));
|
|
72
|
+
const first = lines.findIndex((line) => visibleWidth(line.trim()) > 0);
|
|
73
|
+
if (first < 0) return [];
|
|
74
|
+
const contentLines = lines.slice(first);
|
|
75
|
+
const commonIndent = Math.min(
|
|
76
|
+
...contentLines
|
|
77
|
+
.filter((line) => visibleWidth(line.trim()) > 0)
|
|
78
|
+
.map((line) => stripTerminalSequences(line).match(/^ */)?.[0].length ?? 0),
|
|
79
|
+
);
|
|
80
|
+
const prefix = theme.fg("border", " │ ");
|
|
81
|
+
return contentLines.map((line) => {
|
|
82
|
+
const content = sliceByColumn(line, commonIndent, visibleWidth(line) - commonIndent, true);
|
|
83
|
+
return `${prefix}${content}`;
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
invalidate() {
|
|
87
|
+
component.invalidate?.();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function styleMultiline(text: string, style: (line: string) => string): string {
|
|
93
|
+
return normalizeLineEndings(text).split("\n").map(style).join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function styleOutputLine(text: string, theme: Theme, isError: boolean): string {
|
|
97
|
+
const color = isError ? "error" : "toolOutput";
|
|
98
|
+
return text
|
|
99
|
+
.split(/(⚡️?)/u)
|
|
100
|
+
.map((part) => theme.fg(!isError && part.startsWith("⚡") ? "warning" : color, part))
|
|
101
|
+
.join("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function renderOutput(output: string, theme: Theme, isError: boolean): Component | undefined {
|
|
105
|
+
const normalized = normalizeLineEndings(output).trimEnd();
|
|
106
|
+
if (!normalized) return undefined;
|
|
107
|
+
const styled = normalized.split("\n").map((line) => styleOutputLine(line, theme, isError)).join("\n");
|
|
108
|
+
return prefixedText(styled, theme.fg("border", " │ "));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderArguments(args: ToolArgs, theme: Theme): Component {
|
|
112
|
+
const json = JSON.stringify(args, null, 2) ?? "{}";
|
|
113
|
+
const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
|
|
114
|
+
return prefixedText(styled, theme.fg("border", " │ "));
|
|
115
|
+
}
|