pi-compact-tools 0.7.8 → 0.8.1
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 +13 -108
- package/extensions/compact-tools-color.ts +24 -0
- package/extensions/compact-tools-invocation.ts +28 -6
- 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
|
@@ -1,48 +1,19 @@
|
|
|
1
1
|
# pi-compact-tools
|
|
2
2
|
|
|
3
|
-
Compact, expandable rendering for Pi's built-in tools, with a
|
|
3
|
+
Compact, expandable rendering for Pi's built-in tools, with a GitHub Dark theme.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## Demo
|
|
8
|
-
|
|
9
|
-
The UI uses portable Unicode symbols and does not require a Nerd Font.
|
|
10
|
-
|
|
11
|
-
### Compact tool workflow
|
|
12
|
-
|
|
13
|
-
Active, successful, and failed tool calls stay compact while preserving useful status and timing information.
|
|
14
|
-
|
|
15
|
-

|
|
16
|
-
|
|
17
|
-
### Progress and duration display
|
|
18
|
-
|
|
19
|
-
The working label above the prompt uses the Thinking summary color with a soft white light sweep. Every tool state uses the same `⦁` glyph; running tools use a fourteen-frame cosine-eased RGB pulse between dim and bright gray, then settle to green on success or red on failure. Elapsed time remains visible in the neutral control color.
|
|
5
|
+

|
|
20
6
|
|
|
21
7
|

|
|
22
8
|
|
|
23
|
-
### Bounded result previews
|
|
24
|
-
|
|
25
|
-
Completed `edit` rows show only the lines actually removed (`-`) or added (`+`), without unchanged context, hunk metadata, or success prose. The per-tool `auto_compact` setting chooses between hidden results and a bounded preview; click or `Ctrl+O` uses Pi's normal expansion flow to reveal all changed lines.
|
|
26
|
-
|
|
27
9
|

