pi-compact-tools 0.3.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 -0
- package/README.md +222 -0
- package/examples/compact-tools.json +15 -0
- package/extensions/compact-tools-core.ts +89 -0
- package/extensions/compact-tools.ts +704 -0
- package/package.json +64 -0
- package/schemas/compact-tools.schema.json +90 -0
- package/scripts/check-resources.mjs +55 -0
- package/themes/github-dark-pro.json +93 -0
package/LICENSE
ADDED
|
@@ -0,0 +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.
|
package/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# pi-compact-tools
|
|
2
|
+
|
|
3
|
+
Compact, expandable rendering for all of Pi's built-in tools, bundled with a polished GitHub Dark theme.
|
|
4
|
+
|
|
5
|
+
> Formerly published as `@nedleeds/pi-compact-ui`. New installations should use `pi-compact-tools`.
|
|
6
|
+
|
|
7
|
+
Designed for a focused, low-noise terminal workflow.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Demo
|
|
12
|
+
|
|
13
|
+
**Spinner and status** – track active execution and see duration-aware completion indicators.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
**Keyboard expansion** – press `Ctrl+O` to cycle through detail levels.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
**Edit diffs** – click a finished `edit` row to reveal the diff, click again to collapse.
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- Compact one-line rendering for configurable built-in tools
|
|
28
|
+
- Supports `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`
|
|
29
|
+
- Click or `Ctrl+O` to cycle through available detail levels
|
|
30
|
+
- Skips argument and output levels that contain no additional information
|
|
31
|
+
- Animated, configurable tool spinner with a bright → dark → bright frame tone cycle
|
|
32
|
+
- Millisecond-precision execution duration with configurable indicators
|
|
33
|
+
- Short and full output previews with dark `│` / `└─` visual grouping
|
|
34
|
+
- Clean edit diffs without duplicate JSON arguments or leading blank lines
|
|
35
|
+
- GitHub Dark theme with distinct tool, output, success, and error colors
|
|
36
|
+
- No Nerd Font requirement
|
|
37
|
+
|
|
38
|
+
## Supported tools
|
|
39
|
+
|
|
40
|
+
| Tool | Purpose | Compact by default |
|
|
41
|
+
| --- | --- | :---: |
|
|
42
|
+
| `read` | Read files and images | Yes |
|
|
43
|
+
| `write` | Create or overwrite files | Yes |
|
|
44
|
+
| `edit` | Apply exact text replacements with clean diffs | Yes |
|
|
45
|
+
| `bash` | Run shell commands | Yes |
|
|
46
|
+
| `grep` | Search file contents | Opt-in |
|
|
47
|
+
| `find` | Find files by glob pattern | Opt-in |
|
|
48
|
+
| `ls` | List directory contents | Opt-in |
|
|
49
|
+
| `powershell` | Run PowerShell commands | Opt-in |
|
|
50
|
+
|
|
51
|
+
Enable opt-in tools with the [`tools` configuration](#configuration). PowerShell is intended for Windows; the other seven tools are demonstrated above on macOS.
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- pi `0.85.1` or newer is recommended
|
|
56
|
+
- Node.js 20 or newer
|
|
57
|
+
- Fullscreen TUI mode is required for spinner animation and mouse-click expansion; `Ctrl+O` works without it
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
From npm:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pi install npm:pi-compact-tools
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or directly from GitHub:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pi install git:github.com/nedleeds/pi-compact-ui
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Restart pi or run `/reload` after installation.
|
|
74
|
+
|
|
75
|
+
To migrate from the previous package name without loading both copies:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pi remove npm:@nedleeds/pi-compact-ui
|
|
79
|
+
pi install npm:pi-compact-tools
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The GitHub repository retains its original URL, so existing Git installation links and raw Schema URLs continue to work.
|
|
83
|
+
|
|
84
|
+
## Theme
|
|
85
|
+
|
|
86
|
+
The package includes the `github-dark-pro` theme. Select it from pi's settings UI or set it in `~/.pi/agent/settings.json`:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"theme": "github-dark-pro"
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
The extension works without a configuration file. Its defaults are defined in the source code.
|
|
97
|
+
|
|
98
|
+
To customize it globally, create `~/.pi/agent/compact-tools.json` using the example below. The included JSON Schema provides completion and validation in compatible editors.
|
|
99
|
+
|
|
100
|
+
For project-specific settings, create:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
<project>/.pi/compact-tools.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Project configuration is loaded only for trusted projects. Values are merged in this order:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
built-in defaults
|
|
110
|
+
→ ~/.pi/agent/compact-tools.json
|
|
111
|
+
→ <project>/.pi/compact-tools.json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Default configuration:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/schemas/compact-tools.schema.json",
|
|
119
|
+
"tools": ["read", "write", "edit", "bash"],
|
|
120
|
+
"previewLines": 10,
|
|
121
|
+
"spinner": {
|
|
122
|
+
"frames": ["◐", "◓", "◑", "◒"],
|
|
123
|
+
"intervalMs": 120
|
|
124
|
+
},
|
|
125
|
+
"durationIndicators": [
|
|
126
|
+
{ "underMs": 1000, "icon": "⚡️", "color": "warning" },
|
|
127
|
+
{ "underMs": 10000, "icon": "🔥", "color": "#D95C3F" },
|
|
128
|
+
{ "underMs": 30000, "icon": "○", "color": "#79C0FF" },
|
|
129
|
+
{ "icon": "⏳", "color": "#D2A8FF" }
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`tools` selects which built-in definitions receive compact rendering. Omitted tools retain Pi's default renderer. The array replaces, rather than extends, the previous configuration layer. To enable every Unix-compatible tool:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"]
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`powershell` is also supported and can be selected explicitly on Windows. Unsupported names are ignored with a warning.
|
|
143
|
+
|
|
144
|
+
Spinner frames automatically use a bright → dark → bright tone cycle (`muted` → `dim` → `border` → `dim` → `muted`). Fast tools are never delayed artificially: they transition directly to their success or failure state.
|
|
145
|
+
|
|
146
|
+
`durationIndicators` must use ascending `underMs` values. The final entry must omit `underMs` and acts as the fallback. Durations are displayed with millisecond precision, for example `Done in 0.023s`. The optional `color` field applies color to the icon only; when omitted, it defaults to `dim`. Existing icon-only configurations remain fully compatible, including configurations copied from older releases of this README:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{ "underMs": 1000, "icon": "⚡️" }
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Icons are arbitrary strings and do not require Nerd Font glyphs. Emoji, ASCII text, or terminal-safe Unicode symbols can be used instead. `color` accepts semantic theme colors such as `warning`, `accent`, `success`, `error`, `muted`, `dim`, `border`, and `text`, as well as six-digit hex colors such as `#800020` or `#FFFFFF`. Hex colors use truecolor when available and fall back to the nearest ANSI-256 color.
|
|
153
|
+
|
|
154
|
+
### Recommended Nerd Font preset
|
|
155
|
+
|
|
156
|
+
If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), the following configuration is recommended. It enables compact rendering for all eight built-in tools and uses single-width Nerd Font duration icons:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/schemas/compact-tools.schema.json",
|
|
161
|
+
"tools": ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"],
|
|
162
|
+
"previewLines": 10,
|
|
163
|
+
"spinner": {
|
|
164
|
+
"frames": ["✽", "✻", "✲", "✢", "✲", "✻"],
|
|
165
|
+
"intervalMs": 80
|
|
166
|
+
},
|
|
167
|
+
"durationIndicators": [
|
|
168
|
+
{ "underMs": 1000, "icon": "\uf0e7", "color": "warning" },
|
|
169
|
+
{ "underMs": 10000, "icon": "\uf490", "color": "#D95C3F" },
|
|
170
|
+
{ "underMs": 30000, "icon": "\udb81\udde3", "color": "#79C0FF" },
|
|
171
|
+
{ "icon": "\udb81\udd1f", "color": "#D2A8FF" }
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The escaped code points keep the preset readable on GitHub, whose web-font stack does not include Nerd Font glyphs. JSON automatically decodes them to the intended icons when the configuration is loaded. On systems without PowerShell, remove `"powershell"` from `tools`. The default Unicode configuration remains the most portable option and does not require a Nerd Font.
|
|
177
|
+
|
|
178
|
+
## Expansion levels
|
|
179
|
+
|
|
180
|
+
Only levels with meaningful content are included. Depending on the tool call, the cycle may be:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
summary → arguments → output preview → full output → summary
|
|
184
|
+
summary → output → summary
|
|
185
|
+
summary only
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`edit` uses:
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
summary → diff → summary
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Compatibility and limitations
|
|
195
|
+
|
|
196
|
+
- The package overrides only the built-in definitions selected by `tools`, while preserving their execution behavior and metadata.
|
|
197
|
+
- Another extension overriding the same tool names may conflict depending on extension load order.
|
|
198
|
+
- Tools registered by other extensions are not modified.
|
|
199
|
+
- Duration is measured from the first rendered tool call through completion of the built-in tool's `execute()` call. This includes streamed arguments for large `write` and `edit` calls. Timing survives `/reload` within the same process, but is unavailable after a full process restart.
|
|
200
|
+
- `timeout` tool arguments are execution limits, not measured durations, and are intentionally omitted from the compact shell summary.
|
|
201
|
+
- Windows (`CRLF`), Unix/macOS (`LF`), and classic Mac (`CR`) output is normalized before line counting and preview rendering.
|
|
202
|
+
- Spinner animation is disabled in regular TUI mode to avoid unsafe redraws of transcript rows above the viewport.
|
|
203
|
+
- Separator glyphs use the theme's `border` color; actual darkness depends on the selected theme.
|
|
204
|
+
- Emoji appearance depends on terminal and system font support. All default duration icons use Unicode 6.0 or earlier.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
npm install
|
|
210
|
+
npm run check
|
|
211
|
+
pi -e .
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
When testing with `pi -e .`, disable any local copy of `compact-tools.ts` to avoid duplicate tool overrides.
|
|
215
|
+
|
|
216
|
+
## Security
|
|
217
|
+
|
|
218
|
+
Pi extensions execute with full system access. Review extension source before installing third-party packages.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/schemas/compact-tools.schema.json",
|
|
3
|
+
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
|
4
|
+
"previewLines": 10,
|
|
5
|
+
"spinner": {
|
|
6
|
+
"frames": ["◐", "◓", "◑", "◒"],
|
|
7
|
+
"intervalMs": 120
|
|
8
|
+
},
|
|
9
|
+
"durationIndicators": [
|
|
10
|
+
{ "underMs": 1000, "icon": "⚡️", "color": "warning" },
|
|
11
|
+
{ "underMs": 10000, "icon": "🔥", "color": "#D95C3F" },
|
|
12
|
+
{ "underMs": 30000, "icon": "○", "color": "#79C0FF" },
|
|
13
|
+
{ "icon": "⏳", "color": "#D2A8FF" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Pure configuration, timing, text, and color helpers for compact tool rendering. */
|
|
2
|
+
|
|
3
|
+
const DURATION_INDICATOR_COLORS = [
|
|
4
|
+
"accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim", "text",
|
|
5
|
+
"thinkingText", "toolTitle", "toolOutput", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext", "bashMode",
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
export type ThemeDurationIndicatorColor = (typeof DURATION_INDICATOR_COLORS)[number];
|
|
9
|
+
export type DurationIndicatorColor = ThemeDurationIndicatorColor | `#${string}`;
|
|
10
|
+
export type DurationIndicatorConfig = {
|
|
11
|
+
underMs?: number;
|
|
12
|
+
icon: string;
|
|
13
|
+
color?: DurationIndicatorColor;
|
|
14
|
+
};
|
|
15
|
+
export type RowStatus = "pending" | "running" | "success" | "error";
|
|
16
|
+
|
|
17
|
+
const DURATION_INDICATOR_COLOR_SET = new Set<string>(DURATION_INDICATOR_COLORS);
|
|
18
|
+
export const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
19
|
+
|
|
20
|
+
type JsonObject = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
function isObject(value: unknown): value is JsonObject {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPositiveInteger(value: unknown, minimum: number): value is number {
|
|
27
|
+
return typeof value === "number" && Number.isInteger(value) && value >= minimum;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseDurationIndicators(value: unknown): DurationIndicatorConfig[] | undefined {
|
|
31
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
32
|
+
const rules: DurationIndicatorConfig[] = [];
|
|
33
|
+
let previousLimit = 0;
|
|
34
|
+
for (const [index, item] of value.entries()) {
|
|
35
|
+
if (!isObject(item) || typeof item.icon !== "string" || item.icon.length === 0) return undefined;
|
|
36
|
+
if (
|
|
37
|
+
item.color !== undefined &&
|
|
38
|
+
(typeof item.color !== "string" ||
|
|
39
|
+
(!DURATION_INDICATOR_COLOR_SET.has(item.color) && !HEX_COLOR_PATTERN.test(item.color)))
|
|
40
|
+
) return undefined;
|
|
41
|
+
const color = item.color as DurationIndicatorColor | undefined;
|
|
42
|
+
const isLast = index === value.length - 1;
|
|
43
|
+
if (isLast && item.underMs === undefined) rules.push({ icon: item.icon, color });
|
|
44
|
+
else if (isPositiveInteger(item.underMs, previousLimit + 1)) {
|
|
45
|
+
previousLimit = item.underMs;
|
|
46
|
+
rules.push({ underMs: item.underMs, icon: item.icon, color });
|
|
47
|
+
} else return undefined;
|
|
48
|
+
}
|
|
49
|
+
return rules.at(-1)?.underMs === undefined ? rules : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeLineEndings(value: string): string {
|
|
53
|
+
return value.replace(/\r\n?|\n/g, "\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function classifyCallStatus(isError: boolean, executionStarted: boolean, completed: boolean): RowStatus {
|
|
57
|
+
if (isError) return "error";
|
|
58
|
+
if (completed) return "success";
|
|
59
|
+
return executionStarted ? "running" : "pending";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatDurationMs(elapsedMs: number): string {
|
|
63
|
+
return `${(elapsedMs / 1000).toFixed(3)}s`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function selectDurationIndicator(
|
|
67
|
+
rules: readonly DurationIndicatorConfig[],
|
|
68
|
+
elapsedMs: number | undefined,
|
|
69
|
+
): DurationIndicatorConfig | undefined {
|
|
70
|
+
if (elapsedMs === undefined) return rules[0];
|
|
71
|
+
return rules.find((rule) => rule.underMs === undefined || elapsedMs < rule.underMs);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function colorDistanceSquared(red: number, green: number, blue: number, candidate: readonly number[]): number {
|
|
75
|
+
return (red - candidate[0]!) ** 2 + (green - candidate[1]!) ** 2 + (blue - candidate[2]!) ** 2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function rgbToAnsi256(red: number, green: number, blue: number): number {
|
|
79
|
+
const cube = [red, green, blue].map((channel) => Math.round(channel / 51));
|
|
80
|
+
const cubeRgb = cube.map((channel) => channel * 51);
|
|
81
|
+
const cubeIndex = 16 + 36 * cube[0]! + 6 * cube[1]! + cube[2]!;
|
|
82
|
+
const average = (red + green + blue) / 3;
|
|
83
|
+
const grayStep = Math.max(0, Math.min(23, Math.round((average - 8) / 10)));
|
|
84
|
+
const grayLevel = 8 + grayStep * 10;
|
|
85
|
+
const grayRgb = [grayLevel, grayLevel, grayLevel];
|
|
86
|
+
return colorDistanceSquared(red, green, blue, grayRgb) < colorDistanceSquared(red, green, blue, cubeRgb)
|
|
87
|
+
? 232 + grayStep
|
|
88
|
+
: cubeIndex;
|
|
89
|
+
}
|
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
/** Compact rendering and shared status indicators for built-in tools. */
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type {
|
|
6
|
+
AgentToolResult,
|
|
7
|
+
BashToolDetails,
|
|
8
|
+
BashToolInput,
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
Theme,
|
|
11
|
+
ToolDefinition,
|
|
12
|
+
ToolRenderResultOptions,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import {
|
|
15
|
+
CONFIG_DIR_NAME,
|
|
16
|
+
createBashToolDefinition,
|
|
17
|
+
createEditToolDefinition,
|
|
18
|
+
createFindToolDefinition,
|
|
19
|
+
createGrepToolDefinition,
|
|
20
|
+
createLsToolDefinition,
|
|
21
|
+
createPowerShellToolDefinition,
|
|
22
|
+
createReadToolDefinition,
|
|
23
|
+
createWriteToolDefinition,
|
|
24
|
+
getAgentDir,
|
|
25
|
+
} from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
27
|
+
import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
28
|
+
import {
|
|
29
|
+
classifyCallStatus,
|
|
30
|
+
formatDurationMs,
|
|
31
|
+
HEX_COLOR_PATTERN,
|
|
32
|
+
normalizeLineEndings,
|
|
33
|
+
parseDurationIndicators,
|
|
34
|
+
rgbToAnsi256,
|
|
35
|
+
selectDurationIndicator,
|
|
36
|
+
type DurationIndicatorConfig,
|
|
37
|
+
type RowStatus,
|
|
38
|
+
type ThemeDurationIndicatorColor,
|
|
39
|
+
} from "./compact-tools-core.ts";
|
|
40
|
+
|
|
41
|
+
const MAX_LEVEL = 3;
|
|
42
|
+
const CONFIG_FILE = "compact-tools.json";
|
|
43
|
+
const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"] as const;
|
|
44
|
+
const SUPPORTED_TOOL_SET = new Set<string>(SUPPORTED_TOOLS);
|
|
45
|
+
|
|
46
|
+
type CompactToolName = (typeof SUPPORTED_TOOLS)[number];
|
|
47
|
+
|
|
48
|
+
export interface CompactToolsConfig {
|
|
49
|
+
tools: CompactToolName[];
|
|
50
|
+
previewLines: number;
|
|
51
|
+
spinner: {
|
|
52
|
+
frames: string[];
|
|
53
|
+
intervalMs: number;
|
|
54
|
+
};
|
|
55
|
+
durationIndicators: DurationIndicatorConfig[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_CONFIG: CompactToolsConfig = {
|
|
59
|
+
tools: ["read", "write", "edit", "bash"],
|
|
60
|
+
previewLines: 10,
|
|
61
|
+
spinner: {
|
|
62
|
+
frames: ["◐", "◓", "◑", "◒"],
|
|
63
|
+
intervalMs: 120,
|
|
64
|
+
},
|
|
65
|
+
durationIndicators: [
|
|
66
|
+
{ underMs: 1_000, icon: "⚡️", color: "warning" },
|
|
67
|
+
{ underMs: 10_000, icon: "🔥", color: "#D95C3F" },
|
|
68
|
+
{ underMs: 30_000, icon: "○", color: "#79C0FF" },
|
|
69
|
+
{ icon: "⏳", color: "#D2A8FF" },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let config = DEFAULT_CONFIG;
|
|
74
|
+
let animateRows = false;
|
|
75
|
+
const registeredCompactTools = new Set<CompactToolName>();
|
|
76
|
+
|
|
77
|
+
interface RowState {
|
|
78
|
+
level?: number;
|
|
79
|
+
availableLevels?: number[];
|
|
80
|
+
lastExpanded?: boolean;
|
|
81
|
+
frame?: number;
|
|
82
|
+
startedAt?: number;
|
|
83
|
+
endedAt?: number;
|
|
84
|
+
timer?: ReturnType<typeof setInterval>;
|
|
85
|
+
originalResultComponent?: Component;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type ToolArgs = Record<string, unknown>;
|
|
89
|
+
type BuiltInDefinition = ToolDefinition<any, any, any>;
|
|
90
|
+
type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
|
|
91
|
+
type RenderContext<TArgs = ToolArgs> = Omit<BaseRenderContext, "args" | "state"> & {
|
|
92
|
+
args: TArgs;
|
|
93
|
+
state: RowState;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type JsonObject = Record<string, unknown>;
|
|
97
|
+
|
|
98
|
+
function isObject(value: unknown): value is JsonObject {
|
|
99
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function warnConfig(path: string, message: string): void {
|
|
103
|
+
console.error(`[compact-tools] ${path}: ${message}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isIntegerInRange(value: unknown, minimum: number, maximum: number): value is number {
|
|
107
|
+
return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isNonEmptyStringArray(value: unknown): value is string[] {
|
|
111
|
+
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseTools(value: unknown, path: string): CompactToolName[] | undefined {
|
|
115
|
+
if (!Array.isArray(value)) {
|
|
116
|
+
warnConfig(path, "tools must be an array; using previous values");
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
const valid = value.filter((item): item is CompactToolName => typeof item === "string" && SUPPORTED_TOOL_SET.has(item));
|
|
120
|
+
const invalid = value.filter((item) => typeof item !== "string" || !SUPPORTED_TOOL_SET.has(item));
|
|
121
|
+
if (invalid.length > 0) warnConfig(path, `ignoring unsupported tools: ${invalid.map(String).join(", ")}`);
|
|
122
|
+
return [...new Set(valid)];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function mergeConfig(base: CompactToolsConfig, value: unknown, path: string): CompactToolsConfig {
|
|
126
|
+
if (value === undefined) return base;
|
|
127
|
+
if (!isObject(value)) {
|
|
128
|
+
warnConfig(path, "expected a JSON object; using previous values");
|
|
129
|
+
return base;
|
|
130
|
+
}
|
|
131
|
+
const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
|
|
132
|
+
const previewValue = value.previewLines;
|
|
133
|
+
const validPreviewLines = isIntegerInRange(previewValue, 1, 1_000);
|
|
134
|
+
const previewLines = validPreviewLines ? previewValue : base.previewLines;
|
|
135
|
+
if (previewValue !== undefined && !validPreviewLines) {
|
|
136
|
+
warnConfig(path, "previewLines must be an integer from 1 to 1000; using previous value");
|
|
137
|
+
}
|
|
138
|
+
const spinner = parseSpinnerConfig(base.spinner, value.spinner, path);
|
|
139
|
+
const durationIndicators = parseDurationIndicators(value.durationIndicators) ?? base.durationIndicators;
|
|
140
|
+
if (value.durationIndicators !== undefined && durationIndicators === base.durationIndicators) {
|
|
141
|
+
warnConfig(path, "invalid durationIndicators; using previous values");
|
|
142
|
+
}
|
|
143
|
+
return { tools, previewLines, spinner, durationIndicators };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseSpinnerConfig(
|
|
147
|
+
base: CompactToolsConfig["spinner"],
|
|
148
|
+
value: unknown,
|
|
149
|
+
path: string,
|
|
150
|
+
): CompactToolsConfig["spinner"] {
|
|
151
|
+
if (value === undefined) return base;
|
|
152
|
+
if (!isObject(value)) {
|
|
153
|
+
warnConfig(path, "spinner must be an object; using previous values");
|
|
154
|
+
return base;
|
|
155
|
+
}
|
|
156
|
+
const framesValue = value.frames;
|
|
157
|
+
const intervalValue = value.intervalMs;
|
|
158
|
+
const validFrames = isNonEmptyStringArray(framesValue);
|
|
159
|
+
const validInterval = isIntegerInRange(intervalValue, 40, 5_000);
|
|
160
|
+
if (framesValue !== undefined && !validFrames) warnConfig(path, "spinner.frames must be a non-empty string array");
|
|
161
|
+
if (intervalValue !== undefined && !validInterval) warnConfig(path, "spinner.intervalMs must be 40–5000");
|
|
162
|
+
return {
|
|
163
|
+
frames: validFrames ? framesValue : base.frames,
|
|
164
|
+
intervalMs: validInterval ? intervalValue : base.intervalMs,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readConfig(path: string): unknown {
|
|
169
|
+
if (!existsSync(path)) return undefined;
|
|
170
|
+
try {
|
|
171
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
warnConfig(path, error instanceof Error ? error.message : String(error));
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getConfiguredTuiMode(): "regular" | "fullscreen" {
|
|
179
|
+
for (let index = 0; index < process.argv.length; index++) {
|
|
180
|
+
const argument = process.argv[index];
|
|
181
|
+
if (argument === "--tui-mode") {
|
|
182
|
+
const value = process.argv[index + 1];
|
|
183
|
+
if (value === "regular" || value === "fullscreen") return value;
|
|
184
|
+
}
|
|
185
|
+
if (argument?.startsWith("--tui-mode=")) {
|
|
186
|
+
const value = argument.slice("--tui-mode=".length);
|
|
187
|
+
if (value === "regular" || value === "fullscreen") return value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const settings = readConfig(join(getAgentDir(), "settings.json"));
|
|
192
|
+
return isObject(settings) && settings.tuiMode === "fullscreen" ? "fullscreen" : "regular";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function loadConfig(cwd?: string, projectTrusted = false): CompactToolsConfig {
|
|
196
|
+
const globalPath = join(getAgentDir(), CONFIG_FILE);
|
|
197
|
+
let loaded = mergeConfig(DEFAULT_CONFIG, readConfig(globalPath), globalPath);
|
|
198
|
+
if (!cwd || !projectTrusted) return loaded;
|
|
199
|
+
const projectPath = join(cwd, CONFIG_DIR_NAME, CONFIG_FILE);
|
|
200
|
+
loaded = mergeConfig(loaded, readConfig(projectPath), projectPath);
|
|
201
|
+
return loaded;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const SPINNING_ROWS_KEY = Symbol.for("pi.compact-tools.spinning-rows");
|
|
205
|
+
const EXECUTION_TIMINGS_KEY = Symbol.for("pi.compact-tools.execution-timings");
|
|
206
|
+
type ExecutionTiming = { startedAt: number; endedAt?: number };
|
|
207
|
+
const globalState = globalThis as typeof globalThis & {
|
|
208
|
+
[SPINNING_ROWS_KEY]?: Set<RowState>;
|
|
209
|
+
[EXECUTION_TIMINGS_KEY]?: Map<string, ExecutionTiming>;
|
|
210
|
+
};
|
|
211
|
+
for (const staleState of globalState[SPINNING_ROWS_KEY] ?? []) {
|
|
212
|
+
if (staleState.timer) clearInterval(staleState.timer);
|
|
213
|
+
}
|
|
214
|
+
const spinningRows = new Set<RowState>();
|
|
215
|
+
const executionTimings = globalState[EXECUTION_TIMINGS_KEY] ?? new Map<string, ExecutionTiming>();
|
|
216
|
+
globalState[SPINNING_ROWS_KEY] = spinningRows;
|
|
217
|
+
globalState[EXECUTION_TIMINGS_KEY] = executionTimings;
|
|
218
|
+
|
|
219
|
+
function advanceLevel(state: RowState, expanded: boolean): number {
|
|
220
|
+
state.level ??= 0;
|
|
221
|
+
if (state.lastExpanded === undefined) {
|
|
222
|
+
state.lastExpanded = expanded;
|
|
223
|
+
return state.level;
|
|
224
|
+
}
|
|
225
|
+
if (state.lastExpanded !== expanded) {
|
|
226
|
+
state.lastExpanded = expanded;
|
|
227
|
+
const levels = state.availableLevels ?? [0, 1];
|
|
228
|
+
const index = levels.indexOf(state.level);
|
|
229
|
+
state.level = levels[(index + 1) % levels.length] ?? 0;
|
|
230
|
+
}
|
|
231
|
+
return state.level;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function setAvailableLevels(state: RowState, levels: number[]): void {
|
|
235
|
+
state.availableLevels = levels;
|
|
236
|
+
if (!levels.includes(state.level ?? 0)) state.level = 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function getOutputLevels(output: string): number[] {
|
|
240
|
+
const normalized = normalizeLineEndings(output).trimEnd();
|
|
241
|
+
if (!normalized) return [];
|
|
242
|
+
return normalized.split("\n").length > config.previewLines ? [2, 3] : [2];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function stopSpinner(state: RowState): void {
|
|
246
|
+
if (state.timer) clearInterval(state.timer);
|
|
247
|
+
state.timer = undefined;
|
|
248
|
+
spinningRows.delete(state);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function syncSpinner(state: RowState, running: boolean, invalidate: () => void): void {
|
|
252
|
+
// Regular mode cannot safely redraw a transcript row after it has scrolled
|
|
253
|
+
// above the viewport. Keep active rows static there to avoid full transcript
|
|
254
|
+
// redraws and duplicated-looking terminal output.
|
|
255
|
+
if (!running || !animateRows || config.spinner.frames.length < 2) {
|
|
256
|
+
stopSpinner(state);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (state.timer) return;
|
|
260
|
+
|
|
261
|
+
state.frame ??= 0;
|
|
262
|
+
state.timer = setInterval(() => {
|
|
263
|
+
state.frame = ((state.frame ?? 0) + 1) % config.spinner.frames.length;
|
|
264
|
+
try {
|
|
265
|
+
invalidate();
|
|
266
|
+
} catch {
|
|
267
|
+
stopSpinner(state);
|
|
268
|
+
}
|
|
269
|
+
}, config.spinner.intervalMs);
|
|
270
|
+
state.timer.unref?.();
|
|
271
|
+
spinningRows.add(state);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function syncTiming(state: RowState, callStarted: boolean, finished: boolean): void {
|
|
275
|
+
if (callStarted && state.startedAt === undefined) state.startedAt = Date.now();
|
|
276
|
+
if (finished && state.startedAt !== undefined && state.endedAt === undefined) {
|
|
277
|
+
state.endedAt = Date.now();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function restoreExecutionTiming(state: RowState, toolCallId: string): boolean {
|
|
282
|
+
const timing = executionTimings.get(toolCallId);
|
|
283
|
+
if (!timing) return false;
|
|
284
|
+
state.startedAt = timing.startedAt;
|
|
285
|
+
state.endedAt = timing.endedAt;
|
|
286
|
+
return timing.endedAt !== undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function persistExecutionTiming(state: RowState, toolCallId: string): void {
|
|
290
|
+
if (state.startedAt === undefined) return;
|
|
291
|
+
const timing = executionTimings.get(toolCallId) ?? { startedAt: state.startedAt };
|
|
292
|
+
timing.startedAt = state.startedAt;
|
|
293
|
+
if (state.endedAt !== undefined) timing.endedAt = state.endedAt;
|
|
294
|
+
executionTimings.set(toolCallId, timing);
|
|
295
|
+
if (executionTimings.size <= 2_000) return;
|
|
296
|
+
const oldestToolCallId = executionTimings.keys().next().value;
|
|
297
|
+
if (oldestToolCallId !== undefined) executionTimings.delete(oldestToolCallId);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function syncRow(
|
|
301
|
+
ctx: RenderContext,
|
|
302
|
+
running = ctx.executionStarted && ctx.state.endedAt === undefined,
|
|
303
|
+
finished = false,
|
|
304
|
+
): RowState {
|
|
305
|
+
const state = ctx.state;
|
|
306
|
+
if (restoreExecutionTiming(state, ctx.toolCallId)) running = false;
|
|
307
|
+
const callStarted = !ctx.argsComplete || ctx.executionStarted || running;
|
|
308
|
+
syncTiming(state, callStarted, finished);
|
|
309
|
+
persistExecutionTiming(state, ctx.toolCallId);
|
|
310
|
+
syncSpinner(state, running, ctx.invalidate);
|
|
311
|
+
return state;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resetUiState(clearTimings = false): void {
|
|
315
|
+
for (const state of [...spinningRows]) stopSpinner(state);
|
|
316
|
+
if (clearTimings) executionTimings.clear();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function spinnerTone(frameIndex: number, frameCount: number): "muted" | "dim" | "border" {
|
|
320
|
+
if (frameCount < 3) return "muted";
|
|
321
|
+
const position = frameIndex / (frameCount - 1);
|
|
322
|
+
const distanceFromCenter = Math.abs(position - 0.5) * 2;
|
|
323
|
+
if (distanceFromCenter < 0.34) return "border";
|
|
324
|
+
if (distanceFromCenter < 0.75) return "dim";
|
|
325
|
+
return "muted";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function resolveCallStatus(ctx: RenderContext, state: RowState): RowStatus {
|
|
329
|
+
return classifyCallStatus(ctx.isError, ctx.executionStarted, state.endedAt !== undefined);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function renderIndicator(theme: Theme, state: RowState, status: RowStatus): string {
|
|
333
|
+
if (status === "error") return theme.fg("error", "⊗");
|
|
334
|
+
if (status === "success") return theme.fg("success", "●");
|
|
335
|
+
// A new pending or running row starts at frame zero, then shares the same animation.
|
|
336
|
+
const frameIndex = state.frame ?? 0;
|
|
337
|
+
const frame = config.spinner.frames[frameIndex] ?? config.spinner.frames[0] ?? "◐";
|
|
338
|
+
return theme.fg(spinnerTone(frameIndex, config.spinner.frames.length), frame);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function formatDuration(state: RowState): string | undefined {
|
|
342
|
+
if (state.startedAt === undefined) return undefined;
|
|
343
|
+
return formatDurationMs((state.endedAt ?? Date.now()) - state.startedAt);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function durationIndicator(state: RowState): DurationIndicatorConfig | undefined {
|
|
347
|
+
// Reloading extensions recreates row state, so already-completed rows no longer
|
|
348
|
+
// have timing data. Keep their configured indicator visible using the first rule.
|
|
349
|
+
const elapsedMs = state.startedAt !== undefined && state.endedAt !== undefined
|
|
350
|
+
? state.endedAt - state.startedAt
|
|
351
|
+
: undefined;
|
|
352
|
+
return selectDurationIndicator(config.durationIndicators, elapsedMs);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parseHexColor(color: string): [number, number, number] {
|
|
356
|
+
return [
|
|
357
|
+
Number.parseInt(color.slice(1, 3), 16),
|
|
358
|
+
Number.parseInt(color.slice(3, 5), 16),
|
|
359
|
+
Number.parseInt(color.slice(5, 7), 16),
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function styleDurationIcon(theme: Theme, indicator: DurationIndicatorConfig): string {
|
|
364
|
+
const color = indicator.color ?? "dim";
|
|
365
|
+
if (!HEX_COLOR_PATTERN.test(color)) {
|
|
366
|
+
return theme.fg(color as ThemeDurationIndicatorColor, indicator.icon);
|
|
367
|
+
}
|
|
368
|
+
const [red, green, blue] = parseHexColor(color);
|
|
369
|
+
const ansi = theme.getColorMode() === "truecolor"
|
|
370
|
+
? `\x1b[38;2;${red};${green};${blue}m`
|
|
371
|
+
: `\x1b[38;5;${rgbToAnsi256(red, green, blue)}m`;
|
|
372
|
+
return `${ansi}${indicator.icon}\x1b[39m`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderControls(theme: Theme, state: RowState, running: boolean, isError: boolean): Text {
|
|
376
|
+
const duration = running && !animateRows ? undefined : formatDuration(state);
|
|
377
|
+
const levels = state.availableLevels ?? [0, 1];
|
|
378
|
+
const expandable = levels.length > 1;
|
|
379
|
+
const atLastLevel = state.level === levels.at(-1);
|
|
380
|
+
const action = expandable
|
|
381
|
+
? `${theme.italic("ctrl+o")} ${atLastLevel ? "to collapse" : "for more"}`
|
|
382
|
+
: undefined;
|
|
383
|
+
const indicator = !running && !isError ? durationIndicator(state) : undefined;
|
|
384
|
+
const status = running
|
|
385
|
+
? (duration ?? "Running")
|
|
386
|
+
: `${isError ? "Failed" : "Done"}${duration ? ` in ${duration}` : ""}`;
|
|
387
|
+
let details = indicator
|
|
388
|
+
? `${styleDurationIcon(theme, indicator)} ${theme.fg("borderAccent", status)}`
|
|
389
|
+
: theme.fg("borderAccent", status);
|
|
390
|
+
if (action) details += theme.fg("borderAccent", `, ${action}`);
|
|
391
|
+
return new Text(`${theme.fg("border", " └─ ")}${details}`, 0, 0);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function firstLine(value: string, maxLength = 100): string {
|
|
395
|
+
const line = normalizeLineEndings(value).split("\n")[0] ?? "";
|
|
396
|
+
return line.length > maxLength ? `${line.slice(0, maxLength - 1)}…` : line;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function getTextResult(result: AgentToolResult<unknown>): string {
|
|
400
|
+
const text = result.content
|
|
401
|
+
.filter((item) => item.type === "text")
|
|
402
|
+
.map((item) => item.text)
|
|
403
|
+
.join("\n");
|
|
404
|
+
return normalizeLineEndings(text).trimEnd();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function withoutLeadingBlankLines(component: Component, theme: Theme): Component {
|
|
408
|
+
return {
|
|
409
|
+
render(width: number) {
|
|
410
|
+
const lines = component.render(Math.max(1, width - 4));
|
|
411
|
+
const firstContentLine = lines.findIndex((line) => visibleWidth(line.trim()) > 0);
|
|
412
|
+
const prefix = theme.fg("border", " │ ");
|
|
413
|
+
return firstContentLine < 0 ? [] : lines.slice(firstContentLine).map((line) => `${prefix}${line}`);
|
|
414
|
+
},
|
|
415
|
+
invalidate() {
|
|
416
|
+
component.invalidate?.();
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function styleToolOutput(text: string, theme: Theme, isError: boolean): string {
|
|
422
|
+
const color = isError ? "error" : "toolOutput";
|
|
423
|
+
return text
|
|
424
|
+
.split(/(⚡️?)/u)
|
|
425
|
+
.map((part) => theme.fg(!isError && part.startsWith("⚡") ? "warning" : color, part))
|
|
426
|
+
.join("");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function renderPreview(output: string, level: number, theme: Theme, isError: boolean): Text | undefined {
|
|
430
|
+
const normalized = normalizeLineEndings(output).trimEnd();
|
|
431
|
+
if (!normalized) return undefined;
|
|
432
|
+
|
|
433
|
+
const lines = normalized.split("\n");
|
|
434
|
+
const shown = level >= MAX_LEVEL ? lines : lines.slice(0, config.previewLines);
|
|
435
|
+
const prefix = theme.fg("border", " │ ");
|
|
436
|
+
let text = shown.map((line) => `${prefix}${styleToolOutput(line, theme, isError)}`).join("\n");
|
|
437
|
+
if (shown.length < lines.length) {
|
|
438
|
+
text += `\n${prefix}${theme.fg("dim", `… ${lines.length - shown.length} more lines`)}`;
|
|
439
|
+
}
|
|
440
|
+
return new Text(text, 0, 0);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getPathArg(args: ToolArgs): string {
|
|
444
|
+
const value = args.path ?? args.file_path;
|
|
445
|
+
return typeof value === "string" ? firstLine(value) : "";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function getCallDetails(name: string, args: ToolArgs): string {
|
|
449
|
+
const path = getPathArg(args) || (name === "grep" || name === "find" || name === "ls" ? "." : "");
|
|
450
|
+
const pattern = typeof args.pattern === "string" ? firstLine(args.pattern) : "…";
|
|
451
|
+
if (name === "grep") return `/${pattern}/ in ${path}`;
|
|
452
|
+
if (name === "find") return `${pattern} in ${path}`;
|
|
453
|
+
return path;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function getFileArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
457
|
+
if (name === "edit") return {};
|
|
458
|
+
const omitted = new Set(["path", "file_path"]);
|
|
459
|
+
if (name === "write") omitted.add("content");
|
|
460
|
+
if (name === "grep" || name === "find") omitted.add("pattern");
|
|
461
|
+
return Object.fromEntries(Object.entries(args).filter(([key, value]) => !omitted.has(key) && value !== undefined));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function renderArguments(args: ToolArgs, theme: Theme): Text {
|
|
465
|
+
const json = JSON.stringify(args, null, 2) ?? "{}";
|
|
466
|
+
const prefix = theme.fg("border", " │ ");
|
|
467
|
+
const text = json
|
|
468
|
+
.split("\n")
|
|
469
|
+
.map((line) => `${prefix}${theme.fg("toolOutput", line)}`)
|
|
470
|
+
.join("\n");
|
|
471
|
+
return new Text(text, 0, 0);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function callOriginalEditResult(
|
|
475
|
+
definition: BuiltInDefinition,
|
|
476
|
+
result: AgentToolResult<unknown>,
|
|
477
|
+
options: ToolRenderResultOptions,
|
|
478
|
+
theme: Theme,
|
|
479
|
+
ctx: RenderContext,
|
|
480
|
+
state: RowState,
|
|
481
|
+
level: number,
|
|
482
|
+
): void {
|
|
483
|
+
if (definition.name !== "edit" || !definition.renderResult) return;
|
|
484
|
+
state.originalResultComponent = definition.renderResult(
|
|
485
|
+
result,
|
|
486
|
+
{ ...options, expanded: level >= MAX_LEVEL },
|
|
487
|
+
theme,
|
|
488
|
+
{ ...ctx, state: {}, lastComponent: state.originalResultComponent },
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function getFileOutput(name: string, args: ToolArgs, result: AgentToolResult<unknown>, isError: boolean): string {
|
|
493
|
+
if (isError || name === "read" || name === "grep" || name === "find" || name === "ls") {
|
|
494
|
+
return getTextResult(result);
|
|
495
|
+
}
|
|
496
|
+
if (name === "write") return String(args.content ?? "");
|
|
497
|
+
return "";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function renderFileCall(
|
|
501
|
+
definition: BuiltInDefinition,
|
|
502
|
+
args: ToolArgs,
|
|
503
|
+
theme: Theme,
|
|
504
|
+
ctx: RenderContext,
|
|
505
|
+
): Container {
|
|
506
|
+
// Animate and time from the first rendered call so large write/edit calls
|
|
507
|
+
// represent the full lifecycle, including streamed arguments and filesystem work.
|
|
508
|
+
const state = syncRow(ctx, ctx.state.endedAt === undefined);
|
|
509
|
+
// syncRow may restore a completed timing after /reload. Recompute from the
|
|
510
|
+
// synchronized state so a stale local value cannot leave the spinner visible.
|
|
511
|
+
const status = resolveCallStatus(ctx, state);
|
|
512
|
+
const level = advanceLevel(state, ctx.expanded);
|
|
513
|
+
const callDetails = getCallDetails(definition.name, args);
|
|
514
|
+
const argumentDetails = getFileArgumentDetails(definition.name, args);
|
|
515
|
+
let text = `${renderIndicator(theme, state, status)} `;
|
|
516
|
+
text += theme.fg("toolTitle", theme.bold(definition.name));
|
|
517
|
+
if (callDetails) text += ` ${theme.fg("toolOutput", callDetails)}`;
|
|
518
|
+
|
|
519
|
+
const container = new Container();
|
|
520
|
+
container.addChild(new Text(text, 1, 0));
|
|
521
|
+
if (level >= 1 && Object.keys(argumentDetails).length > 0) {
|
|
522
|
+
container.addChild(renderArguments(argumentDetails, theme));
|
|
523
|
+
}
|
|
524
|
+
return container;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function addFileResultPreview(
|
|
528
|
+
container: Container,
|
|
529
|
+
definition: BuiltInDefinition,
|
|
530
|
+
state: RowState,
|
|
531
|
+
output: string,
|
|
532
|
+
level: number,
|
|
533
|
+
theme: Theme,
|
|
534
|
+
isError: boolean,
|
|
535
|
+
): void {
|
|
536
|
+
if (level < 2) return;
|
|
537
|
+
if (definition.name === "edit") {
|
|
538
|
+
if (state.originalResultComponent) {
|
|
539
|
+
container.addChild(withoutLeadingBlankLines(state.originalResultComponent, theme));
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const preview = renderPreview(output, level, theme, isError);
|
|
544
|
+
if (preview) container.addChild(preview);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function renderFileResult(
|
|
548
|
+
definition: BuiltInDefinition,
|
|
549
|
+
result: AgentToolResult<unknown>,
|
|
550
|
+
options: ToolRenderResultOptions,
|
|
551
|
+
theme: Theme,
|
|
552
|
+
ctx: RenderContext,
|
|
553
|
+
): Container {
|
|
554
|
+
const state = syncRow(ctx, options.isPartial, !options.isPartial);
|
|
555
|
+
const output = getFileOutput(definition.name, ctx.args, result, ctx.isError);
|
|
556
|
+
const hasArguments = Object.keys(getFileArgumentDetails(definition.name, ctx.args)).length > 0;
|
|
557
|
+
const levels = definition.name === "edit" ? [0, 2] : [0, ...(hasArguments ? [1] : []), ...getOutputLevels(output)];
|
|
558
|
+
setAvailableLevels(state, levels);
|
|
559
|
+
|
|
560
|
+
const level = state.level ?? 0;
|
|
561
|
+
callOriginalEditResult(definition, result, options, theme, ctx, state, level);
|
|
562
|
+
const container = new Container();
|
|
563
|
+
addFileResultPreview(container, definition, state, output, level, theme, ctx.isError);
|
|
564
|
+
container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
|
|
565
|
+
return container;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function createFileCallRenderer(definition: BuiltInDefinition) {
|
|
569
|
+
return (args: ToolArgs, theme: Theme, ctx: RenderContext) => renderFileCall(definition, args, theme, ctx);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function createFileResultRenderer(definition: BuiltInDefinition) {
|
|
573
|
+
return (result: AgentToolResult<unknown>, options: ToolRenderResultOptions, theme: Theme, ctx: RenderContext) =>
|
|
574
|
+
renderFileResult(definition, result, options, theme, ctx);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
type TimedExecute = (...args: any[]) => Promise<AgentToolResult<unknown>>;
|
|
578
|
+
|
|
579
|
+
function createTimedExecute(definition: BuiltInDefinition): TimedExecute {
|
|
580
|
+
const execute = definition.execute as TimedExecute;
|
|
581
|
+
return async (...args: any[]) => {
|
|
582
|
+
const toolCallId = typeof args[0] === "string" ? args[0] : undefined;
|
|
583
|
+
const startedAt = (toolCallId ? executionTimings.get(toolCallId)?.startedAt : undefined) ?? Date.now();
|
|
584
|
+
if (toolCallId && !executionTimings.has(toolCallId)) executionTimings.set(toolCallId, { startedAt });
|
|
585
|
+
try {
|
|
586
|
+
return await execute.apply(definition, args);
|
|
587
|
+
} finally {
|
|
588
|
+
const endedAt = Date.now();
|
|
589
|
+
const timing = toolCallId ? executionTimings.get(toolCallId) : undefined;
|
|
590
|
+
if (timing) timing.endedAt = endedAt;
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function registerFileTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
|
|
596
|
+
const tool = {
|
|
597
|
+
...definition,
|
|
598
|
+
execute: createTimedExecute(definition),
|
|
599
|
+
renderShell: "self" as const,
|
|
600
|
+
renderCall: createFileCallRenderer(definition),
|
|
601
|
+
renderResult: createFileResultRenderer(definition),
|
|
602
|
+
};
|
|
603
|
+
// Built-in file schemas are intentionally erased at this shared renderer boundary.
|
|
604
|
+
pi.registerTool(tool as ToolDefinition<any, any, RowState>);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function renderShellCall(
|
|
608
|
+
name: "bash" | "powershell",
|
|
609
|
+
args: BashToolInput,
|
|
610
|
+
theme: Theme,
|
|
611
|
+
ctx: RenderContext<BashToolInput>,
|
|
612
|
+
): Text {
|
|
613
|
+
const state = syncRow(ctx, ctx.state.endedAt === undefined);
|
|
614
|
+
// A completed timing can be restored inside syncRow. Derive the rendered
|
|
615
|
+
// status afterward so completion immediately replaces the spinner.
|
|
616
|
+
const status = resolveCallStatus(ctx, state);
|
|
617
|
+
const level = advanceLevel(state, ctx.expanded);
|
|
618
|
+
const command = normalizeLineEndings(args.command ?? "");
|
|
619
|
+
const displayedCommand = (level >= 1 ? command : firstLine(command)) || "…";
|
|
620
|
+
let text = `${renderIndicator(theme, state, status)} `;
|
|
621
|
+
text += `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("toolOutput", displayedCommand)}`;
|
|
622
|
+
return new Text(text, 1, 0);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function renderShellResult(
|
|
626
|
+
result: AgentToolResult<BashToolDetails | undefined>,
|
|
627
|
+
options: ToolRenderResultOptions,
|
|
628
|
+
theme: Theme,
|
|
629
|
+
ctx: RenderContext<BashToolInput>,
|
|
630
|
+
): Component {
|
|
631
|
+
const state = syncRow(ctx, options.isPartial, !options.isPartial);
|
|
632
|
+
const output = getTextResult(result);
|
|
633
|
+
const hasDetailedCall = /\r\n?|\n/.test(ctx.args.command ?? "");
|
|
634
|
+
setAvailableLevels(state, [0, ...(hasDetailedCall ? [1] : []), ...getOutputLevels(output)]);
|
|
635
|
+
const level = state.level ?? 0;
|
|
636
|
+
if (level < 2) return renderControls(theme, state, options.isPartial, ctx.isError);
|
|
637
|
+
|
|
638
|
+
const container = new Container();
|
|
639
|
+
const preview = renderPreview(output, level, theme, ctx.isError);
|
|
640
|
+
if (preview) container.addChild(preview);
|
|
641
|
+
else {
|
|
642
|
+
const prefix = theme.fg("border", " │ ");
|
|
643
|
+
const message = theme.fg("dim", options.isPartial ? "…" : "(no output)");
|
|
644
|
+
container.addChild(new Text(`${prefix}${message}`, 0, 0));
|
|
645
|
+
}
|
|
646
|
+
container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
|
|
647
|
+
return container;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function createBuiltInDefinition(name: CompactToolName, cwd: string): BuiltInDefinition {
|
|
651
|
+
switch (name) {
|
|
652
|
+
case "read": return createReadToolDefinition(cwd);
|
|
653
|
+
case "write": return createWriteToolDefinition(cwd);
|
|
654
|
+
case "edit": return createEditToolDefinition(cwd);
|
|
655
|
+
case "bash": return createBashToolDefinition(cwd);
|
|
656
|
+
case "powershell": return createPowerShellToolDefinition(cwd);
|
|
657
|
+
case "grep": return createGrepToolDefinition(cwd);
|
|
658
|
+
case "find": return createFindToolDefinition(cwd);
|
|
659
|
+
case "ls": return createLsToolDefinition(cwd);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function registerShellTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
|
|
664
|
+
pi.registerTool({
|
|
665
|
+
...definition,
|
|
666
|
+
execute: createTimedExecute(definition),
|
|
667
|
+
renderShell: "self",
|
|
668
|
+
renderCall: (args: BashToolInput, theme: Theme, ctx: RenderContext<BashToolInput>) =>
|
|
669
|
+
renderShellCall(definition.name as "bash" | "powershell", args, theme, ctx),
|
|
670
|
+
renderResult: renderShellResult,
|
|
671
|
+
} as ToolDefinition<any, BashToolDetails | undefined, RowState>);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function registerBuiltInTools(pi: ExtensionAPI, cwd: string): void {
|
|
675
|
+
for (const name of registeredCompactTools) {
|
|
676
|
+
if (!config.tools.includes(name)) pi.registerTool(createBuiltInDefinition(name, cwd));
|
|
677
|
+
}
|
|
678
|
+
registeredCompactTools.clear();
|
|
679
|
+
for (const name of config.tools) {
|
|
680
|
+
const definition = createBuiltInDefinition(name, cwd);
|
|
681
|
+
if (name === "bash" || name === "powershell") registerShellTool(pi, definition);
|
|
682
|
+
else registerFileTool(pi, definition);
|
|
683
|
+
registeredCompactTools.add(name);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function applyConfig(pi: ExtensionAPI, cwd: string, projectTrusted: boolean): void {
|
|
688
|
+
resetUiState();
|
|
689
|
+
animateRows = getConfiguredTuiMode() === "fullscreen";
|
|
690
|
+
config = loadConfig(cwd, projectTrusted);
|
|
691
|
+
registerBuiltInTools(pi, cwd);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export default function (pi: ExtensionAPI): void {
|
|
695
|
+
animateRows = getConfiguredTuiMode() === "fullscreen";
|
|
696
|
+
config = loadConfig();
|
|
697
|
+
registerBuiltInTools(pi, process.cwd());
|
|
698
|
+
pi.on("session_start", (event, ctx) => {
|
|
699
|
+
if (event.reason !== "reload") executionTimings.clear();
|
|
700
|
+
applyConfig(pi, ctx.cwd, ctx.isProjectTrusted());
|
|
701
|
+
});
|
|
702
|
+
pi.on("resources_discover", (_event, ctx) => applyConfig(pi, ctx.cwd, ctx.isProjectTrusted()));
|
|
703
|
+
pi.on("session_shutdown", (event) => resetUiState(event.reason !== "reload"));
|
|
704
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-compact-tools",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Dong Hyeong Lee",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/nedleeds/pi-compact-ui.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/nedleeds/pi-compact-ui#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/nedleeds/pi-compact-ui/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-coding-agent",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"terminal-ui",
|
|
21
|
+
"theme"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"extensions",
|
|
25
|
+
"themes",
|
|
26
|
+
"examples",
|
|
27
|
+
"scripts",
|
|
28
|
+
"schemas",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "tsx --test tests/*.test.ts",
|
|
34
|
+
"check": "tsc --noEmit && npm test && npm run check:resources",
|
|
35
|
+
"check:resources": "node scripts/check-resources.mjs",
|
|
36
|
+
"prepublishOnly": "npm run check"
|
|
37
|
+
},
|
|
38
|
+
"pi": {
|
|
39
|
+
"extensions": [
|
|
40
|
+
"./extensions/compact-tools.ts"
|
|
41
|
+
],
|
|
42
|
+
"themes": [
|
|
43
|
+
"./themes/github-dark-pro.json"
|
|
44
|
+
],
|
|
45
|
+
"image": "https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/built-in-tool-support.png"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
+
"@earendil-works/pi-tui": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
53
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
54
|
+
"@types/node": "^24.0.0",
|
|
55
|
+
"tsx": "^4.23.13",
|
|
56
|
+
"typescript": "^5.9.0"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=20"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/schemas/compact-tools.schema.json",
|
|
4
|
+
"title": "pi-compact-tools configuration",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"$schema": {
|
|
9
|
+
"type": "string"
|
|
10
|
+
},
|
|
11
|
+
"tools": {
|
|
12
|
+
"description": "Built-in tools that use compact rendering. Omitted tools keep Pi's default renderer.",
|
|
13
|
+
"type": "array",
|
|
14
|
+
"uniqueItems": true,
|
|
15
|
+
"items": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"]
|
|
18
|
+
},
|
|
19
|
+
"default": ["read", "write", "edit", "bash"]
|
|
20
|
+
},
|
|
21
|
+
"previewLines": {
|
|
22
|
+
"description": "Number of output lines shown before the full-output expansion level.",
|
|
23
|
+
"type": "integer",
|
|
24
|
+
"minimum": 1,
|
|
25
|
+
"maximum": 1000,
|
|
26
|
+
"default": 10
|
|
27
|
+
},
|
|
28
|
+
"spinner": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"frames": {
|
|
33
|
+
"description": "Animation frames. Use terminal-safe single-width characters for consistent alignment.",
|
|
34
|
+
"type": "array",
|
|
35
|
+
"minItems": 1,
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"minLength": 1
|
|
39
|
+
},
|
|
40
|
+
"default": ["◐", "◓", "◑", "◒"]
|
|
41
|
+
},
|
|
42
|
+
"intervalMs": {
|
|
43
|
+
"description": "Milliseconds between spinner frames.",
|
|
44
|
+
"type": "integer",
|
|
45
|
+
"minimum": 40,
|
|
46
|
+
"maximum": 5000,
|
|
47
|
+
"default": 120
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"durationIndicators": {
|
|
52
|
+
"description": "Ordered duration thresholds. The final item must omit underMs and acts as the fallback.",
|
|
53
|
+
"type": "array",
|
|
54
|
+
"minItems": 1,
|
|
55
|
+
"items": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": false,
|
|
58
|
+
"required": ["icon"],
|
|
59
|
+
"properties": {
|
|
60
|
+
"underMs": {
|
|
61
|
+
"type": "integer",
|
|
62
|
+
"minimum": 1
|
|
63
|
+
},
|
|
64
|
+
"icon": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"minLength": 1
|
|
67
|
+
},
|
|
68
|
+
"color": {
|
|
69
|
+
"description": "Optional semantic theme color or six-digit hex color used only for this duration icon.",
|
|
70
|
+
"anyOf": [
|
|
71
|
+
{
|
|
72
|
+
"type": "string",
|
|
73
|
+
"enum": [
|
|
74
|
+
"accent", "border", "borderAccent", "borderMuted", "success", "error", "warning",
|
|
75
|
+
"muted", "dim", "text", "thinkingText", "toolTitle", "toolOutput", "toolDiffAdded",
|
|
76
|
+
"toolDiffRemoved", "toolDiffContext", "bashMode"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"type": "string",
|
|
81
|
+
"pattern": "^#[0-9A-Fa-f]{6}$"
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"default": "dim"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const resources = [
|
|
4
|
+
"themes/github-dark-pro.json",
|
|
5
|
+
"examples/compact-tools.json",
|
|
6
|
+
"schemas/compact-tools.schema.json",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
for (const path of resources) {
|
|
10
|
+
const value = JSON.parse(await readFile(new URL(`../${path}`, import.meta.url), "utf8"));
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
12
|
+
throw new Error(`${path} must contain a JSON object`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const theme = JSON.parse(
|
|
17
|
+
await readFile(new URL("../themes/github-dark-pro.json", import.meta.url), "utf8"),
|
|
18
|
+
);
|
|
19
|
+
if (theme.name !== "github-dark-pro" || !theme.colors || typeof theme.colors !== "object") {
|
|
20
|
+
throw new Error("themes/github-dark-pro.json is not a valid pi theme document");
|
|
21
|
+
}
|
|
22
|
+
if (!theme.colors.border || !theme.colors.dim || !theme.colors.muted) {
|
|
23
|
+
throw new Error("github-dark-pro must define spinner and separator colors");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const example = JSON.parse(
|
|
27
|
+
await readFile(new URL("../examples/compact-tools.json", import.meta.url), "utf8"),
|
|
28
|
+
);
|
|
29
|
+
if (!Array.isArray(example.spinner?.frames) || example.spinner.frames.length === 0) {
|
|
30
|
+
throw new Error("example spinner.frames must be a non-empty array");
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isInteger(example.spinner.intervalMs)) {
|
|
33
|
+
throw new Error("example spinner.intervalMs must be an integer");
|
|
34
|
+
}
|
|
35
|
+
const indicators = example.durationIndicators;
|
|
36
|
+
if (!Array.isArray(indicators) || indicators.length === 0 || indicators.at(-1)?.underMs !== undefined) {
|
|
37
|
+
throw new Error("example durationIndicators must end with a fallback rule");
|
|
38
|
+
}
|
|
39
|
+
for (let index = 0; index < indicators.length - 1; index++) {
|
|
40
|
+
if (!(indicators[index].underMs > (indicators[index - 1]?.underMs ?? 0))) {
|
|
41
|
+
throw new Error("example durationIndicators thresholds must be ascending");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (indicators.some((indicator) => indicator.color !== undefined && typeof indicator.color !== "string")) {
|
|
45
|
+
throw new Error("example duration indicator colors must be strings");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const schema = JSON.parse(
|
|
49
|
+
await readFile(new URL("../schemas/compact-tools.schema.json", import.meta.url), "utf8"),
|
|
50
|
+
);
|
|
51
|
+
if (!schema.properties?.durationIndicators?.items?.properties?.color) {
|
|
52
|
+
throw new Error("schema must describe durationIndicators.color");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log("Resource checks passed");
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "github-dark-pro",
|
|
4
|
+
"vars": {
|
|
5
|
+
"surfaceMuted": "#21262D",
|
|
6
|
+
"border": "#30363D",
|
|
7
|
+
"borderSubtle": "#21262D",
|
|
8
|
+
"text": "#C9D1D9",
|
|
9
|
+
"textBright": "#E6EDF3",
|
|
10
|
+
"muted": "#8B949E",
|
|
11
|
+
"dim": "#6E7681",
|
|
12
|
+
"toolStatus": "#59616B",
|
|
13
|
+
"blue": "#58A6FF",
|
|
14
|
+
"blueSoft": "#79C0FF",
|
|
15
|
+
"green": "#3FB950",
|
|
16
|
+
"red": "#F85149",
|
|
17
|
+
"yellow": "#E0A052",
|
|
18
|
+
"orange": "#FFA657",
|
|
19
|
+
"purple": "#D2A8FF",
|
|
20
|
+
"toolAccent": "#95A7FF",
|
|
21
|
+
"toolName": "#8B85D6",
|
|
22
|
+
"toolText": "#98A1AC",
|
|
23
|
+
"pink": "#FF7B72"
|
|
24
|
+
},
|
|
25
|
+
"colors": {
|
|
26
|
+
"accent": "toolAccent",
|
|
27
|
+
"border": "border",
|
|
28
|
+
"borderAccent": "toolStatus",
|
|
29
|
+
"borderMuted": "borderSubtle",
|
|
30
|
+
"success": "green",
|
|
31
|
+
"error": "red",
|
|
32
|
+
"warning": "yellow",
|
|
33
|
+
"muted": "muted",
|
|
34
|
+
"dim": "dim",
|
|
35
|
+
"text": "text",
|
|
36
|
+
"thinkingText": "muted",
|
|
37
|
+
|
|
38
|
+
"selectedBg": "surfaceMuted",
|
|
39
|
+
"searchMatchBg": "#3B2E10",
|
|
40
|
+
"searchMatchText": "textBright",
|
|
41
|
+
"userMessageBg": "#16181C",
|
|
42
|
+
"userMessageText": "textBright",
|
|
43
|
+
"customMessageBg": "",
|
|
44
|
+
"customMessageText": "text",
|
|
45
|
+
"customMessageLabel": "muted",
|
|
46
|
+
"toolPendingBg": "",
|
|
47
|
+
"toolSuccessBg": "",
|
|
48
|
+
"toolErrorBg": "#211414",
|
|
49
|
+
"toolTitle": "toolName",
|
|
50
|
+
"toolOutput": "toolText",
|
|
51
|
+
"scrollbarTrack": "borderSubtle",
|
|
52
|
+
"scrollbarThumb": "border",
|
|
53
|
+
|
|
54
|
+
"mdHeading": "textBright",
|
|
55
|
+
"mdLink": "blue",
|
|
56
|
+
"mdLinkUrl": "dim",
|
|
57
|
+
"mdCode": "blueSoft",
|
|
58
|
+
"mdCodeBlock": "text",
|
|
59
|
+
"mdCodeBlockBorder": "#484F58",
|
|
60
|
+
"mdQuote": "muted",
|
|
61
|
+
"mdQuoteBorder": "border",
|
|
62
|
+
"mdHr": "borderSubtle",
|
|
63
|
+
"mdListBullet": "muted",
|
|
64
|
+
|
|
65
|
+
"toolDiffAdded": "#45B85A",
|
|
66
|
+
"toolDiffRemoved": "#E45D57",
|
|
67
|
+
"toolDiffContext": "dim",
|
|
68
|
+
|
|
69
|
+
"syntaxComment": "dim",
|
|
70
|
+
"syntaxKeyword": "pink",
|
|
71
|
+
"syntaxFunction": "purple",
|
|
72
|
+
"syntaxVariable": "text",
|
|
73
|
+
"syntaxString": "blueSoft",
|
|
74
|
+
"syntaxNumber": "orange",
|
|
75
|
+
"syntaxType": "orange",
|
|
76
|
+
"syntaxOperator": "muted",
|
|
77
|
+
"syntaxPunctuation": "muted",
|
|
78
|
+
|
|
79
|
+
"thinkingOff": "borderSubtle",
|
|
80
|
+
"thinkingMinimal": "border",
|
|
81
|
+
"thinkingLow": "dim",
|
|
82
|
+
"thinkingMedium": "muted",
|
|
83
|
+
"thinkingHigh": "blue",
|
|
84
|
+
"thinkingXhigh": "blueSoft",
|
|
85
|
+
"thinkingMax": "blueSoft",
|
|
86
|
+
"bashMode": "green"
|
|
87
|
+
},
|
|
88
|
+
"export": {
|
|
89
|
+
"pageBg": "#0D1117",
|
|
90
|
+
"cardBg": "#161B22",
|
|
91
|
+
"infoBg": "#1C2128"
|
|
92
|
+
}
|
|
93
|
+
}
|