@nedleeds/pi-compact-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE 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,144 @@
1
+ # pi-compact-ui
2
+
3
+ Compact, expandable rendering for pi's built-in `read`, `write`, `edit`, and `bash` tools, bundled with a polished GitHub Dark theme.
4
+
5
+ Designed for a focused, low-noise terminal workflow.
6
+
7
+ ![Spinner and duration indicators](https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/spinner-status.gif)
8
+
9
+ ## Demo
10
+
11
+ **Keyboard expansion** – press `Ctrl+O` to cycle through detail levels.
12
+
13
+ ![Ctrl+O expansion](https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/keyboard-expand.gif)
14
+
15
+ **Edit diffs** – click a finished `edit` row to reveal the diff, click again to collapse.
16
+
17
+ ![Edit diff expansion](https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/edit-diff.gif)
18
+
19
+ ## Features
20
+
21
+ - Compact one-line tool calls
22
+ - Click or `Ctrl+O` to cycle through available detail levels
23
+ - Skips argument and output levels that contain no additional information
24
+ - Animated, configurable tool spinner
25
+ - Execution duration with configurable indicators
26
+ - Short and full output previews
27
+ - Clean edit diffs without duplicate JSON arguments or leading blank lines
28
+ - GitHub Dark theme with distinct tool, output, success, and error colors
29
+ - No Nerd Font requirement
30
+
31
+ ## Requirements
32
+
33
+ - pi `0.85.1` or newer is recommended
34
+ - Node.js 20 or newer
35
+ - Fullscreen TUI mode is required for mouse-click expansion; `Ctrl+O` works without it
36
+
37
+ ## Install
38
+
39
+ From npm:
40
+
41
+ ```bash
42
+ pi install npm:@nedleeds/pi-compact-ui
43
+ ```
44
+
45
+ Or directly from GitHub:
46
+
47
+ ```bash
48
+ pi install git:github.com/nedleeds/pi-compact-ui
49
+ ```
50
+
51
+ Restart pi or run `/reload` after installation.
52
+
53
+ ## Theme
54
+
55
+ The package includes the `github-dark-pro` theme. Select it from pi's settings UI or set it in `~/.pi/agent/settings.json`:
56
+
57
+ ```json
58
+ {
59
+ "theme": "github-dark-pro"
60
+ }
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ The extension works without a configuration file. Its defaults are defined in the source code.
66
+
67
+ 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.
68
+
69
+ For project-specific settings, create:
70
+
71
+ ```text
72
+ <project>/.pi/compact-tools.json
73
+ ```
74
+
75
+ Project configuration is loaded only for trusted projects. Values are merged in this order:
76
+
77
+ ```text
78
+ built-in defaults
79
+ → ~/.pi/agent/compact-tools.json
80
+ → <project>/.pi/compact-tools.json
81
+ ```
82
+
83
+ Default configuration:
84
+
85
+ ```json
86
+ {
87
+ "$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/schemas/compact-tools.schema.json",
88
+ "previewLines": 10,
89
+ "spinner": {
90
+ "frames": ["◐", "◓", "◑", "◒"],
91
+ "intervalMs": 120
92
+ },
93
+ "durationIndicators": [
94
+ { "underMs": 1000, "icon": "⚡️" },
95
+ { "underMs": 10000, "icon": "🚀" },
96
+ { "underMs": 30000, "icon": "🔥" },
97
+ { "icon": "⏳" }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ `durationIndicators` must use ascending `underMs` values. The final entry must omit `underMs` and acts as the fallback.
103
+
104
+ ## Expansion levels
105
+
106
+ Only levels with meaningful content are included. Depending on the tool call, the cycle may be:
107
+
108
+ ```text
109
+ summary → arguments → output preview → full output → summary
110
+ summary → output → summary
111
+ summary only
112
+ ```
113
+
114
+ `edit` uses:
115
+
116
+ ```text
117
+ summary → diff → summary
118
+ ```
119
+
120
+ ## Compatibility and limitations
121
+
122
+ - The package overrides pi's built-in `read`, `write`, `edit`, and `bash` definitions while preserving their execution behavior and metadata.
123
+ - Another extension overriding the same tool names may conflict depending on extension load order.
124
+ - Tools registered by other extensions are not modified.
125
+ - Duration is measured from the first execution render and is intended as a UI estimate.
126
+ - Emoji appearance depends on terminal and system font support. All default duration icons use Unicode 6.0 or earlier.
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ npm install
132
+ npm run check
133
+ pi -e .
134
+ ```
135
+
136
+ When testing with `pi -e .`, disable any local copy of `compact-tools.ts` to avoid duplicate tool overrides.
137
+
138
+ ## Security
139
+
140
+ Pi extensions execute with full system access. Review extension source before installing third-party packages.
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/schemas/compact-tools.schema.json",
3
+ "previewLines": 10,
4
+ "spinner": {
5
+ "frames": ["◐", "◓", "◑", "◒"],
6
+ "intervalMs": 120
7
+ },
8
+ "durationIndicators": [
9
+ { "underMs": 1000, "icon": "⚡️" },
10
+ { "underMs": 10000, "icon": "🚀" },
11
+ { "underMs": 30000, "icon": "🔥" },
12
+ { "icon": "⏳" }
13
+ ]
14
+ }
@@ -0,0 +1,528 @@
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
+ createReadToolDefinition,
19
+ createWriteToolDefinition,
20
+ getAgentDir,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import type { Component } from "@earendil-works/pi-tui";
23
+ import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
24
+
25
+ const MAX_LEVEL = 3;
26
+ const CONFIG_FILE = "compact-tools.json";
27
+
28
+ export interface DurationIndicatorConfig {
29
+ underMs?: number;
30
+ icon: string;
31
+ }
32
+
33
+ export interface CompactToolsConfig {
34
+ previewLines: number;
35
+ spinner: {
36
+ frames: string[];
37
+ intervalMs: number;
38
+ };
39
+ durationIndicators: DurationIndicatorConfig[];
40
+ }
41
+
42
+ export const DEFAULT_CONFIG: CompactToolsConfig = {
43
+ previewLines: 10,
44
+ spinner: {
45
+ frames: ["◐", "◓", "◑", "◒"],
46
+ intervalMs: 120,
47
+ },
48
+ durationIndicators: [
49
+ { underMs: 1_000, icon: "⚡️" },
50
+ { underMs: 10_000, icon: "🚀" },
51
+ { underMs: 30_000, icon: "🔥" },
52
+ { icon: "☕" },
53
+ ],
54
+ };
55
+
56
+ let config = DEFAULT_CONFIG;
57
+
58
+ interface RowState {
59
+ level?: number;
60
+ availableLevels?: number[];
61
+ lastExpanded?: boolean;
62
+ frame?: number;
63
+ startedAt?: number;
64
+ endedAt?: number;
65
+ timer?: ReturnType<typeof setInterval>;
66
+ originalResultComponent?: Component;
67
+ }
68
+
69
+ type ToolArgs = Record<string, unknown>;
70
+ type BuiltInDefinition = ToolDefinition<any, any, any>;
71
+ type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
72
+ type RenderContext<TArgs = ToolArgs> = Omit<BaseRenderContext, "args" | "state"> & {
73
+ args: TArgs;
74
+ state: RowState;
75
+ };
76
+
77
+ type JsonObject = Record<string, unknown>;
78
+
79
+ function isObject(value: unknown): value is JsonObject {
80
+ return typeof value === "object" && value !== null && !Array.isArray(value);
81
+ }
82
+
83
+ function warnConfig(path: string, message: string): void {
84
+ console.error(`[compact-tools] ${path}: ${message}`);
85
+ }
86
+
87
+ function isIntegerInRange(value: unknown, minimum: number, maximum: number): value is number {
88
+ return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
89
+ }
90
+
91
+ function isNonEmptyStringArray(value: unknown): value is string[] {
92
+ return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0);
93
+ }
94
+
95
+ function parseDurationIndicators(value: unknown): DurationIndicatorConfig[] | undefined {
96
+ if (!Array.isArray(value) || value.length === 0) return undefined;
97
+ const rules: DurationIndicatorConfig[] = [];
98
+ let previousLimit = 0;
99
+ for (const [index, item] of value.entries()) {
100
+ if (!isObject(item) || typeof item.icon !== "string" || item.icon.length === 0) return undefined;
101
+ const isLast = index === value.length - 1;
102
+ if (isLast && item.underMs === undefined) rules.push({ icon: item.icon });
103
+ else if (isIntegerInRange(item.underMs, previousLimit + 1, Number.MAX_SAFE_INTEGER)) {
104
+ previousLimit = item.underMs;
105
+ rules.push({ underMs: item.underMs, icon: item.icon });
106
+ } else return undefined;
107
+ }
108
+ return rules.at(-1)?.underMs === undefined ? rules : undefined;
109
+ }
110
+
111
+ function mergeConfig(base: CompactToolsConfig, value: unknown, path: string): CompactToolsConfig {
112
+ if (value === undefined) return base;
113
+ if (!isObject(value)) {
114
+ warnConfig(path, "expected a JSON object; using previous values");
115
+ return base;
116
+ }
117
+ const previewValue = value.previewLines;
118
+ const validPreviewLines = isIntegerInRange(previewValue, 1, 1_000);
119
+ const previewLines = validPreviewLines ? previewValue : base.previewLines;
120
+ if (previewValue !== undefined && !validPreviewLines) {
121
+ warnConfig(path, "previewLines must be an integer from 1 to 1000; using previous value");
122
+ }
123
+ const spinner = parseSpinnerConfig(base.spinner, value.spinner, path);
124
+ const durationIndicators = parseDurationIndicators(value.durationIndicators) ?? base.durationIndicators;
125
+ if (value.durationIndicators !== undefined && durationIndicators === base.durationIndicators) {
126
+ warnConfig(path, "invalid durationIndicators; using previous values");
127
+ }
128
+ return { previewLines, spinner, durationIndicators };
129
+ }
130
+
131
+ function parseSpinnerConfig(
132
+ base: CompactToolsConfig["spinner"],
133
+ value: unknown,
134
+ path: string,
135
+ ): CompactToolsConfig["spinner"] {
136
+ if (value === undefined) return base;
137
+ if (!isObject(value)) {
138
+ warnConfig(path, "spinner must be an object; using previous values");
139
+ return base;
140
+ }
141
+ const framesValue = value.frames;
142
+ const intervalValue = value.intervalMs;
143
+ const validFrames = isNonEmptyStringArray(framesValue);
144
+ const validInterval = isIntegerInRange(intervalValue, 40, 5_000);
145
+ if (framesValue !== undefined && !validFrames) warnConfig(path, "spinner.frames must be a non-empty string array");
146
+ if (intervalValue !== undefined && !validInterval) warnConfig(path, "spinner.intervalMs must be 40–5000");
147
+ return {
148
+ frames: validFrames ? framesValue : base.frames,
149
+ intervalMs: validInterval ? intervalValue : base.intervalMs,
150
+ };
151
+ }
152
+
153
+ function readConfig(path: string): unknown {
154
+ if (!existsSync(path)) return undefined;
155
+ try {
156
+ return JSON.parse(readFileSync(path, "utf8"));
157
+ } catch (error) {
158
+ warnConfig(path, error instanceof Error ? error.message : String(error));
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ export function loadConfig(cwd?: string, projectTrusted = false): CompactToolsConfig {
164
+ const globalPath = join(getAgentDir(), CONFIG_FILE);
165
+ let loaded = mergeConfig(DEFAULT_CONFIG, readConfig(globalPath), globalPath);
166
+ if (!cwd || !projectTrusted) return loaded;
167
+ const projectPath = join(cwd, CONFIG_DIR_NAME, CONFIG_FILE);
168
+ loaded = mergeConfig(loaded, readConfig(projectPath), projectPath);
169
+ return loaded;
170
+ }
171
+
172
+ const SPINNING_ROWS_KEY = Symbol.for("pi.compact-tools.spinning-rows");
173
+ const globalState = globalThis as typeof globalThis & { [SPINNING_ROWS_KEY]?: Set<RowState> };
174
+ for (const staleState of globalState[SPINNING_ROWS_KEY] ?? []) {
175
+ if (staleState.timer) clearInterval(staleState.timer);
176
+ }
177
+ const spinningRows = new Set<RowState>();
178
+ globalState[SPINNING_ROWS_KEY] = spinningRows;
179
+
180
+ function advanceLevel(state: RowState, expanded: boolean): number {
181
+ state.level ??= 0;
182
+ if (state.lastExpanded === undefined) {
183
+ state.lastExpanded = expanded;
184
+ return state.level;
185
+ }
186
+ if (state.lastExpanded !== expanded) {
187
+ state.lastExpanded = expanded;
188
+ const levels = state.availableLevels ?? [0, 1];
189
+ const index = levels.indexOf(state.level);
190
+ state.level = levels[(index + 1) % levels.length] ?? 0;
191
+ }
192
+ return state.level;
193
+ }
194
+
195
+ function setAvailableLevels(state: RowState, levels: number[]): void {
196
+ state.availableLevels = levels;
197
+ if (!levels.includes(state.level ?? 0)) state.level = 0;
198
+ }
199
+
200
+ function getOutputLevels(output: string): number[] {
201
+ const normalized = output.trimEnd();
202
+ if (!normalized) return [];
203
+ return normalized.split("\n").length > config.previewLines ? [2, 3] : [2];
204
+ }
205
+
206
+ function stopSpinner(state: RowState): void {
207
+ if (state.timer) clearInterval(state.timer);
208
+ state.timer = undefined;
209
+ spinningRows.delete(state);
210
+ }
211
+
212
+ function syncSpinner(state: RowState, running: boolean, invalidate: () => void): void {
213
+ if (!running) {
214
+ stopSpinner(state);
215
+ return;
216
+ }
217
+ if (state.timer) return;
218
+
219
+ state.frame ??= 0;
220
+ state.timer = setInterval(() => {
221
+ state.frame = ((state.frame ?? 0) + 1) % config.spinner.frames.length;
222
+ try {
223
+ invalidate();
224
+ } catch {
225
+ stopSpinner(state);
226
+ }
227
+ }, config.spinner.intervalMs);
228
+ state.timer.unref?.();
229
+ spinningRows.add(state);
230
+ }
231
+
232
+ function syncTiming(state: RowState, executionStarted: boolean, running: boolean): void {
233
+ if ((executionStarted || running) && state.startedAt === undefined) state.startedAt = Date.now();
234
+ if (!running && state.startedAt !== undefined && state.endedAt === undefined) {
235
+ state.endedAt = Date.now();
236
+ }
237
+ }
238
+
239
+ function syncRow(ctx: RenderContext, running = ctx.executionStarted && ctx.isPartial): RowState {
240
+ const state = ctx.state;
241
+ syncTiming(state, ctx.executionStarted, running);
242
+ syncSpinner(state, running, ctx.invalidate);
243
+ return state;
244
+ }
245
+
246
+ function resetUiState(): void {
247
+ for (const state of [...spinningRows]) stopSpinner(state);
248
+ }
249
+
250
+ function renderIndicator(
251
+ theme: Theme,
252
+ state: RowState,
253
+ running: boolean,
254
+ isError: boolean,
255
+ pending: boolean,
256
+ ): string {
257
+ if (isError) return theme.fg("error", "⊗");
258
+ if (pending) return theme.fg("muted", "·");
259
+ if (!running) return theme.fg("success", "●");
260
+ const frame = config.spinner.frames[state.frame ?? 0] ?? config.spinner.frames[0] ?? "◐";
261
+ return theme.fg("muted", frame);
262
+ }
263
+
264
+ function formatDuration(state: RowState): string | undefined {
265
+ if (state.startedAt === undefined) return undefined;
266
+ const elapsedMs = (state.endedAt ?? Date.now()) - state.startedAt;
267
+ return `${(elapsedMs / 1000).toFixed(1)}s`;
268
+ }
269
+
270
+ function durationIndicator(state: RowState): string | undefined {
271
+ if (state.startedAt === undefined || state.endedAt === undefined) return undefined;
272
+ const elapsedMs = state.endedAt - state.startedAt;
273
+ return config.durationIndicators.find((rule) => rule.underMs === undefined || elapsedMs < rule.underMs)?.icon;
274
+ }
275
+
276
+ function renderControls(theme: Theme, state: RowState, running: boolean, isError: boolean): Text {
277
+ const duration = formatDuration(state);
278
+ const levels = state.availableLevels ?? [0, 1];
279
+ const expandable = levels.length > 1;
280
+ const atLastLevel = state.level === levels.at(-1);
281
+ const action = expandable
282
+ ? `${theme.italic("ctrl+o")} ${atLastLevel ? "to collapse" : "for more"}`
283
+ : undefined;
284
+ const indicator = !running && !isError ? durationIndicator(state) : undefined;
285
+ const status = running
286
+ ? duration
287
+ : `${indicator ? `${indicator} ` : ""}${isError ? "Failed" : "Done"}${duration ? ` in ${duration}` : ""}`;
288
+ const details = [status, action].filter(Boolean).join(", ");
289
+ return new Text(` ${theme.fg("dim", details)}`, 0, 0);
290
+ }
291
+
292
+ function firstLine(value: string, maxLength = 100): string {
293
+ const line = value.split("\n")[0] ?? "";
294
+ return line.length > maxLength ? `${line.slice(0, maxLength - 1)}…` : line;
295
+ }
296
+
297
+ function getTextResult(result: AgentToolResult<unknown>): string {
298
+ return result.content
299
+ .filter((item) => item.type === "text")
300
+ .map((item) => item.text)
301
+ .join("\n")
302
+ .trimEnd();
303
+ }
304
+
305
+ function withoutLeadingBlankLines(component: Component): Component {
306
+ return {
307
+ render(width: number) {
308
+ const lines = component.render(width);
309
+ const firstContentLine = lines.findIndex((line) => visibleWidth(line.trim()) > 0);
310
+ return firstContentLine < 0 ? [] : lines.slice(firstContentLine);
311
+ },
312
+ invalidate() {
313
+ component.invalidate?.();
314
+ },
315
+ };
316
+ }
317
+
318
+ function styleToolOutput(text: string, theme: Theme, isError: boolean): string {
319
+ const color = isError ? "error" : "toolOutput";
320
+ return text
321
+ .split(/(⚡️?)/u)
322
+ .map((part) => theme.fg(!isError && part.startsWith("⚡") ? "warning" : color, part))
323
+ .join("");
324
+ }
325
+
326
+ function renderPreview(output: string, level: number, theme: Theme, isError: boolean): Text | undefined {
327
+ const normalized = output.trimEnd();
328
+ if (!normalized) return undefined;
329
+
330
+ const lines = normalized.split("\n");
331
+ const shown = level >= MAX_LEVEL ? lines : lines.slice(0, config.previewLines);
332
+ let text = shown.map((line) => ` ${line}`).join("\n");
333
+ if (shown.length < lines.length) text += `\n … ${lines.length - shown.length} more lines`;
334
+ return new Text(styleToolOutput(text, theme, isError), 0, 0);
335
+ }
336
+
337
+ function getPathArg(args: ToolArgs): string {
338
+ const value = args.path ?? args.file_path;
339
+ return typeof value === "string" ? firstLine(value) : "";
340
+ }
341
+
342
+ function getFileArgumentDetails(name: string, args: ToolArgs): ToolArgs {
343
+ if (name === "edit") return {};
344
+ const omitted = new Set(["path", "file_path"]);
345
+ if (name === "write") omitted.add("content");
346
+ return Object.fromEntries(Object.entries(args).filter(([key, value]) => !omitted.has(key) && value !== undefined));
347
+ }
348
+
349
+ function renderArguments(args: ToolArgs, theme: Theme): Text {
350
+ const json = JSON.stringify(args, null, 2) ?? "{}";
351
+ const text = json
352
+ .split("\n")
353
+ .map((line) => ` ${line}`)
354
+ .join("\n");
355
+ return new Text(theme.fg("toolOutput", text), 0, 0);
356
+ }
357
+
358
+ function callOriginalEditResult(
359
+ definition: BuiltInDefinition,
360
+ result: AgentToolResult<unknown>,
361
+ options: ToolRenderResultOptions,
362
+ theme: Theme,
363
+ ctx: RenderContext,
364
+ state: RowState,
365
+ level: number,
366
+ ): void {
367
+ if (definition.name !== "edit" || !definition.renderResult) return;
368
+ state.originalResultComponent = definition.renderResult(
369
+ result,
370
+ { ...options, expanded: level >= MAX_LEVEL },
371
+ theme,
372
+ { ...ctx, state: {}, lastComponent: state.originalResultComponent },
373
+ );
374
+ }
375
+
376
+ function getFileOutput(name: string, args: ToolArgs, result: AgentToolResult<unknown>, isError: boolean): string {
377
+ if (isError || name === "read") return getTextResult(result);
378
+ if (name === "write") return String(args.content ?? "");
379
+ return "";
380
+ }
381
+
382
+ function renderFileCall(
383
+ definition: BuiltInDefinition,
384
+ args: ToolArgs,
385
+ theme: Theme,
386
+ ctx: RenderContext,
387
+ ): Container {
388
+ const running = ctx.executionStarted && ctx.isPartial;
389
+ const state = syncRow(ctx, running);
390
+ const level = advanceLevel(state, ctx.expanded);
391
+ const path = getPathArg(args);
392
+ const argumentDetails = getFileArgumentDetails(definition.name, args);
393
+ let text = `${renderIndicator(theme, state, running, ctx.isError, ctx.isPartial && !ctx.executionStarted)} `;
394
+ text += theme.fg("toolTitle", theme.bold(definition.name));
395
+ if (path) text += ` ${theme.fg("toolOutput", path)}`;
396
+
397
+ const container = new Container();
398
+ container.addChild(new Text(text, 1, 0));
399
+ if (level >= 1 && Object.keys(argumentDetails).length > 0) {
400
+ container.addChild(renderArguments(argumentDetails, theme));
401
+ }
402
+ return container;
403
+ }
404
+
405
+ function addFileResultPreview(
406
+ container: Container,
407
+ definition: BuiltInDefinition,
408
+ state: RowState,
409
+ output: string,
410
+ level: number,
411
+ theme: Theme,
412
+ isError: boolean,
413
+ ): void {
414
+ if (level < 2) return;
415
+ if (definition.name === "edit") {
416
+ if (state.originalResultComponent) {
417
+ container.addChild(withoutLeadingBlankLines(state.originalResultComponent));
418
+ }
419
+ return;
420
+ }
421
+ const preview = renderPreview(output, level, theme, isError);
422
+ if (preview) container.addChild(preview);
423
+ }
424
+
425
+ function renderFileResult(
426
+ definition: BuiltInDefinition,
427
+ result: AgentToolResult<unknown>,
428
+ options: ToolRenderResultOptions,
429
+ theme: Theme,
430
+ ctx: RenderContext,
431
+ ): Container {
432
+ const state = syncRow(ctx, options.isPartial);
433
+ const output = getFileOutput(definition.name, ctx.args, result, ctx.isError);
434
+ const hasArguments = Object.keys(getFileArgumentDetails(definition.name, ctx.args)).length > 0;
435
+ const levels = definition.name === "edit" ? [0, 2] : [0, ...(hasArguments ? [1] : []), ...getOutputLevels(output)];
436
+ setAvailableLevels(state, levels);
437
+
438
+ const level = state.level ?? 0;
439
+ callOriginalEditResult(definition, result, options, theme, ctx, state, level);
440
+ const container = new Container();
441
+ addFileResultPreview(container, definition, state, output, level, theme, ctx.isError);
442
+ container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
443
+ return container;
444
+ }
445
+
446
+ function createFileCallRenderer(definition: BuiltInDefinition) {
447
+ return (args: ToolArgs, theme: Theme, ctx: RenderContext) => renderFileCall(definition, args, theme, ctx);
448
+ }
449
+
450
+ function createFileResultRenderer(definition: BuiltInDefinition) {
451
+ return (result: AgentToolResult<unknown>, options: ToolRenderResultOptions, theme: Theme, ctx: RenderContext) =>
452
+ renderFileResult(definition, result, options, theme, ctx);
453
+ }
454
+
455
+ function registerFileTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
456
+ const tool = {
457
+ ...definition,
458
+ renderShell: "self" as const,
459
+ renderCall: createFileCallRenderer(definition),
460
+ renderResult: createFileResultRenderer(definition),
461
+ };
462
+ // Built-in file schemas are intentionally erased at this shared renderer boundary.
463
+ pi.registerTool(tool as ToolDefinition<any, any, RowState>);
464
+ }
465
+
466
+ function renderBashCall(args: BashToolInput, theme: Theme, ctx: RenderContext<BashToolInput>): Text {
467
+ const running = ctx.executionStarted && ctx.isPartial;
468
+ const state = syncRow(ctx, running);
469
+ const level = advanceLevel(state, ctx.expanded);
470
+ const command = args.command ?? "";
471
+ const displayedCommand = (level >= 1 ? command : firstLine(command)) || "…";
472
+ let text = `${renderIndicator(theme, state, running, ctx.isError, ctx.isPartial && !ctx.executionStarted)} `;
473
+ text += `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("toolOutput", displayedCommand)}`;
474
+ if (level >= 1 && args.timeout) text += theme.fg("dim", ` (timeout: ${args.timeout}s)`);
475
+ return new Text(text, 1, 0);
476
+ }
477
+
478
+ function renderBashResult(
479
+ result: AgentToolResult<BashToolDetails | undefined>,
480
+ options: ToolRenderResultOptions,
481
+ theme: Theme,
482
+ ctx: RenderContext<BashToolInput>,
483
+ ): Component {
484
+ const state = syncRow(ctx, options.isPartial);
485
+ const output = getTextResult(result);
486
+ const hasDetailedCall = (ctx.args.command ?? "").includes("\n") || ctx.args.timeout !== undefined;
487
+ setAvailableLevels(state, [0, ...(hasDetailedCall ? [1] : []), ...getOutputLevels(output)]);
488
+ const level = state.level ?? 0;
489
+ if (level < 2) return renderControls(theme, state, options.isPartial, ctx.isError);
490
+
491
+ const container = new Container();
492
+ const preview = renderPreview(output, level, theme, ctx.isError);
493
+ if (preview) container.addChild(preview);
494
+ else container.addChild(new Text(theme.fg("dim", options.isPartial ? " …" : " (no output)"), 0, 0));
495
+ container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
496
+ return container;
497
+ }
498
+
499
+ function registerBashTool(pi: ExtensionAPI, cwd: string): void {
500
+ const original = createBashToolDefinition(cwd);
501
+ pi.registerTool<typeof original.parameters, BashToolDetails | undefined, RowState>({
502
+ ...original,
503
+ renderShell: "self",
504
+ renderCall: renderBashCall,
505
+ renderResult: renderBashResult,
506
+ });
507
+ }
508
+
509
+ function registerBuiltInTools(pi: ExtensionAPI, cwd: string): void {
510
+ registerFileTool(pi, createReadToolDefinition(cwd));
511
+ registerFileTool(pi, createEditToolDefinition(cwd));
512
+ registerFileTool(pi, createWriteToolDefinition(cwd));
513
+ registerBashTool(pi, cwd);
514
+ }
515
+
516
+ function applyConfig(pi: ExtensionAPI, cwd: string, projectTrusted: boolean): void {
517
+ resetUiState();
518
+ config = loadConfig(cwd, projectTrusted);
519
+ registerBuiltInTools(pi, cwd);
520
+ }
521
+
522
+ export default function (pi: ExtensionAPI): void {
523
+ config = loadConfig();
524
+ registerBuiltInTools(pi, process.cwd());
525
+ pi.on("session_start", (_event, ctx) => applyConfig(pi, ctx.cwd, ctx.isProjectTrusted()));
526
+ pi.on("resources_discover", (_event, ctx) => applyConfig(pi, ctx.cwd, ctx.isProjectTrusted()));
527
+ pi.on("session_shutdown", resetUiState);
528
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@nedleeds/pi-compact-ui",
3
+ "version": "0.1.0",
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
+ "check": "tsc --noEmit && npm run check:resources",
34
+ "check:resources": "node scripts/check-resources.mjs",
35
+ "prepublishOnly": "npm run check"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./extensions/compact-tools.ts"
40
+ ],
41
+ "themes": [
42
+ "./themes/github-dark-pro.json"
43
+ ],
44
+ "image": "https://raw.githubusercontent.com/nedleeds/pi-compact-ui/main/assets/spinner-status.gif"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*"
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "0.85.1",
52
+ "@earendil-works/pi-tui": "0.85.1",
53
+ "@types/node": "^24.0.0",
54
+ "typescript": "^5.9.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }
@@ -0,0 +1,62 @@
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-ui configuration",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string"
10
+ },
11
+ "previewLines": {
12
+ "description": "Number of output lines shown before the full-output expansion level.",
13
+ "type": "integer",
14
+ "minimum": 1,
15
+ "maximum": 1000,
16
+ "default": 10
17
+ },
18
+ "spinner": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "properties": {
22
+ "frames": {
23
+ "description": "Animation frames. Use terminal-safe single-width characters for consistent alignment.",
24
+ "type": "array",
25
+ "minItems": 1,
26
+ "items": {
27
+ "type": "string",
28
+ "minLength": 1
29
+ },
30
+ "default": ["◐", "◓", "◑", "◒"]
31
+ },
32
+ "intervalMs": {
33
+ "description": "Milliseconds between spinner frames.",
34
+ "type": "integer",
35
+ "minimum": 40,
36
+ "maximum": 5000,
37
+ "default": 120
38
+ }
39
+ }
40
+ },
41
+ "durationIndicators": {
42
+ "description": "Ordered duration thresholds. The final item must omit underMs and acts as the fallback.",
43
+ "type": "array",
44
+ "minItems": 1,
45
+ "items": {
46
+ "type": "object",
47
+ "additionalProperties": false,
48
+ "required": ["icon"],
49
+ "properties": {
50
+ "underMs": {
51
+ "type": "integer",
52
+ "minimum": 1
53
+ },
54
+ "icon": {
55
+ "type": "string",
56
+ "minLength": 1
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,23 @@
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
+
23
+ console.log("Resource checks passed");
@@ -0,0 +1,92 @@
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
+ "blue": "#58A6FF",
13
+ "blueSoft": "#79C0FF",
14
+ "green": "#3FB950",
15
+ "red": "#F85149",
16
+ "yellow": "#E0A052",
17
+ "orange": "#FFA657",
18
+ "purple": "#D2A8FF",
19
+ "toolAccent": "#95A7FF",
20
+ "toolName": "#8B85D6",
21
+ "toolText": "#A6AFBA",
22
+ "pink": "#FF7B72"
23
+ },
24
+ "colors": {
25
+ "accent": "toolAccent",
26
+ "border": "border",
27
+ "borderAccent": "dim",
28
+ "borderMuted": "borderSubtle",
29
+ "success": "green",
30
+ "error": "red",
31
+ "warning": "yellow",
32
+ "muted": "muted",
33
+ "dim": "dim",
34
+ "text": "text",
35
+ "thinkingText": "muted",
36
+
37
+ "selectedBg": "surfaceMuted",
38
+ "searchMatchBg": "#3B2E10",
39
+ "searchMatchText": "textBright",
40
+ "userMessageBg": "#0E1014",
41
+ "userMessageText": "textBright",
42
+ "customMessageBg": "",
43
+ "customMessageText": "text",
44
+ "customMessageLabel": "muted",
45
+ "toolPendingBg": "",
46
+ "toolSuccessBg": "",
47
+ "toolErrorBg": "#211414",
48
+ "toolTitle": "toolName",
49
+ "toolOutput": "toolText",
50
+ "scrollbarTrack": "borderSubtle",
51
+ "scrollbarThumb": "border",
52
+
53
+ "mdHeading": "textBright",
54
+ "mdLink": "blue",
55
+ "mdLinkUrl": "dim",
56
+ "mdCode": "blueSoft",
57
+ "mdCodeBlock": "text",
58
+ "mdCodeBlockBorder": "#484F58",
59
+ "mdQuote": "muted",
60
+ "mdQuoteBorder": "border",
61
+ "mdHr": "borderSubtle",
62
+ "mdListBullet": "muted",
63
+
64
+ "toolDiffAdded": "#45B85A",
65
+ "toolDiffRemoved": "#E45D57",
66
+ "toolDiffContext": "dim",
67
+
68
+ "syntaxComment": "dim",
69
+ "syntaxKeyword": "pink",
70
+ "syntaxFunction": "purple",
71
+ "syntaxVariable": "text",
72
+ "syntaxString": "blueSoft",
73
+ "syntaxNumber": "orange",
74
+ "syntaxType": "orange",
75
+ "syntaxOperator": "muted",
76
+ "syntaxPunctuation": "muted",
77
+
78
+ "thinkingOff": "borderSubtle",
79
+ "thinkingMinimal": "border",
80
+ "thinkingLow": "dim",
81
+ "thinkingMedium": "muted",
82
+ "thinkingHigh": "blue",
83
+ "thinkingXhigh": "blueSoft",
84
+ "thinkingMax": "blueSoft",
85
+ "bashMode": "green"
86
+ },
87
+ "export": {
88
+ "pageBg": "#0D1117",
89
+ "cardBg": "#161B22",
90
+ "infoBg": "#1C2128"
91
+ }
92
+ }