|
|
28
10
|
|
|
29
11
|
## Features
|
|
30
12
|
|
|
31
|
-
- Compact
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
- Millisecond-precision duration and completed output line counts with neutral status text
|
|
36
|
-
- 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, including common collapsed shell file operations
|
|
38
|
-
- Click and `Ctrl+O` follow Pi's built-in expansion state while preview rows collapse back to their preview
|
|
39
|
-
- Edit patches display only removed (`-`) and added (`+`) lines, omitting unchanged context
|
|
40
|
-
- Configurable `previewLines` prevents large diffs, HTML, and shell output from flooding the transcript
|
|
41
|
-
- Width-cached expanded results, edit-diff processing, and unchanged result reuse during status animation
|
|
42
|
-
- Dark `│` and `└─` visual grouping
|
|
43
|
-
- Distinct cool-blue thinking text
|
|
44
|
-
- Truecolor and 256-color terminal support, plus Windows, Unix, and classic Mac line endings
|
|
45
|
-
- No Nerd Font requirement
|
|
13
|
+
- Compact rows for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`, with status, duration, and line count
|
|
14
|
+
- Syntax-highlighted edit diffs and numbered `read`/`write` code views, including Markdown code fences
|
|
15
|
+
- Animated working label above the prompt and a `Ctrl+T` thinking summary/detail cycle
|
|
16
|
+
- No Nerd Font required
|
|
46
17
|
|
|
47
18
|
## Install
|
|
48
19
|
|
|
@@ -50,91 +21,27 @@ Completed `edit` rows show only the lines actually removed (`-`) or added (`+`),
|
|
|
50
21
|
pi install npm:pi-compact-tools
|
|
51
22
|
```
|
|
52
23
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
pi install git:github.com/nedleeds/pi-compact-tools
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
Restart Pi or run `/reload`.
|
|
60
|
-
|
|
61
|
-
Migrating from the previous package name:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
pi remove npm:@nedleeds/pi-compact-ui
|
|
65
|
-
pi install npm:pi-compact-tools
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
## Theme
|
|
69
|
-
|
|
70
|
-
Select `github-dark-pro` in Pi's settings, or add it to `~/.pi/agent/settings.json`:
|
|
71
|
-
|
|
72
|
-
```json
|
|
73
|
-
{
|
|
74
|
-
"theme": "github-dark-pro"
|
|
75
|
-
}
|
|
76
|
-
```
|
|
24
|
+
Then restart Pi or run `/reload`. For the bundled theme, set `"theme": "github-dark-pro"` in `~/.pi/agent/settings.json`.
|
|
77
25
|
|
|
78
26
|
## Configuration
|
|
79
27
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Default configuration:
|
|
28
|
+
Optional. Put it in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.json`:
|
|
83
29
|
|
|
84
30
|
```json
|
|
85
31
|
{
|
|
86
|
-
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json",
|
|
87
32
|
"tools": ["read", "write", "edit", "bash"],
|
|
88
33
|
"previewLines": 10,
|
|
89
|
-
"auto_compact": {
|
|
90
|
-
"read": true,
|
|
91
|
-
"write": true,
|
|
92
|
-
"edit": false,
|
|
93
|
-
"bash": true
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Every tool call title uses `⦁`. Pending calls use dark gray; active calls smoothly pulse through fourteen cosine-eased RGB frames at a 45 ms interval without disappearing. Animation starts while arguments stream, so `write` and `edit` remain visibly active before their filesystem operation begins. Successful titles use green, and failed titles use red. `Done`, `Failed`, elapsed time, and line counts use the same neutral control color without printing inline click or keyboard hints. A single Pi working label animates independently of token arrival and reports `Thinking…`, `Responding…`, semantic built-in states such as `Reading file…`, or cleaned custom states such as `Using jira search…`. Invocation arguments remain in the tool row instead of being duplicated above the prompt. No separate spinner glyph is shown above the prompt.
|
|
99
|
-
|
|
100
|
-
`auto_compact` controls each tool's initial result state:
|
|
101
|
-
|
|
102
|
-
- `true` starts with the result hidden.
|
|
103
|
-
- `false` starts with at most `previewLines` rendered rows visible.
|
|
104
|
-
- Clicking or pressing `Ctrl+O` expands the complete result through Pi's built-in expansion state.
|
|
105
|
-
|
|
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-lines preview.
|
|
107
|
-
|
|
108
|
-
To enable all Unix-compatible tools:
|
|
109
|
-
|
|
110
|
-
```json
|
|
111
|
-
{
|
|
112
|
-
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"]
|
|
34
|
+
"auto_compact": { "read": true, "write": true, "edit": false, "bash": true }
|
|
113
35
|
}
|
|
114
36
|
```
|
|
115
37
|
|
|
116
|
-
|
|
38
|
+
- `tools`: tools to render compactly. Also available: `grep`, `find`, `ls`, `powershell`.
|
|
39
|
+
- `auto_compact`: `true` starts hidden, `false` starts with a preview of up to `previewLines` rows (`1`–`100`).
|
|
117
40
|
|
|
118
41
|
## Controls
|
|
119
42
|
|
|
120
|
-
`Ctrl+
|
|
121
|
-
|
|
122
|
-
```text
|
|
123
|
-
one-line summary → summary + │-indented detail → one-line summary → Thinking...
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
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
|
-
|
|
128
|
-
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 displayed `-`/`+` 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
|
-
|
|
130
|
-
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
|
-
|
|
132
|
-
## Notes
|
|
133
|
-
|
|
134
|
-
- Pi `0.85.1+` and Node.js `22.19.0+` are recommended.
|
|
135
|
-
- The working indicator and mouse interaction are provided by Pi's TUI; `Ctrl+O` also works in regular mode.
|
|
136
|
-
- Duration covers the full rendered tool-call lifecycle, including streamed `write` and `edit` arguments.
|
|
137
|
-
- 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.
|
|
43
|
+
- Click a row or press `Ctrl+O` to expand or collapse results.
|
|
44
|
+
- `Ctrl+T` cycles thinking: summary → detail → summary → hidden.
|
|
138
45
|
|
|
139
46
|
## Development
|
|
140
47
|
|
|
@@ -144,8 +51,6 @@ npm run check
|
|
|
144
51
|
pi -e .
|
|
145
52
|
```
|
|
146
53
|
|
|
147
|
-
Pi extensions execute with full system access. Review third-party extension source before installation.
|
|
148
|
-
|
|
149
54
|
## License
|
|
150
55
|
|
|
151
56
|
MIT
|
|
@@ -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
|
+
}
|
|
@@ -321,6 +321,7 @@ export function getTextResult(result: AgentToolResult<unknown>): string {
|
|
|
321
321
|
|
|
322
322
|
type ResultDetails = {
|
|
323
323
|
diff?: unknown;
|
|
324
|
+
patch?: unknown;
|
|
324
325
|
truncation?: {
|
|
325
326
|
firstLineExceedsLimit?: boolean;
|
|
326
327
|
outputLines?: number;
|
|
@@ -341,21 +342,42 @@ function countTextLines(text: string): number {
|
|
|
341
342
|
return count;
|
|
342
343
|
}
|
|
343
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
|
+
|
|
344
355
|
/** Keep only the lines that the edit actually removed or added; discard context and ellipses. */
|
|
345
356
|
export function getEditChanges(result: AgentToolResult<unknown>): string {
|
|
346
|
-
|
|
347
|
-
if (typeof diff !== "string") return "";
|
|
348
|
-
return normalizeLineEndings(diff)
|
|
357
|
+
return getEditDiff(result)
|
|
349
358
|
.split("\n")
|
|
350
359
|
.filter((line) => line.startsWith("-") || line.startsWith("+"))
|
|
351
360
|
.join("\n")
|
|
352
361
|
.trimEnd();
|
|
353
362
|
}
|
|
354
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
|
+
|
|
355
379
|
function stripGeneratedFooter(name: string, text: string, details: ResultDetails | undefined): string {
|
|
356
|
-
if (name === "read")
|
|
357
|
-
return text.replace(/\n\n\[(?:Showing lines |\d+ more lines in file\.)[^\n]*\]$/u, "");
|
|
358
|
-
}
|
|
380
|
+
if (name === "read") return splitReadFooter(text).body;
|
|
359
381
|
const hasGeneratedFooter = details?.truncation?.outputLines !== undefined
|
|
360
382
|
|| details?.matchLimitReached !== undefined
|
|
361
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;
|