pi-compact-tools 0.6.1 → 0.7.7
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 +29 -61
- package/examples/compact-tools-2.json +1 -4
- package/examples/compact-tools.json +1 -4
- package/extensions/compact-tools-color.ts +65 -0
- package/extensions/compact-tools-config.ts +7 -42
- package/extensions/compact-tools-core.ts +26 -0
- package/extensions/compact-tools-invocation.ts +166 -31
- package/extensions/compact-tools-layout.ts +23 -22
- package/extensions/compact-tools-progress.ts +158 -0
- package/extensions/compact-tools-runtime.ts +44 -70
- package/extensions/compact-tools-thinking.ts +53 -92
- package/extensions/compact-tools-types.ts +11 -8
- package/extensions/compact-tools.ts +126 -94
- package/package.json +1 -1
- package/schemas/compact-tools.schema.json +8 -24
- package/scripts/check-resources.mjs +13 -16
- package/extensions/compact-tools-input.ts +0 -8
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
|
@@ -6,7 +6,7 @@ Compact, expandable rendering for Pi's built-in tools, with a polished GitHub Da
|
|
|
6
6
|
|
|
7
7
|
## Demo
|
|
8
8
|
|
|
9
|
-
The
|
|
9
|
+
The UI uses portable Unicode symbols and does not require a Nerd Font.
|
|
10
10
|
|
|
11
11
|
### Compact tool workflow
|
|
12
12
|
|
|
@@ -14,32 +14,34 @@ Active, successful, and failed tool calls stay compact while preserving useful s
|
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
|
-
###
|
|
17
|
+
### Progress and duration display
|
|
18
18
|
|
|
19
|
-
The
|
|
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.
|
|
20
20
|
|
|
21
21
|

|
|
22
22
|
|
|
23
|
-
###
|
|
23
|
+
### Bounded result previews
|
|
24
24
|
|
|
25
|
-
Completed `edit` rows show
|
|
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
26
|
|
|
27
27
|

