pi-compact-tools 0.7.7 → 0.8.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 -21
- package/README.md +22 -7
- package/extensions/compact-tools-color.ts +24 -0
- package/extensions/compact-tools-invocation.ts +210 -14
- package/extensions/compact-tools-layout.ts +248 -8
- package/extensions/compact-tools-markdown.ts +94 -0
- package/extensions/compact-tools.ts +43 -10
- package/package.json +1 -1
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
|
@@ -20,9 +20,20 @@ The working label above the prompt uses the Thinking summary color with a soft w
|
|
|
20
20
|
|
|
21
21
|

|
|
22
22
|
|
|
23
|
-
###
|
|
23
|
+
### Code-aware results
|
|
24
24
|
|
|
25
|
-
Completed `edit` rows
|
|
25
|
+
Completed `edit` rows render a syntax-highlighted diff: a line-number and `+`/`-` gutter beside the code, tinted rows for additions and deletions, and `⋮` where context is skipped. Collapsed rows keep one line of context around each change so the edit itself stays visible; expanding with a click or `Ctrl+O` restores the full context the patch carries.
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
⦁ edit src/load.ts
|
|
29
|
+
│ 3 export async function load(path: string): Promise<string> {
|
|
30
|
+
│ 4 - const raw = await readFile(path, "utf8");
|
|
31
|
+
│ 4 + const raw = await readFile(path, "utf-8");
|
|
32
|
+
│ 5 return raw.trim();
|
|
33
|
+
└─ Done in 0.012s (2 lines)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`read` and `write` results use the same numbered, syntax-highlighted layout without the diff signs. `read` line numbers start at the requested `offset`, and Pi's continuation notice (`[Showing lines …]`) is shown as a dim note below the code. Markdown files keep their exact source but style headings, lists, quotes, links, emphasis, and inline code, while fenced blocks are highlighted in their declared language. Files without a recognized language keep the plain output color instead of a guessed highlight.
|
|
26
37
|
|
|
27
38
|

|
|
28
39
|
|
|
@@ -30,13 +41,15 @@ Completed `edit` rows show only the lines actually removed (`-`) or added (`+`),
|
|
|
30
41
|
|
|
31
42
|
- Compact rendering for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`
|
|
32
43
|
- One continuously glowing working label above the input prompt across thinking and tool execution
|
|
33
|
-
- Semantic built-in progress labels and automatically cleaned custom/MCP tool names
|
|
44
|
+
- Semantic built-in progress labels and automatically cleaned custom/MCP tool names; collapsed text-search rows include the searched pattern
|
|
34
45
|
- Four-step `Ctrl+T` thinking cycle when provider detail is available: summary, detail, summary, then hidden
|
|
35
46
|
- Millisecond-precision duration and completed output line counts with neutral status text
|
|
36
47
|
- Per-tool hidden or persistent bounded-preview collapsed state with `auto_compact: true` or `false`
|
|
37
|
-
- Primary invocation targets such as paths and patterns stay visible
|
|
48
|
+
- Primary invocation targets such as paths and patterns stay visible, including common collapsed shell file operations
|
|
38
49
|
- Click and `Ctrl+O` follow Pi's built-in expansion state while preview rows collapse back to their preview
|
|
39
|
-
- Edit
|
|
50
|
+
- Edit results render as syntax-highlighted diffs with a line-number gutter, tinted change rows, and `⋮` context breaks
|
|
51
|
+
- `read` and `write` contents use the same numbered, syntax-highlighted layout, with `read` line numbers following `offset`
|
|
52
|
+
- Markdown files keep their exact source but style headings, lists, quotes, links, and inline code, and highlight fenced code blocks in their declared language
|
|
40
53
|
- Configurable `previewLines` prevents large diffs, HTML, and shell output from flooding the transcript
|
|
41
54
|
- Width-cached expanded results, edit-diff processing, and unchanged result reuse during status animation
|
|
42
55
|
- Dark `│` and `└─` visual grouping
|
|
@@ -103,7 +116,7 @@ Every tool call title uses `⦁`. Pending calls use dark gray; active calls smoo
|
|
|
103
116
|
- `false` starts with at most `previewLines` rendered rows visible.
|
|
104
117
|
- Clicking or pressing `Ctrl+O` expands the complete result through Pi's built-in expansion state.
|
|
105
118
|
|
|
106
|
-
`previewLines` accepts `1`–`100` and defaults to `10`. It limits the collapsed preview; the complete result remains available. Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start hidden, while `edit` starts with a short changed
|
|
119
|
+
`previewLines` accepts `1`–`100` and defaults to `10`. It limits the collapsed preview; the complete result remains available. Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start hidden, while `edit` starts with a short diff preview centered on the changed lines.
|
|
107
120
|
|
|
108
121
|
To enable all Unix-compatible tools:
|
|
109
122
|
|
|
@@ -125,7 +138,7 @@ one-line summary → summary + │-indented detail → one-line summary → Thin
|
|
|
125
138
|
|
|
126
139
|
Standalone bold lines and Markdown headings split one provider thinking run into logical sections. Each section gets its own summary and optional `│`-indented detail. Summary-only views stay uncluttered with no control hint; the control row appears only while provider-supplied detail is visible. When no thinking detail exists, `Ctrl+T` falls back to Pi's normal visible/hidden toggle instead of showing a duplicate detail phase. While thinking streams, a slightly faster highlight sweeps from the Thinking summary color to white across Pi's working label at a steady interval rather than depending on token updates. The transcript summary remains visually stable. The transformation is display-only; complete thinking remains unchanged in the session and model context.
|
|
127
140
|
|
|
128
|
-
Primary file and search targets stay visible. Collapsed `bash` and `powershell` rows describe intent—such as `Run tests
|
|
141
|
+
Primary file and search targets stay visible. Collapsed `bash` and `powershell` rows describe intent—such as `Run tests` or `Check repository status`. Text searches retain their primary pattern (for example, `Search text "TODO|FIXME"`), and common file commands retain their operands (for example, `Run rm "build"` or `Find files "*.test.ts" in "src"`). Expanding the row reveals the exact command. After every supported tool completes, the status reports the expanded result size as `Done in …s (N lines)` instead of repeating options such as context and limit. Counts use the returned text for reads, searches, listings, and shell commands, written content for `write`, and only the changed `-`/`+` lines for `edit`. The count is computed once after completion and cached with the row, so streaming and repeated expansion do not add ongoing work. Large payloads represented as results follow the result toggle.
|
|
129
142
|
|
|
130
143
|
A mouse click toggles one row using Pi's native result state. `Ctrl+O` remains owned by Pi and expands or collapses all tool rows consistently, including tools not rendered by this extension. A row with `auto_compact: false` cycles predictably between its bounded preview and the complete result, so repeated clicks never remove the row and fall through to a neighboring Thinking block. The extension does not intercept the global keybinding.
|
|
131
144
|
|
|
@@ -134,6 +147,8 @@ A mouse click toggles one row using Pi's native result state. `Ctrl+O` remains o
|
|
|
134
147
|
- Pi `0.85.1+` and Node.js `22.19.0+` are recommended.
|
|
135
148
|
- The working indicator and mouse interaction are provided by Pi's TUI; `Ctrl+O` also works in regular mode.
|
|
136
149
|
- Duration covers the full rendered tool-call lifecycle, including streamed `write` and `edit` arguments.
|
|
150
|
+
- Syntax highlighting is skipped, keeping line numbers, for content over 512 KB, 10,000 lines, or a single 4 KB line. Pi already truncates `read` output to 2,000 lines or 50 KB.
|
|
151
|
+
- Highlighting runs per diff hunk or `read` range, so a hunk or `offset` that starts inside a block comment or Markdown code fence may color its first lines as surrounding text.
|
|
137
152
|
- Selected tools override definitions with the same names so their compact renderers are restored reliably after `/reload`; other extensions overriding those tools may conflict by load order.
|
|
138
153
|
|
|
139
154
|
## Development
|
|
@@ -63,3 +63,27 @@ export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
|
|
|
63
63
|
}
|
|
64
64
|
return `\x1b[38;2;${color.r};${color.g};${color.b}m${text}\x1b[39m`;
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
function isDarkTheme(theme: Theme): boolean {
|
|
68
|
+
const text = themeColorRgb(theme, "text");
|
|
69
|
+
if (!text) return true;
|
|
70
|
+
return text.r * 0.299 + text.g * 0.587 + text.b * 0.114 > 128;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Diff rows need a tint that reads on the terminal background without hijacking `toolSuccessBg`
|
|
75
|
+
* and `toolErrorBg`, which Pi paints across the whole tool row.
|
|
76
|
+
*/
|
|
77
|
+
export function diffTintRgb(theme: Theme, color: ThemeForeground, amount = 0.16): Rgb | undefined {
|
|
78
|
+
const rgb = themeColorRgb(theme, color);
|
|
79
|
+
if (!rgb) return undefined;
|
|
80
|
+
const base: Rgb = isDarkTheme(theme) ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 };
|
|
81
|
+
return interpolateRgb(base, rgb, amount);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function fillRgb(theme: Theme, color: Rgb, text: string): string {
|
|
85
|
+
if (typeof theme.getColorMode === "function" && theme.getColorMode() === "256color") {
|
|
86
|
+
return `\x1b[48;5;${rgbToAnsi256(color)}m${text}\x1b[49m`;
|
|
87
|
+
}
|
|
88
|
+
return `\x1b[48;2;${color.r};${color.g};${color.b}m${text}\x1b[49m`;
|
|
89
|
+
}
|
|
@@ -77,18 +77,192 @@ function shellSegments(command: string): string[] {
|
|
|
77
77
|
return segments;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function
|
|
81
|
-
|
|
80
|
+
function shellWords(command: string, powershell = false): string[] {
|
|
81
|
+
const words: string[] = [];
|
|
82
|
+
let current = "";
|
|
83
|
+
let quote: "'" | '"' | undefined;
|
|
84
|
+
const input = command.trim();
|
|
85
|
+
for (let index = 0; index < input.length; index++) {
|
|
86
|
+
const character = input[index]!;
|
|
87
|
+
if (powershell && character === "`" && quote !== "'") {
|
|
88
|
+
const next = input[index + 1];
|
|
89
|
+
if (next !== undefined) {
|
|
90
|
+
current += next;
|
|
91
|
+
index++;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!powershell && character === "\\" && quote !== "'") {
|
|
96
|
+
const next = input[index + 1];
|
|
97
|
+
if (next === undefined) {
|
|
98
|
+
current += "\\";
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// In double quotes, Bash preserves backslashes before ordinary characters.
|
|
102
|
+
if (quote === '"' && !['$', '`', '"', "\\", "\n"].includes(next)) {
|
|
103
|
+
current += "\\";
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
current += next;
|
|
107
|
+
index++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (quote) {
|
|
111
|
+
if (character === quote) quote = undefined;
|
|
112
|
+
else current += character;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (character === "'" || character === '"') {
|
|
116
|
+
quote = character;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (/\s/u.test(character)) {
|
|
120
|
+
if (current) words.push(current);
|
|
121
|
+
current = "";
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
current += character;
|
|
125
|
+
}
|
|
126
|
+
if (current) words.push(current);
|
|
127
|
+
return words;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SEARCH_OPTIONS_WITH_VALUES = new Set([
|
|
131
|
+
"-a", "--after-context", "-b", "--before-context", "-c", "--context", "--context-separator",
|
|
132
|
+
"-d", "--max-depth", "--dfa-size-limit", "-f", "--file", "-g", "--glob", "--iglob",
|
|
133
|
+
"-m", "--max-count", "--max-columns", "--path-separator", "--pre", "--pre-glob",
|
|
134
|
+
"--regex-size-limit", "--replace", "--sort", "--sortr", "-t", "--type", "--type-not",
|
|
135
|
+
"--encoding", "--engine", "--field-context-separator", "--field-match-separator", "--hostname-bin",
|
|
136
|
+
"--binary-files", "--exclude", "--exclude-from", "--exclude-dir", "--include", "--label",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
function searchPattern(executable: string, words: string[]): string | undefined {
|
|
140
|
+
const args = words.slice(1);
|
|
141
|
+
for (let index = 0; index < args.length; index++) {
|
|
142
|
+
const argument = args[index] ?? "";
|
|
143
|
+
const lower = argument.toLowerCase();
|
|
144
|
+
if (executable === "select-string") {
|
|
145
|
+
if (lower === "-pattern") return args[index + 1];
|
|
146
|
+
if (lower.startsWith("-pattern:")) return argument.slice(argument.indexOf(":") + 1);
|
|
147
|
+
if (!argument.startsWith("-")) return argument;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (lower === "-e" || lower === "--regexp") return args[index + 1];
|
|
151
|
+
if (lower.startsWith("--regexp=")) return argument.slice(argument.indexOf("=") + 1);
|
|
152
|
+
if (/^-e.+/u.test(argument)) return argument.slice(2);
|
|
153
|
+
if (argument === "--") return args[index + 1];
|
|
154
|
+
const optionName = lower.split("=", 1)[0] ?? lower;
|
|
155
|
+
if (SEARCH_OPTIONS_WITH_VALUES.has(optionName) && !argument.includes("=")) {
|
|
156
|
+
index++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (argument.startsWith("-")) continue;
|
|
160
|
+
return argument;
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function summarizeTextSearch(executable: string, words: string[]): string {
|
|
166
|
+
const pattern = searchPattern(executable, words);
|
|
167
|
+
return pattern === undefined ? "Search text" : `Search text ${JSON.stringify(pattern)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatTargets(targets: string[]): string {
|
|
171
|
+
const visible = targets.slice(0, 2).map((target) => JSON.stringify(target)).join(", ");
|
|
172
|
+
const remaining = targets.length - 2;
|
|
173
|
+
return remaining > 0 ? `${visible} + ${remaining} more` : visible;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function plainOperands(words: string[]): string[] {
|
|
177
|
+
const separator = words.indexOf("--");
|
|
178
|
+
if (separator >= 0) return words.slice(separator + 1);
|
|
179
|
+
return words.slice(1).filter((word) => !word.startsWith("-"));
|
|
82
180
|
}
|
|
83
181
|
|
|
84
|
-
|
|
182
|
+
function optionValue(words: string[], names: string[]): string | undefined {
|
|
183
|
+
for (let index = 1; index < words.length; index++) {
|
|
184
|
+
const word = words[index] ?? "";
|
|
185
|
+
const lower = word.toLowerCase();
|
|
186
|
+
if (names.includes(lower)) return words[index + 1];
|
|
187
|
+
const prefix = names.find((name) => lower.startsWith(`${name}=`) || lower.startsWith(`${name}:`));
|
|
188
|
+
if (prefix) return word.slice(prefix.length + 1);
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const FD_OPTIONS_WITH_VALUES = new Set([
|
|
194
|
+
"-d", "--max-depth", "-e", "--extension", "-E", "--exclude", "-t", "--type",
|
|
195
|
+
"-x", "--exec", "-X", "--exec-batch", "-j", "--threads", "--base-directory",
|
|
196
|
+
"--changed-before", "--changed-within", "--format", "--glob", "--path-separator",
|
|
197
|
+
"--search-path", "--size", "--strip-cwd-prefix",
|
|
198
|
+
].map((option) => option.toLowerCase()));
|
|
199
|
+
|
|
200
|
+
function fdOperands(words: string[]): string[] {
|
|
201
|
+
const operands: string[] = [];
|
|
202
|
+
for (let index = 1; index < words.length; index++) {
|
|
203
|
+
const word = words[index] ?? "";
|
|
204
|
+
if (word === "--") return [...operands, ...words.slice(index + 1)];
|
|
205
|
+
const optionName = word.toLowerCase().split("=", 1)[0] ?? word.toLowerCase();
|
|
206
|
+
if (FD_OPTIONS_WITH_VALUES.has(optionName) && !word.includes("=")) {
|
|
207
|
+
index++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!word.startsWith("-")) operands.push(word);
|
|
211
|
+
}
|
|
212
|
+
return operands;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function summarizeFileDiscovery(executable: string, words: string[]): string {
|
|
216
|
+
if (executable === "find") {
|
|
217
|
+
const paths: string[] = [];
|
|
218
|
+
for (let index = 1; index < words.length; index++) {
|
|
219
|
+
const word = words[index] ?? "";
|
|
220
|
+
if (word.startsWith("-") || word === "!" || word === "(") break;
|
|
221
|
+
paths.push(word);
|
|
222
|
+
}
|
|
223
|
+
const pattern = optionValue(words, ["-name", "-iname", "-path", "-ipath", "-regex"]);
|
|
224
|
+
const location = formatTargets(paths.length > 0 ? paths : ["."]);
|
|
225
|
+
return pattern ? `Find files ${JSON.stringify(pattern)} in ${location}` : `Find files in ${location}`;
|
|
226
|
+
}
|
|
227
|
+
if (executable === "fd") {
|
|
228
|
+
const operands = fdOperands(words);
|
|
229
|
+
const pattern = operands[0];
|
|
230
|
+
const locations = operands.slice(1);
|
|
231
|
+
if (!pattern) return "Find files";
|
|
232
|
+
return locations.length > 0
|
|
233
|
+
? `Find files ${JSON.stringify(pattern)} in ${formatTargets(locations)}`
|
|
234
|
+
: `Find files ${JSON.stringify(pattern)}`;
|
|
235
|
+
}
|
|
236
|
+
const explicitPath = optionValue(words, ["-path", "-literalpath"]);
|
|
237
|
+
const targets = explicitPath ? [explicitPath] : plainOperands(words);
|
|
238
|
+
return targets.length > 0 ? `List files ${formatTargets(targets)}` : "List files";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function summarizeFileOperation(executable: string, words: string[]): string | undefined {
|
|
242
|
+
const targets = plainOperands(words);
|
|
243
|
+
if (targets.length === 0) return undefined;
|
|
244
|
+
if (["rm", "del", "erase", "remove-item"].includes(executable)) return `Run ${executable} ${formatTargets(targets)}`;
|
|
245
|
+
if (["cat", "type", "get-content"].includes(executable)) return `Read file ${formatTargets(targets)}`;
|
|
246
|
+
if (["mkdir", "md", "new-item"].includes(executable)) return `Create ${formatTargets(targets)}`;
|
|
247
|
+
if (["rmdir", "rd"].includes(executable)) return `Remove directory ${formatTargets(targets)}`;
|
|
248
|
+
if (["touch"].includes(executable)) return `Touch ${formatTargets(targets)}`;
|
|
249
|
+
if (["cp", "copy", "copy-item"].includes(executable) && targets.length >= 2) {
|
|
250
|
+
return `Copy ${JSON.stringify(targets[0])} to ${JSON.stringify(targets.at(-1))}`;
|
|
251
|
+
}
|
|
252
|
+
if (["mv", "move", "move-item", "ren", "rename-item"].includes(executable) && targets.length >= 2) {
|
|
253
|
+
return `Move ${JSON.stringify(targets[0])} to ${JSON.stringify(targets.at(-1))}`;
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A calm collapsed label that describes intent while retaining primary command targets. */
|
|
85
259
|
export function summarizeShellCommand(name: string, command: string): string {
|
|
86
260
|
const allSegments = shellSegments(normalizeLineEndings(command));
|
|
87
261
|
const segments = allSegments.filter((segment) => !/^\s*(?:cd|pushd|popd)\b/iu.test(segment));
|
|
88
262
|
const primary = segments[0] ?? allSegments[0] ?? "";
|
|
89
263
|
if (!primary) return name === "powershell" ? "Prepare PowerShell command" : "Prepare shell command";
|
|
90
264
|
|
|
91
|
-
const words = primary
|
|
265
|
+
const words = shellWords(primary, name === "powershell");
|
|
92
266
|
while (words.length > 0) {
|
|
93
267
|
if (/^[A-Za-z_][A-Za-z\d_]*=.*/u.test(words[0] ?? "")
|
|
94
268
|
|| ["command", "env", "sudo", "time"].includes((words[0] ?? "").toLowerCase())) {
|
|
@@ -117,11 +291,11 @@ export function summarizeShellCommand(name: string, command: string): string {
|
|
|
117
291
|
} else if (executable === "git" && ["fetch", "pull", "push", "clone"].includes(action)) {
|
|
118
292
|
summary = `${action[0]!.toUpperCase()}${action.slice(1)} repository`;
|
|
119
293
|
} else if (["rg", "grep", "select-string"].includes(executable)) {
|
|
120
|
-
summary =
|
|
294
|
+
summary = summarizeTextSearch(executable, words);
|
|
121
295
|
} else if (["find", "fd", "get-childitem", "ls", "dir"].includes(executable)) {
|
|
122
|
-
summary = executable
|
|
123
|
-
} else if (
|
|
124
|
-
summary =
|
|
296
|
+
summary = summarizeFileDiscovery(executable, words);
|
|
297
|
+
} else if (summarizeFileOperation(executable, words)) {
|
|
298
|
+
summary = summarizeFileOperation(executable, words)!;
|
|
125
299
|
} else if (["tsc", "mypy", "pyright"].includes(executable)) {
|
|
126
300
|
summary = "Check types";
|
|
127
301
|
} else if (["jest", "vitest", "pytest", "invoke-pester"].includes(executable)) {
|
|
@@ -147,6 +321,7 @@ export function getTextResult(result: AgentToolResult<unknown>): string {
|
|
|
147
321
|
|
|
148
322
|
type ResultDetails = {
|
|
149
323
|
diff?: unknown;
|
|
324
|
+
patch?: unknown;
|
|
150
325
|
truncation?: {
|
|
151
326
|
firstLineExceedsLimit?: boolean;
|
|
152
327
|
outputLines?: number;
|
|
@@ -167,21 +342,42 @@ function countTextLines(text: string): number {
|
|
|
167
342
|
return count;
|
|
168
343
|
}
|
|
169
344
|
|
|
345
|
+
export function getEditDiff(result: AgentToolResult<unknown>): string {
|
|
346
|
+
const diff = (result.details as ResultDetails | undefined)?.diff;
|
|
347
|
+
return typeof diff === "string" ? normalizeLineEndings(diff).trimEnd() : "";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function getEditPatch(result: AgentToolResult<unknown>): string {
|
|
351
|
+
const patch = (result.details as ResultDetails | undefined)?.patch;
|
|
352
|
+
return typeof patch === "string" ? normalizeLineEndings(patch).trimEnd() : "";
|
|
353
|
+
}
|
|
354
|
+
|
|
170
355
|
/** Keep only the lines that the edit actually removed or added; discard context and ellipses. */
|
|
171
356
|
export function getEditChanges(result: AgentToolResult<unknown>): string {
|
|
172
|
-
|
|
173
|
-
if (typeof diff !== "string") return "";
|
|
174
|
-
return normalizeLineEndings(diff)
|
|
357
|
+
return getEditDiff(result)
|
|
175
358
|
.split("\n")
|
|
176
359
|
.filter((line) => line.startsWith("-") || line.startsWith("+"))
|
|
177
360
|
.join("\n")
|
|
178
361
|
.trimEnd();
|
|
179
362
|
}
|
|
180
363
|
|
|
364
|
+
const READ_FOOTER = /\n\n(\[(?:Showing lines |\d+ more lines in file\.)[^\n]*\])$/u;
|
|
365
|
+
|
|
366
|
+
/** Separate a read's file contents from the continuation notice Pi appends to them. */
|
|
367
|
+
export function splitReadFooter(text: string): { body: string; footer?: string } {
|
|
368
|
+
const match = text.match(READ_FOOTER);
|
|
369
|
+
return match ? { body: text.slice(0, match.index), footer: match[1] } : { body: text };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Whether a read result is plain file text that can be shown as numbered code. */
|
|
373
|
+
export function isReadTextResult(result: AgentToolResult<unknown>): boolean {
|
|
374
|
+
const details = result.details as ResultDetails | undefined;
|
|
375
|
+
return !details?.truncation?.firstLineExceedsLimit
|
|
376
|
+
&& !result.content.some((item) => item.type === "image");
|
|
377
|
+
}
|
|
378
|
+
|
|
181
379
|
function stripGeneratedFooter(name: string, text: string, details: ResultDetails | undefined): string {
|
|
182
|
-
if (name === "read")
|
|
183
|
-
return text.replace(/\n\n\[(?:Showing lines |\d+ more lines in file\.)[^\n]*\]$/u, "");
|
|
184
|
-
}
|
|
380
|
+
if (name === "read") return splitReadFooter(text).body;
|
|
185
381
|
const hasGeneratedFooter = details?.truncation?.outputLines !== undefined
|
|
186
382
|
|| details?.matchLimitReached !== undefined
|
|
187
383
|
|| details?.resultLimitReached !== undefined
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Component } from "@earendil-works/pi-tui";
|
|
3
3
|
import {
|
|
4
4
|
Container,
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
visibleWidth,
|
|
8
8
|
wrapTextWithAnsi,
|
|
9
9
|
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { diffTintRgb, fillRgb } from "./compact-tools-color.ts";
|
|
10
11
|
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
12
|
+
import { highlightMarkdown } from "./compact-tools-markdown.ts";
|
|
11
13
|
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
12
14
|
|
|
13
15
|
class CachedComponent implements Component {
|
|
@@ -129,14 +131,252 @@ export function renderOutput(output: string, theme: Theme, isError: boolean): Co
|
|
|
129
131
|
return prefixedText(styled, theme.fg("border", " │ "));
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
export type CodeDiffLineKind = "context" | "add" | "remove" | "separator";
|
|
135
|
+
|
|
136
|
+
export type CodeDiffLine = {
|
|
137
|
+
kind: CodeDiffLineKind;
|
|
138
|
+
lineNumber?: number;
|
|
139
|
+
content: string;
|
|
140
|
+
hunk: number;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export type CodeDiffOptions = {
|
|
144
|
+
/** Context lines kept around each change; omit to keep every context line the diff carries. */
|
|
145
|
+
contextLines?: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const DIFF_SEPARATOR = "\u22ee";
|
|
149
|
+
|
|
150
|
+
function parseUnifiedPatch(patch: string): CodeDiffLine[] {
|
|
151
|
+
const result: CodeDiffLine[] = [];
|
|
152
|
+
let oldLine = 0;
|
|
153
|
+
let newLine = 0;
|
|
154
|
+
let hunk = -1;
|
|
155
|
+
for (const line of normalizeLineEndings(patch).split("\n")) {
|
|
156
|
+
const header = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/u);
|
|
157
|
+
if (header) {
|
|
158
|
+
if (hunk >= 0) result.push({ kind: "separator", content: DIFF_SEPARATOR, hunk });
|
|
159
|
+
hunk++;
|
|
160
|
+
oldLine = Number(header[1]);
|
|
161
|
+
newLine = Number(header[2]);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (hunk < 0 || line.startsWith("\")) continue;
|
|
165
|
+
const prefix = line[0];
|
|
166
|
+
const content = line.slice(1).replace(/\t/gu, " ");
|
|
167
|
+
if (prefix === "+") {
|
|
168
|
+
result.push({ kind: "add", lineNumber: newLine++, content, hunk });
|
|
169
|
+
} else if (prefix === "-") {
|
|
170
|
+
result.push({ kind: "remove", lineNumber: oldLine++, content, hunk });
|
|
171
|
+
} else if (prefix === " ") {
|
|
172
|
+
result.push({ kind: "context", lineNumber: newLine++, content, hunk });
|
|
173
|
+
oldLine++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseDisplayDiff(diff: string): CodeDiffLine[] {
|
|
180
|
+
const result: CodeDiffLine[] = [];
|
|
181
|
+
let hunk = 0;
|
|
182
|
+
for (const line of normalizeLineEndings(diff).split("\n")) {
|
|
183
|
+
if (/^\s*\.\.\.\s*$/u.test(line)) {
|
|
184
|
+
result.push({ kind: "separator", content: DIFF_SEPARATOR, hunk: hunk++ });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const match = line.match(/^([ +\-])(\s*\d+)\s(.*)$/u);
|
|
188
|
+
if (!match) continue;
|
|
189
|
+
result.push({
|
|
190
|
+
kind: match[1] === "+" ? "add" : match[1] === "-" ? "remove" : "context",
|
|
191
|
+
lineNumber: Number(match[2]),
|
|
192
|
+
content: match[3]!.replace(/\t/gu, " "),
|
|
193
|
+
hunk,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Parse a standard patch, falling back to Pi's display-oriented numbered diff. */
|
|
200
|
+
export function parseCodeDiff(patch: string, displayDiff = ""): CodeDiffLine[] {
|
|
201
|
+
const parsedPatch = patch ? parseUnifiedPatch(patch) : [];
|
|
202
|
+
return parsedPatch.length > 0 ? parsedPatch : parseDisplayDiff(displayDiff);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Drop context that sits far from any change so a collapsed preview still reaches the edit. */
|
|
206
|
+
export function trimDiffContext(lines: CodeDiffLine[], contextLines: number): CodeDiffLine[] {
|
|
207
|
+
const kept = lines.map((line) => line.kind !== "context");
|
|
208
|
+
lines.forEach((line, index) => {
|
|
209
|
+
if (line.kind !== "add" && line.kind !== "remove") return;
|
|
210
|
+
for (let offset = 1; offset <= contextLines; offset++) {
|
|
211
|
+
if (lines[index - offset]?.kind === "context") kept[index - offset] = true;
|
|
212
|
+
if (lines[index + offset]?.kind === "context") kept[index + offset] = true;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
const result: CodeDiffLine[] = [];
|
|
216
|
+
lines.forEach((line, index) => {
|
|
217
|
+
if (kept[index]) {
|
|
218
|
+
result.push(line);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (result.length > 0 && result[result.length - 1]!.kind !== "separator") {
|
|
222
|
+
result.push({ kind: "separator", content: DIFF_SEPARATOR, hunk: line.hunk });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
while (result.length > 0 && result[result.length - 1]!.kind === "separator") result.pop();
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Past these sizes syntax highlighting costs more than it helps; mirrors Codex's diff renderer. */
|
|
230
|
+
const HIGHLIGHT_MAX_CHARS = 512 * 1024;
|
|
231
|
+
const HIGHLIGHT_MAX_LINES = 10_000;
|
|
232
|
+
const HIGHLIGHT_MAX_LINE_CHARS = 4 * 1024;
|
|
233
|
+
|
|
234
|
+
function canHighlight(lines: readonly CodeDiffLine[]): boolean {
|
|
235
|
+
if (lines.length > HIGHLIGHT_MAX_LINES) return false;
|
|
236
|
+
let total = 0;
|
|
237
|
+
for (const line of lines) {
|
|
238
|
+
if (line.content.length > HIGHLIGHT_MAX_LINE_CHARS) return false;
|
|
239
|
+
total += line.content.length + 1;
|
|
240
|
+
if (total > HIGHLIGHT_MAX_CHARS) return false;
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Highlight each hunk as one block so multi-line strings and comments keep their state. */
|
|
246
|
+
function highlightCodeLines(lines: CodeDiffLine[], language: string | undefined, theme: Theme): string[] {
|
|
247
|
+
if (!language || !canHighlight(lines)) {
|
|
248
|
+
return lines.map((line) => line.kind === "separator" ? line.content : theme.fg("toolOutput", line.content));
|
|
249
|
+
}
|
|
250
|
+
const rendered = new Array<string>(lines.length);
|
|
251
|
+
let index = 0;
|
|
252
|
+
while (index < lines.length) {
|
|
253
|
+
if (lines[index]!.kind === "separator") {
|
|
254
|
+
rendered[index] = lines[index]!.content;
|
|
255
|
+
index++;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
let end = index;
|
|
259
|
+
while (end < lines.length && lines[end]!.kind !== "separator") end++;
|
|
260
|
+
const block = lines.slice(index, end);
|
|
261
|
+
const contents = block.map((line) => line.content);
|
|
262
|
+
const highlighted = language === "markdown"
|
|
263
|
+
? highlightMarkdown(contents, theme)
|
|
264
|
+
: highlightCode(contents.join("\n"), language);
|
|
265
|
+
block.forEach((line, offset) => {
|
|
266
|
+
rendered[index + offset] = highlighted[offset] ?? line.content;
|
|
267
|
+
});
|
|
268
|
+
index = end;
|
|
269
|
+
}
|
|
270
|
+
return rendered;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hardSliceAnsi(text: string, width: number): string[] {
|
|
274
|
+
const safeWidth = Math.max(1, width);
|
|
275
|
+
const lineWidth = visibleWidth(text);
|
|
276
|
+
if (lineWidth === 0) return [""];
|
|
277
|
+
const slices: string[] = [];
|
|
278
|
+
for (let offset = 0; offset < lineWidth; offset += safeWidth) {
|
|
279
|
+
slices.push(sliceByColumn(text, offset, safeWidth, true));
|
|
280
|
+
}
|
|
281
|
+
return slices;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function codeGutter(
|
|
285
|
+
kind: CodeDiffLineKind,
|
|
286
|
+
lineNumber: number | undefined,
|
|
287
|
+
numberWidth: number,
|
|
288
|
+
signs: boolean,
|
|
289
|
+
theme: Theme,
|
|
290
|
+
): string {
|
|
291
|
+
const number = (lineNumber === undefined ? "" : String(lineNumber)).padStart(numberWidth);
|
|
292
|
+
if (!signs) return theme.fg("toolDiffContext", `${number} `);
|
|
293
|
+
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
|
294
|
+
const color = kind === "add" ? "toolDiffAdded" : kind === "remove" ? "toolDiffRemoved" : "toolDiffContext";
|
|
295
|
+
return theme.fg(color, `${number} ${sign} `);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
type CodeRowsOptions = {
|
|
299
|
+
/** Show the `+`/`-` sign column and tint changed rows. */
|
|
300
|
+
signs: boolean;
|
|
301
|
+
/** Dim note rendered below the code, such as a read truncation notice. */
|
|
302
|
+
footer?: string;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** Render numbered, syntax-highlighted code rows shared by edit diffs and read/write file views. */
|
|
306
|
+
function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, options: CodeRowsOptions): Component {
|
|
307
|
+
const highlighted = highlightCodeLines(lines, getLanguageFromPath(path), theme);
|
|
308
|
+
const numberWidth = Math.max(1, ...lines.map((line) => String(line.lineNumber ?? "").length));
|
|
309
|
+
const prefix = theme.fg("border", " \u2502 ");
|
|
310
|
+
const prefixWidth = visibleWidth(prefix);
|
|
311
|
+
const gutterWidth = numberWidth + (options.signs ? 3 : 2);
|
|
312
|
+
const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
|
|
313
|
+
const removedTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved") : undefined;
|
|
314
|
+
return new CachedComponent((width) => {
|
|
315
|
+
const bodyWidth = Math.max(1, width - prefixWidth);
|
|
316
|
+
const codeWidth = Math.max(1, bodyWidth - gutterWidth);
|
|
317
|
+
const output: string[] = [];
|
|
318
|
+
lines.forEach((line, index) => {
|
|
319
|
+
if (line.kind === "separator") {
|
|
320
|
+
output.push(prefix + " ".repeat(numberWidth + 1) + theme.fg("toolDiffContext", line.content));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
|
|
324
|
+
for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted[index] ?? line.content, codeWidth).entries()) {
|
|
325
|
+
const gutter = codeGutter(
|
|
326
|
+
line.kind,
|
|
327
|
+
chunkIndex === 0 ? line.lineNumber : undefined,
|
|
328
|
+
numberWidth,
|
|
329
|
+
options.signs,
|
|
330
|
+
theme,
|
|
331
|
+
);
|
|
332
|
+
const body = gutter + chunk;
|
|
333
|
+
if (!tint) {
|
|
334
|
+
output.push(prefix + body);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const padded = body + " ".repeat(Math.max(0, bodyWidth - visibleWidth(body)));
|
|
338
|
+
output.push(prefix + fillRgb(theme, tint, padded));
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
if (options.footer) {
|
|
342
|
+
for (const line of wrapTextWithAnsi(options.footer, bodyWidth)) output.push(prefix + theme.fg("dim", line));
|
|
343
|
+
}
|
|
344
|
+
return output;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Render an edit as a syntax-highlighted diff with a line-number gutter and tinted change rows. */
|
|
349
|
+
export function renderCodeDiff(
|
|
350
|
+
patch: string,
|
|
351
|
+
displayDiff: string,
|
|
352
|
+
path: string,
|
|
353
|
+
theme: Theme,
|
|
354
|
+
options: CodeDiffOptions = {},
|
|
355
|
+
): Component | undefined {
|
|
356
|
+
const diff = parseCodeDiff(patch, displayDiff);
|
|
357
|
+
const lines = options.contextLines === undefined ? diff : trimDiffContext(diff, options.contextLines);
|
|
358
|
+
if (lines.length === 0) return undefined;
|
|
359
|
+
return renderCodeRows(lines, path, theme, { signs: true });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export type CodeViewOptions = {
|
|
363
|
+
/** File line number of the first code line, e.g. a read's `offset`. */
|
|
364
|
+
startLine?: number;
|
|
365
|
+
footer?: string;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** Render file contents with line numbers and syntax highlighting, matching the edit diff layout. */
|
|
369
|
+
export function renderCodeView(code: string, path: string, theme: Theme, options: CodeViewOptions = {}): Component | undefined {
|
|
370
|
+
const normalized = normalizeLineEndings(code).replace(/\n+$/u, "");
|
|
135
371
|
if (!normalized) return undefined;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
372
|
+
const startLine = Math.max(1, Math.floor(options.startLine ?? 1));
|
|
373
|
+
const lines = normalized.split("\n").map((content, index): CodeDiffLine => ({
|
|
374
|
+
kind: "context",
|
|
375
|
+
lineNumber: startLine + index,
|
|
376
|
+
content: content.replace(/\t/gu, " "),
|
|
377
|
+
hunk: 0,
|
|
378
|
+
}));
|
|
379
|
+
return renderCodeRows(lines, path, theme, { signs: false, footer: options.footer });
|
|
140
380
|
}
|
|
141
381
|
|
|
142
382
|
/** Limit a result preview by rendered rows while preserving the full source component for expansion. */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type Fence = { char: string; length: number; language: string | undefined; start: number };
|
|
4
|
+
|
|
5
|
+
const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/u;
|
|
6
|
+
const FENCE_CLOSE = /^\s{0,3}(`{3,}|~{3,})\s*$/u;
|
|
7
|
+
const HEADING = /^\s{0,3}#{1,6}(?:\s|$)/u;
|
|
8
|
+
const RULE = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/u;
|
|
9
|
+
const QUOTE = /^(\s{0,3}(?:>\s?)+)(.*)$/u;
|
|
10
|
+
const LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+(?:\[[ xX]\]\s+)?)(.*)$/u;
|
|
11
|
+
const INLINE = /(`+)(.+?)\1|\[([^\]\n]+)\]\(([^)\s]+)\)|\*\*(?=\S)(.+?)\*\*|__(?=\S)(.+?)__|\*(?=[^\s*])(.+?)\*|(?<!\w)_(?=[^\s_])(.+?)_(?!\w)/gu;
|
|
12
|
+
|
|
13
|
+
/** Resolve a fence info string such as `ts` or `typescript` to a highlighter language. */
|
|
14
|
+
function fenceLanguage(info: string): string | undefined {
|
|
15
|
+
const name = info.toLowerCase();
|
|
16
|
+
if (!name) return undefined;
|
|
17
|
+
return getLanguageFromPath(`fence.${name}`) ?? name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function highlightInline(text: string, theme: Theme): string {
|
|
21
|
+
const plain = (value: string) => value ? theme.fg("toolOutput", value) : "";
|
|
22
|
+
const marker = (value: string) => theme.fg("dim", value);
|
|
23
|
+
let output = "";
|
|
24
|
+
let last = 0;
|
|
25
|
+
for (const match of text.matchAll(INLINE)) {
|
|
26
|
+
output += plain(text.slice(last, match.index));
|
|
27
|
+
const [whole, ticks, code, label, url, strong, strongAlt, emphasis, emphasisAlt] = match;
|
|
28
|
+
if (ticks !== undefined) {
|
|
29
|
+
output += marker(ticks) + theme.fg("mdCode", code!) + marker(ticks);
|
|
30
|
+
} else if (label !== undefined) {
|
|
31
|
+
output += marker("[") + theme.fg("mdLink", label) + marker("](") + theme.fg("mdLinkUrl", url!) + marker(")");
|
|
32
|
+
} else if (strong !== undefined || strongAlt !== undefined) {
|
|
33
|
+
const delimiter = whole.slice(0, 2);
|
|
34
|
+
output += marker(delimiter) + theme.bold(plain(strong ?? strongAlt!)) + marker(delimiter);
|
|
35
|
+
} else {
|
|
36
|
+
const delimiter = whole[0]!;
|
|
37
|
+
output += marker(delimiter) + theme.italic(plain(emphasis ?? emphasisAlt!)) + marker(delimiter);
|
|
38
|
+
}
|
|
39
|
+
last = match.index + whole.length;
|
|
40
|
+
}
|
|
41
|
+
return output + plain(text.slice(last));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function highlightProse(line: string, theme: Theme): string {
|
|
45
|
+
if (HEADING.test(line)) return theme.bold(theme.fg("mdHeading", line));
|
|
46
|
+
if (RULE.test(line)) return theme.fg("mdHr", line);
|
|
47
|
+
const quote = line.match(QUOTE);
|
|
48
|
+
if (quote) return theme.fg("mdQuoteBorder", quote[1]!) + theme.italic(theme.fg("mdQuote", quote[2]!));
|
|
49
|
+
const item = line.match(LIST_ITEM);
|
|
50
|
+
if (item) return item[1]! + theme.fg("mdListBullet", item[2]! + item[3]!) + highlightInline(item[4]!, theme);
|
|
51
|
+
return highlightInline(line, theme);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function highlightFenceBody(lines: string[], language: string | undefined, theme: Theme): string[] {
|
|
55
|
+
if (lines.length === 0) return [];
|
|
56
|
+
if (!language) return lines.map((line) => theme.fg("mdCodeBlock", line));
|
|
57
|
+
return highlightCode(lines.join("\n"), language);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Highlight Markdown source line by line: prose gets Markdown styling, fenced code is
|
|
62
|
+
* highlighted as its declared language, and every character of the source stays visible.
|
|
63
|
+
*/
|
|
64
|
+
export function highlightMarkdown(lines: string[], theme: Theme): string[] {
|
|
65
|
+
const output = new Array<string>(lines.length);
|
|
66
|
+
let fence: Fence | undefined;
|
|
67
|
+
const flush = (end: number) => {
|
|
68
|
+
if (!fence) return;
|
|
69
|
+
const body = highlightFenceBody(lines.slice(fence.start, end), fence.language, theme);
|
|
70
|
+
body.forEach((line, offset) => {
|
|
71
|
+
output[fence!.start + offset] = line;
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
lines.forEach((line, index) => {
|
|
75
|
+
if (fence) {
|
|
76
|
+
const close = line.match(FENCE_CLOSE);
|
|
77
|
+
if (close && close[1]![0] === fence.char && close[1]!.length >= fence.length) {
|
|
78
|
+
flush(index);
|
|
79
|
+
output[index] = theme.fg("mdCodeBlockBorder", line);
|
|
80
|
+
fence = undefined;
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const open = line.match(FENCE_OPEN);
|
|
85
|
+
if (open) {
|
|
86
|
+
fence = { char: open[1]![0]!, length: open[1]!.length, language: fenceLanguage(open[2]!), start: index + 1 };
|
|
87
|
+
output[index] = theme.fg("mdCodeBlockBorder", line);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
output[index] = highlightProse(line, theme);
|
|
91
|
+
});
|
|
92
|
+
flush(lines.length);
|
|
93
|
+
return output.map((line, index) => line ?? lines[index]!);
|
|
94
|
+
}
|
|
@@ -33,9 +33,12 @@ import {
|
|
|
33
33
|
formatResultLineSummary,
|
|
34
34
|
getArgumentDetails,
|
|
35
35
|
getCallDetails,
|
|
36
|
-
|
|
36
|
+
getEditDiff,
|
|
37
|
+
getEditPatch,
|
|
37
38
|
getFileOutput,
|
|
38
39
|
getTextResult,
|
|
40
|
+
isReadTextResult,
|
|
41
|
+
splitReadFooter,
|
|
39
42
|
summarizeShellCommand,
|
|
40
43
|
} from "./compact-tools-invocation.ts";
|
|
41
44
|
import {
|
|
@@ -43,7 +46,8 @@ import {
|
|
|
43
46
|
limitComponentLines,
|
|
44
47
|
prefixedText,
|
|
45
48
|
renderArguments,
|
|
46
|
-
|
|
49
|
+
renderCodeDiff,
|
|
50
|
+
renderCodeView,
|
|
47
51
|
renderOutput,
|
|
48
52
|
renderToolCall,
|
|
49
53
|
styleMultiline,
|
|
@@ -60,6 +64,9 @@ import type {
|
|
|
60
64
|
ToolArgs,
|
|
61
65
|
} from "./compact-tools-types.ts";
|
|
62
66
|
|
|
67
|
+
/** Context lines kept around each change while an edit result is collapsed. */
|
|
68
|
+
const PREVIEW_DIFF_CONTEXT_LINES = 1;
|
|
69
|
+
|
|
63
70
|
const runtime = new ToolRuntime();
|
|
64
71
|
const registeredTools = new Set<CompactToolName>();
|
|
65
72
|
let registeredConfiguration: string | undefined;
|
|
@@ -161,19 +168,45 @@ function renderFileCall(
|
|
|
161
168
|
return container;
|
|
162
169
|
}
|
|
163
170
|
|
|
171
|
+
function renderFileBody(
|
|
172
|
+
name: CompactToolName,
|
|
173
|
+
args: ToolArgs,
|
|
174
|
+
result: AgentToolResult<unknown>,
|
|
175
|
+
output: string,
|
|
176
|
+
expanded: boolean,
|
|
177
|
+
theme: Theme,
|
|
178
|
+
isError: boolean,
|
|
179
|
+
): Component | undefined {
|
|
180
|
+
if (isError) return renderOutput(output, theme, isError);
|
|
181
|
+
const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : "";
|
|
182
|
+
if (name === "edit") {
|
|
183
|
+
const diff = getEditDiff(result);
|
|
184
|
+
const component = diff
|
|
185
|
+
? renderCodeDiff(getEditPatch(result), diff, path, theme, expanded ? {} : { contextLines: PREVIEW_DIFF_CONTEXT_LINES })
|
|
186
|
+
: undefined;
|
|
187
|
+
return component ?? renderOutput(output, theme, isError);
|
|
188
|
+
}
|
|
189
|
+
if (name === "read" && isReadTextResult(result)) {
|
|
190
|
+
const { body, footer } = splitReadFooter(output);
|
|
191
|
+
const startLine = typeof args.offset === "number" ? args.offset : 1;
|
|
192
|
+
return renderCodeView(body, path, theme, { startLine, footer }) ?? renderOutput(output, theme, isError);
|
|
193
|
+
}
|
|
194
|
+
if (name === "write") return renderCodeView(output, path, theme) ?? renderOutput(output, theme, isError);
|
|
195
|
+
return renderOutput(output, theme, isError);
|
|
196
|
+
}
|
|
197
|
+
|
|
164
198
|
function appendFileResult(
|
|
165
199
|
container: Container,
|
|
166
|
-
|
|
200
|
+
name: CompactToolName,
|
|
167
201
|
state: RowState,
|
|
202
|
+
args: ToolArgs,
|
|
203
|
+
result: AgentToolResult<unknown>,
|
|
168
204
|
output: string,
|
|
169
|
-
editChanges: string,
|
|
170
205
|
theme: Theme,
|
|
171
206
|
isError: boolean,
|
|
172
207
|
): void {
|
|
173
208
|
if (!state.expanded && !state.preview) return;
|
|
174
|
-
const component =
|
|
175
|
-
? renderEditChanges(editChanges, theme)
|
|
176
|
-
: renderOutput(output, theme, isError);
|
|
209
|
+
const component = renderFileBody(name, args, result, output, state.expanded === true, theme, isError);
|
|
177
210
|
if (!component) return;
|
|
178
211
|
container.addChild(state.expanded ? component : limitComponentLines(component, runtime.config.previewLines, theme));
|
|
179
212
|
}
|
|
@@ -190,10 +223,10 @@ function renderFileResult(
|
|
|
190
223
|
runtime.syncExpansion(state, ctx.expanded, name);
|
|
191
224
|
if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
|
|
192
225
|
const output = getFileOutput(name, ctx.args, result, ctx.isError);
|
|
193
|
-
const
|
|
194
|
-
runtime.setResultAvailable(state, name,
|
|
226
|
+
const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
|
|
227
|
+
runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0);
|
|
195
228
|
const container = new CachedContainer();
|
|
196
|
-
appendFileResult(container,
|
|
229
|
+
appendFileResult(container, name, state, ctx.args, result, output, theme, ctx.isError);
|
|
197
230
|
if (!options.isPartial && !state.resultLineSummaryComputed) {
|
|
198
231
|
state.resultLineSummary = formatResultLineSummary(name, ctx.args, result, output);
|
|
199
232
|
state.resultLineSummaryComputed = true;
|