pi-zentui 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -86,7 +86,7 @@ On first run, Zentui creates a config file at:
86
86
  {
87
87
  "icons": {
88
88
  "cwd": "󰝰",
89
- "git": "",
89
+ "git": "",
90
90
  "ahead": "↑",
91
91
  "behind": "↓",
92
92
  "diverged": "⇕",
@@ -109,10 +109,19 @@ On first run, Zentui creates a config file at:
109
109
  "tokens": "muted",
110
110
  "cost": "success",
111
111
  "separator": "borderMuted"
112
+ },
113
+ "tools": {
114
+ "style": "compact"
112
115
  }
113
116
  }
114
117
  ```
115
118
 
119
+ `tools.style` controls the initial built-in tool output style:
120
+
121
+ - `compact` — one-line tool calls by default
122
+ - `truncated` — expanded preview with long output truncated
123
+ - `full` — expanded full output
124
+
116
125
  ### Color values
117
126
 
118
127
  Colors can be:
@@ -129,23 +138,28 @@ This means Zentui works with any Pi theme — it uses your theme's colors by def
129
138
 
130
139
  ## Development
131
140
 
132
- If you use [mise](https://mise.jdx.dev/):
141
+ ```bash
142
+ npm install
143
+ npm run verify
144
+ npm run fmt
145
+ npm run pack:check
146
+ ```
147
+
148
+ ### Test in Pi
149
+
150
+ The project keeps Pi core packages as peer dependencies for runtime and dev dependencies for
151
+ typechecking. To avoid accidentally running the local `node_modules/.bin/pi` shim, the dev scripts use
152
+ the globally installed Pi binary by default:
133
153
 
134
154
  ```bash
135
- mise install
136
- mise run setup
137
- mise run verify
138
- mise run fmt
139
- mise run ci
155
+ npm run pi:dev
156
+ npm run pi:install-local
140
157
  ```
141
158
 
142
- Without mise:
159
+ Override the binary if your Pi install is somewhere else:
143
160
 
144
161
  ```bash