|
|
28
28
|
|
|
29
29
|
## Features
|
|
30
30
|
|
|
31
31
|
- Compact rendering for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
32
|
+
- 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 without exposing invocation arguments
|
|
34
|
+
- Four-step `Ctrl+T` thinking cycle when provider detail is available: summary, detail, summary, then hidden
|
|
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; collapsed shell calls show concise intent summaries
|
|
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
|
|
40
42
|
- Dark `│` and `└─` visual grouping
|
|
41
43
|
- Distinct cool-blue thinking text
|
|
42
|
-
- Windows, Unix, and classic Mac line
|
|
44
|
+
- Truecolor and 256-color terminal support, plus Windows, Unix, and classic Mac line endings
|
|
43
45
|
- No Nerd Font requirement
|
|
44
46
|
|
|
45
47
|
## Install
|
|
@@ -83,30 +85,25 @@ Default configuration:
|
|
|
83
85
|
{
|
|
84
86
|
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json",
|
|
85
87
|
"tools": ["read", "write", "edit", "bash"],
|
|
88
|
+
"previewLines": 10,
|
|
86
89
|
"auto_compact": {
|
|
87
90
|
"read": true,
|
|
88
91
|
"write": true,
|
|
89
92
|
"edit": false,
|
|
90
93
|
"bash": true
|
|
91
|
-
},
|
|
92
|
-
"spinner": {
|
|
93
|
-
"frames": ["◐", "◓", "◑", "◒"],
|
|
94
|
-
"intervalMs": 120
|
|
95
94
|
}
|
|
96
95
|
}
|
|
97
96
|
```
|
|
98
97
|
|
|
99
|
-
|
|
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.
|
|
100
99
|
|
|
101
|
-
|
|
100
|
+
`auto_compact` controls each tool's initial result state:
|
|
102
101
|
|
|
103
|
-
`
|
|
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.
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
- `false` starts with the full result visible and is unaffected by `Ctrl+O`.
|
|
107
|
-
- Clicking any compact-rendered row still toggles that row's result.
|
|
108
|
-
|
|
109
|
-
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.
|
|
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.
|
|
110
107
|
|
|
111
108
|
To enable all Unix-compatible tools:
|
|
112
109
|
|
|
@@ -118,23 +115,6 @@ To enable all Unix-compatible tools:
|
|
|
118
115
|
|
|
119
116
|
Add `"powershell"` on Windows if desired.
|
|
120
117
|
|
|
121
|
-
## Nerd Font spinner preset
|
|
122
|
-
|
|
123
|
-
If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset enables all eight tools and the spinner shown in the demos:
|
|
124
|
-
|
|
125
|
-
```json
|
|
126
|
-
{
|
|
127
|
-
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json",
|
|
128
|
-
"tools": ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"],
|
|
129
|
-
"spinner": {
|
|
130
|
-
"frames": ["✽", "✻", "✲", "✢", "✲", "✻"],
|
|
131
|
-
"intervalMs": 80
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Remove `"powershell"` on systems where it is unavailable.
|
|
137
|
-
|
|
138
118
|
## Controls
|
|
139
119
|
|
|
140
120
|
`Ctrl+T` cycles every thinking block through:
|
|
@@ -143,30 +123,18 @@ Remove `"powershell"` on systems where it is unavailable.
|
|
|
143
123
|
one-line summary → summary + │-indented detail → one-line summary → Thinking...
|
|
144
124
|
```
|
|
145
125
|
|
|
146
|
-
Standalone bold lines and Markdown headings split one provider thinking run into logical sections. Each section gets its own summary
|
|
147
|
-
|
|
148
|
-
Relevant invocation details stay visible, including paths, patterns, shell commands, and auxiliary arguments. After `grep`, `find`, or `ls` completes, auxiliary arguments appear in parentheses beside the `Done in …s` status instead of in the invocation line or a JSON block. Read calls omit offset and limit noise, then report the number of lines read after completion. Large payloads represented as results, such as write content and edit diffs, follow the result toggle.
|
|
149
|
-
|
|
150
|
-
A mouse click toggles one row's result:
|
|
151
|
-
|
|
152
|
-
```text
|
|
153
|
-
result hidden ↔ full result
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
`Ctrl+O` operates on all rows whose tool has `auto_compact: true`:
|
|
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.
|
|
157
127
|
|
|
158
|
-
|
|
159
|
-
- If every participating row is fully expanded, it collapses all participating rows.
|
|
160
|
-
- Rows whose tool has `auto_compact: false` are left unchanged.
|
|
128
|
+
Primary file and search targets stay visible. Collapsed `bash` and `powershell` rows describe intent—such as `Run tests`, `Check repository status`, or `Search text`—without printing arguments; 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.
|
|
161
129
|
|
|
162
|
-
|
|
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.
|
|
163
131
|
|
|
164
132
|
## Notes
|
|
165
133
|
|
|
166
|
-
- Pi `0.85.1+` and Node.js
|
|
167
|
-
-
|
|
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.
|
|
168
136
|
- Duration covers the full rendered tool-call lifecycle, including streamed `write` and `edit` arguments.
|
|
169
|
-
- Selected tools override
|
|
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.
|
|
170
138
|
|
|
171
139
|
## Development
|
|
172
140
|
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json",
|
|
3
3
|
"tools": ["read", "write", "edit", "bash"],
|
|
4
|
+
"previewLines": 10,
|
|
4
5
|
"auto_compact": {
|
|
5
6
|
"read": true,
|
|
6
7
|
"write": true,
|
|
7
8
|
"edit": false,
|
|
8
9
|
"bash": true
|
|
9
|
-
},
|
|
10
|
-
"spinner": {
|
|
11
|
-
"frames": ["●", "●", " ", "●", "●"],
|
|
12
|
-
"intervalMs": 120
|
|
13
10
|
}
|
|
14
11
|
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json",
|
|
3
3
|
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
|
4
|
+
"previewLines": 10,
|
|
4
5
|
"auto_compact": {
|
|
5
6
|
"read": true,
|
|
6
7
|
"write": true,
|
|
7
8
|
"edit": false,
|
|
8
9
|
"bash": true
|
|
9
|
-
},
|
|
10
|
-
"spinner": {
|
|
11
|
-
"frames": ["◐", "◓", "◑", "◒"],
|
|
12
|
-
"intervalMs": 120
|
|
13
10
|
}
|
|
14
11
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type Rgb = { r: number; g: number; b: number };
|
|
4
|
+
type ThemeForeground = Parameters<Theme["getFgAnsi"]>[0];
|
|
5
|
+
|
|
6
|
+
const ANSI_BASIC_RGB: readonly Rgb[] = [
|
|
7
|
+
{ r: 0, g: 0, b: 0 }, { r: 128, g: 0, b: 0 }, { r: 0, g: 128, b: 0 }, { r: 128, g: 128, b: 0 },
|
|
8
|
+
{ r: 0, g: 0, b: 128 }, { r: 128, g: 0, b: 128 }, { r: 0, g: 128, b: 128 }, { r: 192, g: 192, b: 192 },
|
|
9
|
+
{ r: 128, g: 128, b: 128 }, { r: 255, g: 0, b: 0 }, { r: 0, g: 255, b: 0 }, { r: 255, g: 255, b: 0 },
|
|
10
|
+
{ r: 0, g: 0, b: 255 }, { r: 255, g: 0, b: 255 }, { r: 0, g: 255, b: 255 }, { r: 255, g: 255, b: 255 },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function ansi256ToRgb(index: number): Rgb {
|
|
14
|
+
if (index < 16) return ANSI_BASIC_RGB[index] ?? { r: 192, g: 192, b: 192 };
|
|
15
|
+
if (index < 232) {
|
|
16
|
+
const value = index - 16;
|
|
17
|
+
const channel = (part: number) => part === 0 ? 0 : 55 + part * 40;
|
|
18
|
+
return {
|
|
19
|
+
r: channel(Math.floor(value / 36)),
|
|
20
|
+
g: channel(Math.floor((value % 36) / 6)),
|
|
21
|
+
b: channel(value % 6),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const gray = 8 + Math.min(23, index - 232) * 10;
|
|
25
|
+
return { r: gray, g: gray, b: gray };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rgbToAnsi256(rgb: Rgb): number {
|
|
29
|
+
let bestIndex = 0;
|
|
30
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
31
|
+
for (let index = 0; index < 256; index++) {
|
|
32
|
+
const candidate = ansi256ToRgb(index);
|
|
33
|
+
const distance = (rgb.r - candidate.r) ** 2 + (rgb.g - candidate.g) ** 2 + (rgb.b - candidate.b) ** 2;
|
|
34
|
+
if (distance >= bestDistance) continue;
|
|
35
|
+
bestIndex = index;
|
|
36
|
+
bestDistance = distance;
|
|
37
|
+
}
|
|
38
|
+
return bestIndex;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function themeColorRgb(theme: Theme, color: ThemeForeground): Rgb | undefined {
|
|
42
|
+
if (typeof theme.getFgAnsi !== "function") return undefined;
|
|
43
|
+
const ansi = theme.getFgAnsi(color);
|
|
44
|
+
const trueColor = ansi.match(/\[38;2;(\d+);(\d+);(\d+)m/u);
|
|
45
|
+
if (trueColor) return { r: Number(trueColor[1]), g: Number(trueColor[2]), b: Number(trueColor[3]) };
|
|
46
|
+
const indexed = ansi.match(/\[38;5;(\d+)m/u);
|
|
47
|
+
return indexed ? ansi256ToRgb(Number(indexed[1])) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function interpolateRgb(from: Rgb, to: Rgb, amount: number): Rgb {
|
|
51
|
+
const bounded = Math.max(0, Math.min(1, amount));
|
|
52
|
+
const channel = (start: number, end: number) => Math.round(start + (end - start) * bounded);
|
|
53
|
+
return {
|
|
54
|
+
r: channel(from.r, to.r),
|
|
55
|
+
g: channel(from.g, to.g),
|
|
56
|
+
b: channel(from.b, to.b),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
|
|
61
|
+
if (typeof theme.getColorMode === "function" && theme.getColorMode() === "256color") {
|
|
62
|
+
return `\x1b[38;5;${rgbToAnsi256(color)}m${text}\x1b[39m`;
|
|
63
|
+
}
|
|
64
|
+
return `\x1b[38;2;${color.r};${color.g};${color.b}m${text}\x1b[39m`;
|
|
65
|
+
}
|
|
@@ -12,6 +12,7 @@ type JsonObject = Record<string, unknown>;
|
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_CONFIG: CompactToolsConfig = {
|
|
14
14
|
tools: ["read", "write", "edit", "bash"],
|
|
15
|
+
previewLines: 10,
|
|
15
16
|
auto_compact: {
|
|
16
17
|
read: true,
|
|
17
18
|
write: true,
|
|
@@ -22,10 +23,6 @@ export const DEFAULT_CONFIG: CompactToolsConfig = {
|
|
|
22
23
|
find: true,
|
|
23
24
|
ls: true,
|
|
24
25
|
},
|
|
25
|
-
spinner: {
|
|
26
|
-
frames: ["◐", "◓", "◑", "◒"],
|
|
27
|
-
intervalMs: 120,
|
|
28
|
-
},
|
|
29
26
|
};
|
|
30
27
|
|
|
31
28
|
function isObject(value: unknown): value is JsonObject {
|
|
@@ -40,10 +37,6 @@ function isIntegerInRange(value: unknown, minimum: number, maximum: number): val
|
|
|
40
37
|
return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
function isNonEmptyStringArray(value: unknown): value is string[] {
|
|
44
|
-
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
40
|
function parseTools(value: unknown, path: string): CompactToolName[] | undefined {
|
|
48
41
|
if (!Array.isArray(value)) {
|
|
49
42
|
warn(path, "tools must be an array; using previous values");
|
|
@@ -82,28 +75,6 @@ function parseAutoCompact(
|
|
|
82
75
|
return result;
|
|
83
76
|
}
|
|
84
77
|
|
|
85
|
-
function parseSpinner(
|
|
86
|
-
base: CompactToolsConfig["spinner"],
|
|
87
|
-
value: unknown,
|
|
88
|
-
path: string,
|
|
89
|
-
): CompactToolsConfig["spinner"] {
|
|
90
|
-
if (value === undefined) return base;
|
|
91
|
-
if (!isObject(value)) {
|
|
92
|
-
warn(path, "spinner must be an object; using previous values");
|
|
93
|
-
return base;
|
|
94
|
-
}
|
|
95
|
-
const frames = value.frames;
|
|
96
|
-
const intervalMs = value.intervalMs;
|
|
97
|
-
const validFrames = isNonEmptyStringArray(frames);
|
|
98
|
-
const validInterval = isIntegerInRange(intervalMs, 40, 5_000);
|
|
99
|
-
if (frames !== undefined && !validFrames) warn(path, "spinner.frames must be a non-empty string array");
|
|
100
|
-
if (intervalMs !== undefined && !validInterval) warn(path, "spinner.intervalMs must be 40–5000");
|
|
101
|
-
return {
|
|
102
|
-
frames: validFrames ? frames : base.frames,
|
|
103
|
-
intervalMs: validInterval ? intervalMs : base.intervalMs,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
78
|
export function mergeConfig(base: CompactToolsConfig, value: unknown, path: string): CompactToolsConfig {
|
|
108
79
|
if (value === undefined) return base;
|
|
109
80
|
if (!isObject(value)) {
|
|
@@ -112,8 +83,12 @@ export function mergeConfig(base: CompactToolsConfig, value: unknown, path: stri
|
|
|
112
83
|
}
|
|
113
84
|
const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
|
|
114
85
|
const auto_compact = parseAutoCompact(base.auto_compact, value.auto_compact, path);
|
|
115
|
-
const
|
|
116
|
-
|
|
86
|
+
const previewLines = value.previewLines === undefined ? base.previewLines
|
|
87
|
+
: isIntegerInRange(value.previewLines, 1, 100) ? value.previewLines : base.previewLines;
|
|
88
|
+
if (value.previewLines !== undefined && !isIntegerInRange(value.previewLines, 1, 100)) {
|
|
89
|
+
warn(path, "previewLines must be 1–100; using previous value");
|
|
90
|
+
}
|
|
91
|
+
return { tools, auto_compact, previewLines };
|
|
117
92
|
}
|
|
118
93
|
|
|
119
94
|
function readJson(path: string): unknown {
|
|
@@ -133,13 +108,3 @@ export function loadConfig(cwd?: string, projectTrusted = false): CompactToolsCo
|
|
|
133
108
|
const projectPath = join(cwd, CONFIG_DIR_NAME, CONFIG_FILE);
|
|
134
109
|
return mergeConfig(globalConfig, readJson(projectPath), projectPath);
|
|
135
110
|
}
|
|
136
|
-
|
|
137
|
-
export function isFullscreenMode(argv = process.argv): boolean {
|
|
138
|
-
for (let index = 0; index < argv.length; index++) {
|
|
139
|
-
const argument = argv[index];
|
|
140
|
-
if (argument === "--tui-mode") return argv[index + 1] === "fullscreen";
|
|
141
|
-
if (argument?.startsWith("--tui-mode=")) return argument.slice("--tui-mode=".length) === "fullscreen";
|
|
142
|
-
}
|
|
143
|
-
const settings = readJson(join(getAgentDir(), "settings.json"));
|
|
144
|
-
return isObject(settings) && settings.tuiMode === "fullscreen";
|
|
145
|
-
}
|
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
export type RowStatus = "pending" | "running" | "success" | "error";
|
|
4
4
|
|
|
5
|
+
export type IndicatorTone = "borderAccent" | "border" | "borderMuted" | "success" | "error";
|
|
6
|
+
|
|
7
|
+
export const RUNNING_INDICATOR_FRAME_COUNT = 14;
|
|
8
|
+
const RUNNING_INDICATOR_MIN_STRENGTH = 0.08;
|
|
9
|
+
|
|
10
|
+
export function indicatorStrength(status: RowStatus, frame = 0): number {
|
|
11
|
+
if (status !== "running") return 1;
|
|
12
|
+
const normalizedFrame = ((frame % RUNNING_INDICATOR_FRAME_COUNT) + RUNNING_INDICATOR_FRAME_COUNT)
|
|
13
|
+
% RUNNING_INDICATOR_FRAME_COUNT;
|
|
14
|
+
const wave = (Math.cos((normalizedFrame / RUNNING_INDICATOR_FRAME_COUNT) * Math.PI * 2) + 1) / 2;
|
|
15
|
+
return RUNNING_INDICATOR_MIN_STRENGTH + (1 - RUNNING_INDICATOR_MIN_STRENGTH) * wave;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Semantic fallback for themes whose foreground RGB values cannot be resolved. */
|
|
19
|
+
export function indicatorTone(status: RowStatus, frame = 0): IndicatorTone {
|
|
20
|
+
if (status === "success") return "success";
|
|
21
|
+
if (status === "error") return "error";
|
|
22
|
+
if (status === "pending") return "borderAccent";
|
|
23
|
+
const strength = indicatorStrength(status, frame);
|
|
24
|
+
return strength >= 0.7 ? "borderAccent" : strength >= 0.35 ? "border" : "borderMuted";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function indicatorGlyph(_status: RowStatus, _frame = 0): string {
|
|
28
|
+
return "⦁";
|
|
29
|
+
}
|
|
30
|
+
|
|
5
31
|
export function normalizeLineEndings(value: string): string {
|
|
6
32
|
return value.replace(/\r\n?|\n/g, "\n");
|
|
7
33
|
}
|
|
@@ -24,24 +24,6 @@ function collectArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
|
24
24
|
return details;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function formatInlineValue(value: unknown): string {
|
|
28
|
-
if (typeof value === "string") {
|
|
29
|
-
const compact = normalizeLineEndings(value).replace(/\s+/gu, " ").trim();
|
|
30
|
-
return compact || '""';
|
|
31
|
-
}
|
|
32
|
-
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
33
|
-
return String(value);
|
|
34
|
-
}
|
|
35
|
-
return JSON.stringify(value) ?? String(value);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function formatArgumentSummary(name: string, args: ToolArgs): string | undefined {
|
|
39
|
-
if (!INLINE_ARGUMENT_TOOLS.has(name)) return undefined;
|
|
40
|
-
const arguments_ = Object.entries(collectArgumentDetails(name, args));
|
|
41
|
-
if (arguments_.length === 0) return undefined;
|
|
42
|
-
return arguments_.map(([key, value]) => `${key} ${formatInlineValue(value)}`).join(" · ");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
27
|
export function getCallDetails(name: string, args: ToolArgs): string {
|
|
46
28
|
const path = pathFrom(args) || (INLINE_ARGUMENT_TOOLS.has(name) ? "." : "");
|
|
47
29
|
if (name === "read") return path;
|
|
@@ -55,6 +37,106 @@ export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
|
55
37
|
return collectArgumentDetails(name, args);
|
|
56
38
|
}
|
|
57
39
|
|
|
40
|
+
function shellSegments(command: string): string[] {
|
|
41
|
+
const segments: string[] = [];
|
|
42
|
+
let current = "";
|
|
43
|
+
let quote: "'" | '"' | "`" | undefined;
|
|
44
|
+
let escaped = false;
|
|
45
|
+
for (let index = 0; index < command.length; index++) {
|
|
46
|
+
const character = command[index]!;
|
|
47
|
+
if (escaped) {
|
|
48
|
+
current += character;
|
|
49
|
+
escaped = false;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (character === "\\" && quote !== "'") {
|
|
53
|
+
current += character;
|
|
54
|
+
escaped = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (quote) {
|
|
58
|
+
current += character;
|
|
59
|
+
if (character === quote) quote = undefined;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (character === "'" || character === '"' || character === "`") {
|
|
63
|
+
quote = character;
|
|
64
|
+
current += character;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const pair = command.slice(index, index + 2);
|
|
68
|
+
if (character === "\n" || character === ";" || pair === "&&" || pair === "||") {
|
|
69
|
+
if (current.trim()) segments.push(current.trim());
|
|
70
|
+
current = "";
|
|
71
|
+
if (pair === "&&" || pair === "||") index++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
current += character;
|
|
75
|
+
}
|
|
76
|
+
if (current.trim()) segments.push(current.trim());
|
|
77
|
+
return segments;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function unquote(value: string): string {
|
|
81
|
+
return value.replace(/^["']|["']$/gu, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A calm collapsed label that describes intent without exposing command arguments. */
|
|
85
|
+
export function summarizeShellCommand(name: string, command: string): string {
|
|
86
|
+
const allSegments = shellSegments(normalizeLineEndings(command));
|
|
87
|
+
const segments = allSegments.filter((segment) => !/^\s*(?:cd|pushd|popd)\b/iu.test(segment));
|
|
88
|
+
const primary = segments[0] ?? allSegments[0] ?? "";
|
|
89
|
+
if (!primary) return name === "powershell" ? "Prepare PowerShell command" : "Prepare shell command";
|
|
90
|
+
|
|
91
|
+
const words = primary.trim().split(/\s+/u).map(unquote);
|
|
92
|
+
while (words.length > 0) {
|
|
93
|
+
if (/^[A-Za-z_][A-Za-z\d_]*=.*/u.test(words[0] ?? "")
|
|
94
|
+
|| ["command", "env", "sudo", "time"].includes((words[0] ?? "").toLowerCase())) {
|
|
95
|
+
words.shift();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const executable = (words[0] ?? "").replace(/^.*[\\/]/u, "").replace(/\.(?:cmd|exe|ps1)$/iu, "").toLowerCase();
|
|
101
|
+
const action = (words[1] ?? "").toLowerCase();
|
|
102
|
+
const subject = words[2] ?? "";
|
|
103
|
+
|
|
104
|
+
let summary: string;
|
|
105
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executable) && action === "run" && subject) {
|
|
106
|
+
summary = `Run ${subject} task`;
|
|
107
|
+
} else if (["npm", "pnpm", "yarn", "bun"].includes(executable) && ["test", "check"].includes(action)) {
|
|
108
|
+
summary = action === "test" ? "Run tests" : "Run project checks";
|
|
109
|
+
} else if (["npm", "pnpm", "yarn", "bun"].includes(executable) && ["install", "i", "ci"].includes(action)) {
|
|
110
|
+
summary = "Install dependencies";
|
|
111
|
+
} else if (executable === "git" && action === "status") {
|
|
112
|
+
summary = "Check repository status";
|
|
113
|
+
} else if (executable === "git" && action === "diff") {
|
|
114
|
+
summary = "Review repository changes";
|
|
115
|
+
} else if (executable === "git" && action === "log") {
|
|
116
|
+
summary = "Review commit history";
|
|
117
|
+
} else if (executable === "git" && ["fetch", "pull", "push", "clone"].includes(action)) {
|
|
118
|
+
summary = `${action[0]!.toUpperCase()}${action.slice(1)} repository`;
|
|
119
|
+
} else if (["rg", "grep", "select-string"].includes(executable)) {
|
|
120
|
+
summary = "Search text";
|
|
121
|
+
} else if (["find", "fd", "get-childitem", "ls", "dir"].includes(executable)) {
|
|
122
|
+
summary = executable === "ls" || executable === "dir" || executable === "get-childitem" ? "List files" : "Find files";
|
|
123
|
+
} else if (["cat", "type", "get-content"].includes(executable)) {
|
|
124
|
+
summary = "Read file content";
|
|
125
|
+
} else if (["tsc", "mypy", "pyright"].includes(executable)) {
|
|
126
|
+
summary = "Check types";
|
|
127
|
+
} else if (["jest", "vitest", "pytest", "invoke-pester"].includes(executable)) {
|
|
128
|
+
summary = "Run tests";
|
|
129
|
+
} else if (executable) {
|
|
130
|
+
const display = executable.replace(/[-_]+/gu, " ");
|
|
131
|
+
summary = `Run ${display}`;
|
|
132
|
+
} else {
|
|
133
|
+
summary = name === "powershell" ? "Run PowerShell command" : "Run shell command";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const remainingSteps = Math.max(0, segments.length - 1);
|
|
137
|
+
return remainingSteps > 0 ? `${summary} + ${remainingSteps} more ${remainingSteps === 1 ? "step" : "steps"}` : summary;
|
|
138
|
+
}
|
|
139
|
+
|
|
58
140
|
export function getTextResult(result: AgentToolResult<unknown>): string {
|
|
59
141
|
const parts: string[] = [];
|
|
60
142
|
for (const item of result.content) {
|
|
@@ -63,20 +145,73 @@ export function getTextResult(result: AgentToolResult<unknown>): string {
|
|
|
63
145
|
return normalizeLineEndings(parts.join("\n")).trimEnd();
|
|
64
146
|
}
|
|
65
147
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
148
|
+
type ResultDetails = {
|
|
149
|
+
diff?: unknown;
|
|
150
|
+
truncation?: {
|
|
151
|
+
firstLineExceedsLimit?: boolean;
|
|
152
|
+
outputLines?: number;
|
|
153
|
+
};
|
|
154
|
+
matchLimitReached?: unknown;
|
|
155
|
+
resultLimitReached?: unknown;
|
|
156
|
+
entryLimitReached?: unknown;
|
|
157
|
+
linesTruncated?: unknown;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function countTextLines(text: string): number {
|
|
161
|
+
const normalized = normalizeLineEndings(text).trimEnd();
|
|
162
|
+
if (!normalized) return 0;
|
|
163
|
+
let count = 1;
|
|
164
|
+
for (let index = 0; index < normalized.length; index++) {
|
|
165
|
+
if (normalized.charCodeAt(index) === 10) count++;
|
|
166
|
+
}
|
|
167
|
+
return count;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Keep only the lines that the edit actually removed or added; discard context and ellipses. */
|
|
171
|
+
export function getEditChanges(result: AgentToolResult<unknown>): string {
|
|
172
|
+
const diff = (result.details as ResultDetails | undefined)?.diff;
|
|
173
|
+
if (typeof diff !== "string") return "";
|
|
174
|
+
return normalizeLineEndings(diff)
|
|
175
|
+
.split("\n")
|
|
176
|
+
.filter((line) => line.startsWith("-") || line.startsWith("+"))
|
|
177
|
+
.join("\n")
|
|
178
|
+
.trimEnd();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
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, "");
|
|
79
184
|
}
|
|
185
|
+
const hasGeneratedFooter = details?.truncation?.outputLines !== undefined
|
|
186
|
+
|| details?.matchLimitReached !== undefined
|
|
187
|
+
|| details?.resultLimitReached !== undefined
|
|
188
|
+
|| details?.entryLimitReached !== undefined
|
|
189
|
+
|| details?.linesTruncated !== undefined;
|
|
190
|
+
return hasGeneratedFooter ? text.replace(/\n\n\[[^\n]*\]$/u, "") : text;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatResultLineSummary(
|
|
194
|
+
name: string,
|
|
195
|
+
args: ToolArgs,
|
|
196
|
+
result: AgentToolResult<unknown>,
|
|
197
|
+
output?: string,
|
|
198
|
+
): string | undefined {
|
|
199
|
+
const details = result.details as ResultDetails | undefined;
|
|
200
|
+
const text = name === "edit"
|
|
201
|
+
? getEditChanges(result)
|
|
202
|
+
: output ?? (name === "write"
|
|
203
|
+
? String(args.content ?? "")
|
|
204
|
+
: result.content.some((item) => item.type === "text")
|
|
205
|
+
? getTextResult(result)
|
|
206
|
+
: undefined);
|
|
207
|
+
if (text === undefined) return undefined;
|
|
208
|
+
const truncationLines = details?.truncation?.outputLines;
|
|
209
|
+
const lineCount = !details?.truncation?.firstLineExceedsLimit
|
|
210
|
+
&& typeof truncationLines === "number"
|
|
211
|
+
&& Number.isInteger(truncationLines)
|
|
212
|
+
&& truncationLines >= 0
|
|
213
|
+
? truncationLines
|
|
214
|
+
: countTextLines(stripGeneratedFooter(name, text, details));
|
|
80
215
|
return `${lineCount} ${lineCount === 1 ? "line" : "lines"}`;
|
|
81
216
|
}
|
|
82
217
|
|