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 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
+ ![Built-in tool support](https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/built-in-tool-support.png)
10
+
11
+ ## Demo
12
+
13
+ **Spinner and status** – track active execution and see duration-aware completion indicators.
14
+
15
+ ![Spinner and duration indicators](https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/spinner-status.gif)
16
+
17
+ **Keyboard expansion** – press `Ctrl+O` to cycle through detail levels.
18
+
19
+ ![Ctrl+O expansion](https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/keyboard-expand.gif)
20
+
21
+ **Edit diffs** – click a finished `edit` row to reveal the diff, click again to collapse.
22
+
23
+ ![Edit diff expansion](https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/edit-diff.gif)
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
+ }