145
- npm install
146
- npm run verify
147
- npm run fmt
148
- npm run pack:check
162
+ PI_BIN=/path/to/pi npm run pi:dev
149
163
  ```
150
164
 
151
165
  ## Credits
@@ -0,0 +1,565 @@
1
+ import { homedir } from "node:os";
2
+ import type {
3
+ AgentToolResult,
4
+ ExtensionAPI,
5
+ Theme,
6
+ ToolDefinition,
7
+ ToolRenderResultOptions,
8
+ } from "@mariozechner/pi-coding-agent";
9
+ import {
10
+ createBashToolDefinition,
11
+ createEditToolDefinition,
12
+ createFindToolDefinition,
13
+ createGrepToolDefinition,
14
+ createLsToolDefinition,
15
+ createReadToolDefinition,
16
+ createWriteToolDefinition,
17
+ getLanguageFromPath,
18
+ highlightCode,
19
+ renderDiff,
20
+ } from "@mariozechner/pi-coding-agent";
21
+ import { Box, type Component, Container, Spacer, Text } from "@mariozechner/pi-tui";
22
+ import { type ToolOutputStyle, loadConfig } from "./config";
23
+
24
+ type BuiltInDefinitions = ReturnType<typeof createBuiltInDefinitions>;
25
+ type ToolArgs = Record<string, unknown>;
26
+
27
+ type CompactState = {
28
+ summary?: string;
29
+ error?: boolean;
30
+ errorText?: string;
31
+ };
32
+
33
+ type CompactRenderContext = {
34
+ args: ToolArgs;
35
+ state: CompactState;
36
+ isError: boolean;
37
+ lastComponent?: Component;
38
+ invalidate: () => void;
39
+ };
40
+
41
+ type CompactRenderCall = (args: ToolArgs, theme: Theme, context: CompactRenderContext) => Component;
42
+ type CompactExpandedRenderer = (
43
+ result: AgentToolResult<unknown>,
44
+ theme: Theme,
45
+ context: CompactRenderContext,
46
+ error: boolean,
47
+ mode: ExpandedOutputMode,
48
+ ) => string;
49
+ type CompactRenderResult = (
50
+ result: AgentToolResult<unknown>,
51
+ options: ToolRenderResultOptions,
52
+ theme: Theme,
53
+ context: CompactRenderContext,
54
+ ) => Component;
55
+
56
+ type OutputMode = "one-line" | "preview" | "full";
57
+ type ExpandedOutputMode = Exclude<OutputMode, "one-line">;
58
+ type ExpandedBoxStatus = "pending" | "success" | "error";
59
+ type ToolBackground = Parameters<Theme["bg"]>[0];
60
+
61
+ const PREVIEW_LINES = 12;
62
+ const OUTPUT_MODES: OutputMode[] = ["one-line", "preview", "full"];
63
+
64
+ let outputMode: OutputMode = "one-line";
65
+ let observedPiExpanded = false;
66
+
67
+ const home = homedir();
68
+ const definitionsByCwd = new Map<string, BuiltInDefinitions>();
69
+
70
+ function createBuiltInDefinitions(cwd: string) {
71
+ return {
72
+ bash: createBashToolDefinition(cwd),
73
+ edit: createEditToolDefinition(cwd),
74
+ find: createFindToolDefinition(cwd),
75
+ grep: createGrepToolDefinition(cwd),
76
+ ls: createLsToolDefinition(cwd),
77
+ read: createReadToolDefinition(cwd),
78
+ write: createWriteToolDefinition(cwd),
79
+ };
80
+ }
81
+
82
+ function getBuiltIns(cwd: string): BuiltInDefinitions {
83
+ let definitions = definitionsByCwd.get(cwd);
84
+ if (!definitions) {
85
+ definitions = createBuiltInDefinitions(cwd);
86
+ definitionsByCwd.set(cwd, definitions);
87
+ }
88
+ return definitions;
89
+ }
90
+
91
+ function stripAtPrefix(path: string): string {
92
+ return path.startsWith("@") ? path.slice(1) : path;
93
+ }
94
+
95
+ function shortPath(path: unknown, fallback = "."): string {
96
+ if (typeof path !== "string" || path.length === 0) return fallback;
97
+ const cleaned = stripAtPrefix(path);
98
+ return cleaned.startsWith(home) ? `~${cleaned.slice(home.length)}` : cleaned;
99
+ }
100
+
101
+ function quote(value: unknown): string {
102
+ return `"${String(value ?? "")}"`;
103
+ }
104
+
105
+ function truncate(value: unknown, max = 120): string {
106
+ const text = String(value ?? "")
107
+ .replace(/\s+/g, " ")
108
+ .trim();
109
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
110
+ }
111
+
112
+ function plural(count: number, one: string, many = `${one}s`): string {
113
+ return `${count} ${count === 1 ? one : many}`;
114
+ }
115
+
116
+ function asRecord(value: unknown): Record<string, unknown> {
117
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
118
+ }
119
+
120
+ function resultDetails(result: AgentToolResult<unknown>): Record<string, unknown> {
121
+ return asRecord(result.details);
122
+ }
123
+
124
+ function isResultTruncated(result: AgentToolResult<unknown>): boolean {
125
+ return Boolean(asRecord(resultDetails(result).truncation).truncated);
126
+ }
127
+
128
+ function textContent(result: AgentToolResult<unknown>): string {
129
+ const block = result.content.find((item) => item.type === "text");
130
+ return block?.type === "text" ? block.text : "";
131
+ }
132
+
133
+ function hasImage(result: AgentToolResult<unknown>): boolean {
134
+ return result.content.some((item) => item.type === "image");
135
+ }
136
+
137
+ function visibleLineCount(text: string): number {
138
+ return text
139
+ .split("\n")
140
+ .map((line) => line.trim())
141
+ .filter(
142
+ (line) =>
143
+ line.length > 0 && !line.startsWith("[Showing ") && !line.startsWith("[Output truncated"),
144
+ ).length;
145
+ }
146
+
147
+ function firstUsefulLine(text: string): string | undefined {
148
+ return text
149
+ .split("\n")
150
+ .map((line) => line.trim())
151
+ .find((line) => line.length > 0);
152
+ }
153
+
154
+ function normalizeDisplayText(text: string): string {
155
+ return text.replace(/\r/g, "");
156
+ }
157
+
158
+ function replaceTabs(text: string): string {
159
+ return text.replace(/\t/g, " ");
160
+ }
161
+
162
+ function trimTrailingEmptyLines(lines: string[]): string[] {
163
+ let end = lines.length;
164
+ while (end > 0 && lines[end - 1] === "") end--;
165
+ return lines.slice(0, end);
166
+ }
167
+
168
+ function limitRenderedText(text: string, mode: ExpandedOutputMode, theme: Theme): string {
169
+ if (mode === "full") return text;
170
+
171
+ const lines = trimTrailingEmptyLines(text.split("\n"));
172
+ if (lines.length <= PREVIEW_LINES) return lines.join("\n");
173
+
174
+ const hidden = lines.length - PREVIEW_LINES;
175
+ return `${lines.slice(0, PREVIEW_LINES).join("\n")}\n${theme.fg(
176
+ "dim",
177
+ `… ${hidden} more lines (Ctrl+O for full)`,
178
+ )}`;
179
+ }
180
+
181
+ function nextOutputMode(mode: OutputMode): OutputMode {
182
+ return OUTPUT_MODES[(OUTPUT_MODES.indexOf(mode) + 1) % OUTPUT_MODES.length] ?? "one-line";
183
+ }
184
+
185
+ function outputModeForToolStyle(style: ToolOutputStyle): OutputMode {
186
+ switch (style) {
187
+ case "full":
188
+ return "full";
189
+ case "truncated":
190
+ return "preview";
191
+ default:
192
+ return "one-line";
193
+ }
194
+ }
195
+
196
+ function configuredOutputMode(expanded: boolean): OutputMode {
197
+ const configured = outputModeForToolStyle(loadConfig().tools.style);
198
+ return configured === "one-line" && expanded ? "preview" : configured;
199
+ }
200
+
201
+ function syncOutputModeWithPiToggle(expanded: boolean) {
202
+ if (expanded === observedPiExpanded) return;
203
+ observedPiExpanded = expanded;
204
+ outputMode = nextOutputMode(outputMode);
205
+ }
206
+
207
+ function argPath(context: CompactRenderContext): string | undefined {
208
+ const rawPath = context.args.path ?? context.args.file_path;
209
+ return typeof rawPath === "string" ? stripAtPrefix(rawPath) : undefined;
210
+ }
211
+
212
+ function renderHighlightedSource(
213
+ source: string,
214
+ path: string | undefined,
215
+ theme: Theme,
216
+ error: boolean,
217
+ mode: ExpandedOutputMode,
218
+ ): string {
219
+ const normalized = replaceTabs(normalizeDisplayText(source)).trimEnd();
220
+ if (!normalized) return "";
221
+ if (error) return limitRenderedText(theme.fg("error", normalized), mode, theme);
222
+
223
+ const language = path ? getLanguageFromPath(path) : undefined;
224
+ if (!language) return limitRenderedText(theme.fg("muted", normalized), mode, theme);
225
+
226
+ try {
227
+ return limitRenderedText(
228
+ trimTrailingEmptyLines(highlightCode(normalized, language)).join("\n"),
229
+ mode,
230
+ theme,
231
+ );
232
+ } catch {
233
+ return limitRenderedText(theme.fg("muted", normalized), mode, theme);
234
+ }
235
+ }
236
+
237
+ function highlightShellCommand(command: unknown, theme: Theme): string {
238
+ const normalized = truncate(command, 180);
239
+ if (!normalized) return theme.fg("muted", "...");
240
+
241
+ try {
242
+ return trimTrailingEmptyLines(highlightCode(normalized, "bash")).join(" ");
243
+ } catch {
244
+ return theme.fg("accent", normalized);
245
+ }
246
+ }
247
+
248
+ function renderExpandedBox(content: string, theme: Theme, status: ExpandedBoxStatus) {
249
+ const background: ToolBackground =
250
+ status === "error" ? "toolErrorBg" : status === "pending" ? "toolPendingBg" : "toolSuccessBg";
251
+ const box = new Box(3, 1, (text: string) => theme.bg(background, text));
252
+ box.addChild(new Text(content, 0, 0));
253
+ return box;
254
+ }
255
+
256
+ function renderPlainExpanded(
257
+ result: AgentToolResult<unknown>,
258
+ theme: Theme,
259
+ _context: CompactRenderContext,
260
+ error: boolean,
261
+ mode: ExpandedOutputMode,
262
+ ): string {
263
+ return limitRenderedText(
264
+ theme.fg(error ? "error" : "muted", textContent(result).trimEnd()),
265
+ mode,
266
+ theme,
267
+ );
268
+ }
269
+
270
+ function renderReadExpanded(
271
+ result: AgentToolResult<unknown>,
272
+ theme: Theme,
273
+ context: CompactRenderContext,
274
+ error: boolean,
275
+ mode: ExpandedOutputMode,
276
+ ): string {
277
+ return renderHighlightedSource(textContent(result), argPath(context), theme, error, mode);
278
+ }
279
+
280
+ function renderBashExpanded(
281
+ result: AgentToolResult<unknown>,
282
+ theme: Theme,
283
+ _context: CompactRenderContext,
284
+ error: boolean,
285
+ mode: ExpandedOutputMode,
286
+ ): string {
287
+ const output = textContent(result).trimEnd();
288
+ return output ? limitRenderedText(theme.fg(error ? "error" : "muted", output), mode, theme) : "";
289
+ }
290
+
291
+ function renderWriteExpanded(
292
+ result: AgentToolResult<unknown>,
293
+ theme: Theme,
294
+ context: CompactRenderContext,
295
+ error: boolean,
296
+ mode: ExpandedOutputMode,
297
+ ) {
298
+ if (error) return renderPlainExpanded(result, theme, context, true, mode);
299
+ const source =
300
+ typeof context.args.content === "string" ? context.args.content : textContent(result);
301
+ return renderHighlightedSource(source, argPath(context), theme, false, mode);
302
+ }
303
+
304
+ function renderEditExpanded(
305
+ result: AgentToolResult<unknown>,
306
+ theme: Theme,
307
+ context: CompactRenderContext,
308
+ error: boolean,
309
+ mode: ExpandedOutputMode,
310
+ ): string {
311
+ if (error) return renderPlainExpanded(result, theme, context, true, mode);
312
+ const diff = resultDetails(result).diff;
313
+ if (typeof diff === "string" && diff.length > 0) {
314
+ return limitRenderedText(renderDiff(diff, { filePath: argPath(context) }), mode, theme);
315
+ }
316
+ return renderPlainExpanded(result, theme, context, false, mode);
317
+ }
318
+
319
+ function setCompactState(context: CompactRenderContext, next: CompactState) {
320
+ const state = context.state;
321
+ const changed =
322
+ state.summary !== next.summary ||
323
+ state.error !== next.error ||
324
+ state.errorText !== next.errorText;
325
+ state.summary = next.summary;
326
+ state.error = next.error;
327
+ state.errorText = next.errorText;
328
+
329
+ // renderCall runs before renderResult. Re-render once after renderResult stores
330
+ // the final summary so the count/status can stay on the same compact line.
331
+ if (changed) queueMicrotask(() => context.invalidate());
332
+ }
333
+
334
+ function suffix(state: CompactState): string {
335
+ if (state.errorText) return ` — ${truncate(state.errorText, 100)}`;
336
+ if (state.summary) return ` (${state.summary})`;
337
+ return "";
338
+ }
339
+
340
+ function compactCall(format: (args: ToolArgs, state: CompactState) => string): CompactRenderCall {
341
+ return (args, theme, context) => {
342
+ const state = context.state;
343
+ const component =
344
+ context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
345
+ const color = state.error || context.isError ? "error" : "muted";
346
+ component.setText(theme.fg(color, format(args, state)));
347
+ return component;
348
+ };
349
+ }
350
+
351
+ const compactBashCall: CompactRenderCall = (args, theme, context) => {
352
+ const state = context.state;
353
+ const component =
354
+ context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
355
+ const command = truncate(args.command, 180);
356
+ const timeout = args.timeout !== undefined ? ` [timeout=${args.timeout}s]` : "";
357
+ const meta = `${timeout}${suffix(state)}`;
358
+
359
+ if (state.error || context.isError) {
360
+ component.setText(theme.fg("error", `→ $ ${command}${meta}`));
361
+ } else {
362
+ component.setText(
363
+ `${theme.fg("muted", "→ ")}${theme.fg("accent", "$")} ${highlightShellCommand(
364
+ command,
365
+ theme,
366
+ )}${theme.fg("dim", meta)}`,
367
+ );
368
+ }
369
+
370
+ return component;
371
+ };
372
+
373
+ function compactResult(
374
+ summarize: (
375
+ result: AgentToolResult<unknown>,
376
+ context: CompactRenderContext,
377
+ ) => string | undefined,
378
+ renderExpanded: CompactExpandedRenderer = renderPlainExpanded,
379
+ gapBeforeExpanded = true,
380
+ ): CompactRenderResult {
381
+ return (result, { expanded, isPartial }, theme, context) => {
382
+ const output = textContent(result);
383
+ const error = Boolean(context.isError);
384
+ const errorText = error ? firstUsefulLine(output) : undefined;
385
+ const summary = isPartial ? "running" : summarize(result, context);
386
+
387
+ setCompactState(context, { summary, error, errorText });
388
+
389
+ syncOutputModeWithPiToggle(Boolean(expanded));
390
+ if (outputMode === "one-line") return new Text("", 0, 0);
391
+
392
+ const mode: ExpandedOutputMode = outputMode === "full" ? "full" : "preview";
393
+ const expandedText = renderExpanded(result, theme, context, error, mode);
394
+ if (!expandedText || expandedText.trim() === "") return new Text("", 0, 0);
395
+
396
+ const status: ExpandedBoxStatus = error ? "error" : isPartial ? "pending" : "success";
397
+ const box = renderExpandedBox(expandedText, theme, status);
398
+ if (!gapBeforeExpanded) return box;
399
+
400
+ const container = new Container();
401
+ container.addChild(new Spacer(1));
402
+ container.addChild(box);
403
+ return container;
404
+ };
405
+ }
406
+
407
+ function summarizeRead(result: AgentToolResult<unknown>): string | undefined {
408
+ if (hasImage(result)) return "image";
409
+ const lines = visibleLineCount(textContent(result));
410
+ const truncated = isResultTruncated(result);
411
+ return lines > 0 ? `${plural(lines, "line")}${truncated ? ", truncated" : ""}` : undefined;
412
+ }
413
+
414
+ function summarizeBash(
415
+ result: AgentToolResult<unknown>,
416
+ context: CompactRenderContext,
417
+ ): string | undefined {
418
+ const output = textContent(result);
419
+ if (context.isError) {
420
+ const exit = output.match(/Command exited with code (\d+)/i)?.[1];
421
+ if (exit) return `exit ${exit}`;
422
+ }
423
+ const lines = visibleLineCount(output);
424
+ const truncated = isResultTruncated(result);
425
+ if (lines === 0 || output.trim() === "(no output)") return "done";
426
+ return `${plural(lines, "line")}${truncated ? ", truncated" : ""}`;
427
+ }
428
+
429
+ function summarizeEdit(result: AgentToolResult<unknown>): string | undefined {
430
+ const diff = resultDetails(result).diff;
431
+ if (typeof diff !== "string") return "done";
432
+ let additions = 0;
433
+ let removals = 0;
434
+ for (const line of diff.split("\n")) {
435
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
436
+ if (line.startsWith("-") && !line.startsWith("---")) removals++;
437
+ }
438
+ return `+${additions}/-${removals}`;
439
+ }
440
+
441
+ function summarizeWrite(): string {
442
+ return "written";
443
+ }
444
+
445
+ function summarizeCount(noun: string, many = `${noun}s`) {
446
+ return (result: AgentToolResult<unknown>): string | undefined => {
447
+ const count = visibleLineCount(textContent(result));
448
+ if (count === 0) return `0 ${many}`;
449
+ return plural(count, noun, many);
450
+ };
451
+ }
452
+
453
+ function registerCompactBuiltIn(
454
+ pi: ExtensionAPI,
455
+ name: keyof BuiltInDefinitions,
456
+ renderCall: CompactRenderCall,
457
+ renderResult: CompactRenderResult,
458
+ ) {
459
+ const initialDefinition = getBuiltIns(process.cwd())[name] as ToolDefinition;
460
+
461
+ pi.registerTool({
462
+ ...initialDefinition,
463
+ renderShell: "self",
464
+ async execute(
465
+ toolCallId: string,
466
+ params: Parameters<ToolDefinition["execute"]>[1],
467
+ signal: AbortSignal | undefined,
468
+ onUpdate: Parameters<ToolDefinition["execute"]>[3],
469
+ ctx: Parameters<ToolDefinition["execute"]>[4],
470
+ ) {
471
+ const definition = getBuiltIns(ctx.cwd)[name] as ToolDefinition;
472
+ return definition.execute(toolCallId, params, signal, onUpdate, ctx);
473
+ },
474
+ renderCall: renderCall as NonNullable<ToolDefinition["renderCall"]>,
475
+ renderResult: renderResult as NonNullable<ToolDefinition["renderResult"]>,
476
+ });
477
+ }
478
+
479
+ export function registerCompactTools(pi: ExtensionAPI) {
480
+ pi.on("session_start", async (_event, ctx) => {
481
+ observedPiExpanded = ctx.ui.getToolsExpanded();
482
+ outputMode = configuredOutputMode(observedPiExpanded);
483
+ });
484
+
485
+ registerCompactBuiltIn(
486
+ pi,
487
+ "read",
488
+ compactCall((args, state) => {
489
+ const options: string[] = [];
490
+ if (args.offset !== undefined) options.push(`offset=${args.offset}`);
491
+ if (args.limit !== undefined) options.push(`limit=${args.limit}`);
492
+ return `→ Read ${shortPath(args.path)}${
493
+ options.length ? ` [${options.join(", ")}]` : ""
494
+ }${suffix(state)}`;
495
+ }),
496
+ compactResult(summarizeRead, renderReadExpanded),
497
+ );
498
+
499
+ registerCompactBuiltIn(
500
+ pi,
501
+ "bash",
502
+ compactBashCall,
503
+ compactResult(summarizeBash, renderBashExpanded),
504
+ );
505
+
506
+ registerCompactBuiltIn(
507
+ pi,
508
+ "edit",
509
+ compactCall((args, state) => {
510
+ const edits = Array.isArray(args.edits) ? ` [${plural(args.edits.length, "edit")}]` : "";
511
+ return `→ Edit ${shortPath(args.path)}${edits}${suffix(state)}`;
512
+ }),
513
+ compactResult(summarizeEdit, renderEditExpanded),
514
+ );
515
+
516
+ registerCompactBuiltIn(
517
+ pi,
518
+ "write",
519
+ compactCall((args, state) => {
520
+ const lines =
521
+ typeof args.content === "string"
522
+ ? ` [${plural(args.content.split("\n").length, "line")}]`
523
+ : "";
524
+ return `→ Write ${shortPath(args.path)}${lines}${suffix(state)}`;
525
+ }),
526
+ compactResult(summarizeWrite, renderWriteExpanded),
527
+ );
528
+
529
+ registerCompactBuiltIn(
530
+ pi,
531
+ "find",
532
+ compactCall((args, state) => {
533
+ const limit = args.limit !== undefined ? ` [limit=${args.limit}]` : "";
534
+ return `* Glob ${quote(args.pattern)} in ${shortPath(args.path)}${limit}${suffix(state)}`;
535
+ }),
536
+ compactResult(summarizeCount("match", "matches")),
537
+ );
538
+
539
+ registerCompactBuiltIn(
540
+ pi,
541
+ "grep",
542
+ compactCall((args, state) => {
543
+ const parts: string[] = [];
544
+ if (args.glob) parts.push(`glob=${args.glob}`);
545
+ if (args.ignoreCase) parts.push("ignoreCase=true");
546
+ if (args.literal) parts.push("literal=true");
547
+ if (args.context !== undefined) parts.push(`context=${args.context}`);
548
+ if (args.limit !== undefined) parts.push(`limit=${args.limit}`);
549
+ return `* Grep ${quote(args.pattern)} in ${shortPath(args.path)}${
550
+ parts.length ? ` [${parts.join(", ")}]` : ""
551
+ }${suffix(state)}`;
552
+ }),
553
+ compactResult(summarizeCount("match", "matches")),
554
+ );
555
+
556
+ registerCompactBuiltIn(
557
+ pi,
558
+ "ls",
559
+ compactCall((args, state) => {
560
+ const limit = args.limit !== undefined ? ` [limit=${args.limit}]` : "";
561
+ return `→ List ${shortPath(args.path)}${limit}${suffix(state)}`;
562
+ }),
563
+ compactResult(summarizeCount("entry", "entries")),
564
+ );
565
+ }
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
4
 
5
5
  export type ColorSpec = string;
6
+ export type ToolOutputStyle = "compact" | "truncated" | "full";
6
7
 
7
8
  export type PolishedTuiConfig = {
8
9
  icons: {
@@ -31,6 +32,9 @@ export type PolishedTuiConfig = {
31
32
  cost: ColorSpec;
32
33
  separator: ColorSpec;
33
34
  };
35
+ tools: {
36
+ style: ToolOutputStyle;
37
+ };
34
38
  };
35
39
 
36
40
  export const configPath = join(getAgentDir(), "zentui.json");
@@ -110,6 +114,9 @@ export const defaultConfig: PolishedTuiConfig = {
110
114
  cost: "success",
111
115
  separator: "borderMuted",
112
116
  },
117
+ tools: {
118
+ style: "compact",
119
+ },
113
120
  };
114
121
 
115
122
  function isHexColor(value: string): boolean {
@@ -128,6 +135,18 @@ type ThemeLike = {
128
135
  fg(color: string, text: string): string;
129
136
  };
130
137
 
138
+ type ConfigRecord = Record<string, unknown>;
139
+
140
+ function isRecord(value: unknown): value is ConfigRecord {
141
+ return value !== null && typeof value === "object" && !Array.isArray(value);
142
+ }
143
+
144
+ function parseToolOutputStyle(value: unknown): ToolOutputStyle {
145
+ return value === "truncated" || value === "full" || value === "compact"
146
+ ? value
147
+ : defaultConfig.tools.style;
148
+ }
149
+
131
150
  export function colorize(theme: ThemeLike, color: ColorSpec, text: string): string {
132
151
  if (themeColorTokens.has(color)) {
133
152
  return theme.fg(color, text);
@@ -148,20 +167,34 @@ export function ensureConfigExists(): void {
148
167
  }
149
168
  }
150
169
 
170
+ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
171
+ const config = isRecord(parsed) ? parsed : {};
172
+ const icons = isRecord(config.icons) ? (config.icons as Partial<PolishedTuiConfig["icons"]>) : {};
173
+ const colors = isRecord(config.colors)
174
+ ? (config.colors as Partial<PolishedTuiConfig["colors"]>)
175
+ : {};
176
+ const tools = isRecord(config.tools) ? (config.tools as Partial<PolishedTuiConfig["tools"]>) : {};
177
+
178
+ return {
179
+ icons: {
180
+ ...defaultConfig.icons,
181
+ ...icons,
182
+ },
183
+ colors: {
184
+ ...defaultConfig.colors,
185
+ ...colors,
186
+ },
187
+ tools: {
188
+ ...defaultConfig.tools,
189
+ style: parseToolOutputStyle(tools.style),
190
+ },
191
+ };
192
+ }
193
+
151
194
  export function loadConfig(): PolishedTuiConfig {
152
195
  try {
153
196
  if (!existsSync(configPath)) return defaultConfig;
154
- const parsed = JSON.parse(readFileSync(configPath, "utf8")) as Partial<PolishedTuiConfig>;
155
- return {
156
- icons: {
157
- ...defaultConfig.icons,
158
- ...(parsed.icons ?? {}),
159
- },
160
- colors: {
161
- ...defaultConfig.colors,
162
- ...(parsed.colors ?? {}),
163
- },
164
- };
197
+ return mergeConfig(JSON.parse(readFileSync(configPath, "utf8")));
165
198
  } catch {
166
199
  return defaultConfig;
167
200
  }
@@ -6,13 +6,13 @@ import type {
6
6
  Theme,
7
7
  } from "@mariozechner/pi-coding-agent";
8
8
  import { type EditorTheme, type TUI, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
9
+ import { registerCompactTools } from "./compact-tools";
9
10
  import { type PolishedTuiConfig, colorize, ensureConfigExists, loadConfig } from "./config";
10
11
  import { type GitStatusSummary, emptyGitStatus, readGitStatus } from "./git";
11
12
  import { type RuntimeInfo, readRuntimeInfo } from "./runtime";
12
- import { PolishedEditor, patchUserMessageComponent } from "./ui";
13
+ import { PolishedEditor, patchUserMessageComponent, restoreUserMessageComponent } from "./ui";
13
14
 
14
15
  type FooterState = GitStatusSummary & {
15
- busy: boolean;
16
16
  modelLabel: string;
17
17
  providerLabel: string;
18
18
  contextLabel: string;
@@ -127,8 +127,9 @@ function formatCwdLabel(cwd: string, cwdIcon: string): string {
127
127
  }
128
128
 
129
129
  export default function (pi: ExtensionAPI) {
130
+ registerCompactTools(pi);
131
+
130
132
  const state: FooterState = {
131
- busy: false,
132
133
  modelLabel: "no-model",
133
134
  providerLabel: "Unknown",
134
135
  contextLabel: "--",
@@ -267,15 +268,8 @@ export default function (pi: ExtensionAPI) {
267
268
  const installEditor = (ctx: ExtensionContext) => {
268
269
  syncState(ctx);
269
270
 
270
- let currentEditor: PolishedEditor | undefined;
271
- let autocompleteFixed = false;
272
-
273
- type AutocompleteEditorInternals = {
274
- autocompleteProvider?: unknown;
275
- };
276
-
277
- const editorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
278
- const editor = new PolishedEditor(
271
+ const editorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
272
+ new PolishedEditor(
279
273
  tui,
280
274
  theme,
281
275
  keybindings,
@@ -287,22 +281,6 @@ export default function (pi: ExtensionAPI) {
287
281
  ].join(ctx.ui.theme.fg("borderMuted", " ")),
288
282
  () => pi.getThinkingLevel(),
289
283
  );
290
- currentEditor = editor;
291
-
292
- const originalHandleInput = editor.handleInput.bind(editor);
293
- editor.handleInput = (data: string) => {
294
- const editorInternals = editor as unknown as AutocompleteEditorInternals;
295
- if (!autocompleteFixed && !editorInternals.autocompleteProvider) {
296
- autocompleteFixed = true;
297
- ctx.ui.setEditorComponent(editorFactory);
298
- currentEditor?.handleInput(data);
299
- return;
300
- }
301
- originalHandleInput(data);
302
- };
303
-
304
- return editor;
305
- };
306
284
 
307
285
  ctx.ui.setEditorComponent(editorFactory);
308
286
  };
@@ -321,14 +299,16 @@ export default function (pi: ExtensionAPI) {
321
299
  installUi(ctx);
322
300
  });
323
301
 
302
+ pi.on("session_shutdown", async () => {
303
+ restoreUserMessageComponent();
304
+ });
305
+
324
306
  pi.on("agent_start", async (_event, ctx) => {
325
- state.busy = true;
326
307
  syncState(ctx);
327
308
  refresh();
328
309
  });
329
310
 
330
311
  pi.on("agent_end", async (_event, ctx) => {
331
- state.busy = false;
332
312
  syncState(ctx);
333
313
  scheduleProjectRefresh(ctx);
334
314
  refresh();
@@ -16,13 +16,22 @@ import {
16
16
  const OSC133_ZONE_START = "\x1b]133;A\x07";
17
17
  const OSC133_ZONE_END = "\x1b]133;B\x07";
18
18
  const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
19
- const originalUserMessageRender = UserMessageComponent.prototype.render;
19
+ const userMessagePatchKey = "__zentuiUserMessagePatch";
20
20
 
21
21
  type AutocompleteEditorInternals = {
22
22
  autocompleteList?: Pick<Component, "render">;
23
23
  isShowingAutocomplete?: () => boolean;
24
24
  };
25
25
 
26
+ type UserMessageRender = (this: UserMessageComponent, width: number) => string[];
27
+ type UserMessagePatchState = {
28
+ originalRender: UserMessageRender;
29
+ patchedRender: UserMessageRender;
30
+ };
31
+ type PatchableUserMessagePrototype = typeof UserMessageComponent.prototype & {
32
+ [userMessagePatchKey]?: UserMessagePatchState;
33
+ };
34
+
26
35
  let currentUiTheme: Theme | undefined;
27
36
 
28
37
  const TRUECOLOR_BACKGROUND_ANSI = /\x1b\[48;2;\d+;\d+;\d+m/g;
@@ -36,51 +45,74 @@ function stripBackgroundAnsi(text: string): string {
36
45
  .replace(SIMPLE_BACKGROUND_ANSI, "");
37
46
  }
38
47
 
39
- function fillStyledLine(
40
- content: string,
41
- width: number,
42
- background?: (text: string) => string,
43
- ): string {
48
+ function fillStyledLine(content: string, width: number): string {
44
49
  const truncated = truncateToWidth(stripBackgroundAnsi(content), width, "");
45
50
  const padWidth = Math.max(0, width - visibleWidth(truncated));
46
- const pad =
47
- padWidth > 0 ? (background ? background(" ".repeat(padWidth)) : " ".repeat(padWidth)) : "";
51
+ const pad = padWidth > 0 ? " ".repeat(padWidth) : "";
48
52
  return `${truncated}${pad}`;
49
53
  }
50
54
 
55
+ function userMessagePrototype(): PatchableUserMessagePrototype {
56
+ return UserMessageComponent.prototype as PatchableUserMessagePrototype;
57
+ }
58
+
59
+ function renderPatchedUserMessage(this: UserMessageComponent, width: number): string[] {
60
+ const originalRender = userMessagePrototype()[userMessagePatchKey]?.originalRender;
61
+ if (!currentUiTheme || !originalRender) {
62
+ return originalRender
63
+ ? originalRender.call(this, width)
64
+ : (Container.prototype.render.call(this, width) as string[]);
65
+ }
66
+
67
+ const railWidth = 2;
68
+ const innerWidth = Math.max(1, width - railWidth);
69
+ const baseLines = Container.prototype.render.call(this, innerWidth) as string[];
70
+ if (baseLines.length === 0) return baseLines;
71
+
72
+ const hasLeadingSpacer = baseLines.length > 1 && visibleWidth(baseLines[0] ?? "") === 0;
73
+ const leadingLines = hasLeadingSpacer ? [baseLines[0] ?? ""] : [];
74
+ const contentLines = hasLeadingSpacer ? baseLines.slice(1) : baseLines;
75
+ const rail = `${currentUiTheme.fg("accent", "│")}\x1b[0m `;
76
+ const border = currentUiTheme.fg("border", "─".repeat(width));
77
+ const styledLines = contentLines.map((line) => `${rail}${fillStyledLine(line, innerWidth)}`);
78
+
79
+ if (styledLines.length === 0) {
80
+ return leadingLines;
81
+ }
82
+
83
+ const framedLines = [border, ...styledLines, border];
84
+ framedLines[0] = OSC133_ZONE_START + framedLines[0];
85
+ framedLines[framedLines.length - 1] =
86
+ framedLines[framedLines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
87
+ return [...leadingLines, ...framedLines];
88
+ }
89
+
51
90
  export function patchUserMessageComponent(uiTheme: Theme): void {
52
91
  currentUiTheme = uiTheme;
53
92
 
54
- const prototype = UserMessageComponent.prototype as {
55
- render(width: number): string[];
93
+ const prototype = userMessagePrototype();
94
+ const patchState = prototype[userMessagePatchKey] ?? {
95
+ originalRender: prototype.render,
96
+ patchedRender: renderPatchedUserMessage,
56
97
  };
57
- prototype.render = function (this: UserMessageComponent, width: number): string[] {
58
- if (!currentUiTheme) {
59
- return originalUserMessageRender.call(this, width);
60
- }
98
+ patchState.patchedRender = renderPatchedUserMessage;
61
99
 
62
- const railWidth = 2;
63
- const innerWidth = Math.max(1, width - railWidth);
64
- const baseLines = Container.prototype.render.call(this, innerWidth) as string[];
65
- if (baseLines.length === 0) return baseLines;
100
+ Object.defineProperty(prototype, userMessagePatchKey, {
101
+ value: patchState,
102
+ configurable: true,
103
+ });
104
+ prototype.render = renderPatchedUserMessage;
105
+ }
66
106
 
67
- const hasLeadingSpacer = baseLines.length > 1 && visibleWidth(baseLines[0] ?? "") === 0;
68
- const leadingLines = hasLeadingSpacer ? [baseLines[0] ?? ""] : [];
69
- const contentLines = hasLeadingSpacer ? baseLines.slice(1) : baseLines;
70
- const rail = `${currentUiTheme.fg("accent", "│")}\x1b[0m `;
71
- const border = currentUiTheme.fg("border", "─".repeat(width));
72
- const styledLines = contentLines.map((line) => `${rail}${fillStyledLine(line, innerWidth)}`);
107
+ export function restoreUserMessageComponent(): void {
108
+ currentUiTheme = undefined;
73
109
 
74
- if (styledLines.length === 0) {
75
- return leadingLines;
76
- }
110
+ const prototype = userMessagePrototype();
111
+ const patchState = prototype[userMessagePatchKey];
112
+ if (!patchState || prototype.render !== patchState.patchedRender) return;
77
113
 
78
- const framedLines = [border, ...styledLines, border];
79
- framedLines[0] = OSC133_ZONE_START + framedLines[0];
80
- framedLines[framedLines.length - 1] =
81
- framedLines[framedLines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
82
- return [...leadingLines, ...framedLines];
83
- };
114
+ prototype.render = patchState.originalRender;
115
+ delete prototype[userMessagePatchKey];
84
116
  }
85
117
 
86
118
  export class PolishedEditor extends CustomEditor {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,6 +18,8 @@
18
18
  "lint": "biome check .",
19
19
  "typecheck": "tsc --noEmit",
20
20
  "test": "vitest run",
21
+ "pi:dev": "${PI_BIN:-/opt/homebrew/bin/pi} --no-extensions -e ./extensions/zentui/index.ts",
22
+ "pi:install-local": "${PI_BIN:-/opt/homebrew/bin/pi} install ./ -l",
21
23
  "fmt": "biome format --write .",
22
24
  "fmt:check": "biome format --check .",
23
25
  "verify": "npm run lint && npm run typecheck && npm run test",
@@ -38,9 +40,9 @@
38
40
  },
39
41
  "devDependencies": {
40
42
  "@biomejs/biome": "^1.9.4",
41
- "@mariozechner/pi-ai": "^0.65.2",
42
- "@mariozechner/pi-coding-agent": "^0.65.2",
43
- "@mariozechner/pi-tui": "^0.65.2",
43
+ "@mariozechner/pi-ai": "^0.73.0",
44
+ "@mariozechner/pi-coding-agent": "^0.73.0",
45
+ "@mariozechner/pi-tui": "^0.73.0",
44
46
  "@types/node": "^25.5.2",
45
47
  "typescript": "^6.0.2",
46
48
  "vitest": "^3.2.4"