pi-zen 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.
@@ -0,0 +1,565 @@
1
+ import {
2
+ type AgentToolResult,
3
+ type BashToolDetails,
4
+ type BashToolOptions,
5
+ createBashToolDefinition,
6
+ createEditToolDefinition,
7
+ createFindToolDefinition,
8
+ createGrepToolDefinition,
9
+ createLsToolDefinition,
10
+ createReadToolDefinition,
11
+ createWriteToolDefinition,
12
+ type EditToolDetails,
13
+ type ExtensionAPI,
14
+ type FindToolDetails,
15
+ type GrepToolDetails,
16
+ type LsToolDetails,
17
+ type ReadToolDetails,
18
+ type Theme,
19
+ type ToolRenderResultOptions,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { Box, type Component, Container, Text } from "@earendil-works/pi-tui";
22
+
23
+ import {
24
+ type BuiltinCallSlot,
25
+ type BuiltinResultSlot,
26
+ forwardCall,
27
+ type ForwardedRenderContext,
28
+ forwardResult,
29
+ } from "./builtin-render.ts";
30
+ import { type CallGrouper, formatGroupLine, type GroupLabel, type GroupSlot } from "./call-group.ts";
31
+ import { compactDiff, type DiffLine, MAX_DIFF_LINES, renderDiff } from "./edit-diff.ts";
32
+ import { displayPath } from "./display-path.ts";
33
+ import {
34
+ bashFailureSummary,
35
+ commandHead,
36
+ countMatchLines,
37
+ countPatchChanges,
38
+ countPatchHunks,
39
+ countResultLines,
40
+ firstActionableLine,
41
+ hasImage,
42
+ formatDuration,
43
+ formatEditChange,
44
+ textOf,
45
+ } from "./tool-output.ts";
46
+ import { formatToolRow, type RowDetail, type RowOutcome, type RowPalette, type RowSubject } from "./tool-row.ts";
47
+
48
+ /** Options Pi built its own tools with, so an override runs them identically. */
49
+ export type BuiltinToolOptions = {
50
+ /** Whether the read tool resizes large images. */
51
+ readonly autoResizeImages: boolean;
52
+ /** Shell setup commands prepended to every bash command. */
53
+ readonly shellCommandPrefix: string | undefined;
54
+ /** Explicit shell binary for the bash tool. */
55
+ readonly shellPath: string | undefined;
56
+ };
57
+
58
+ /** Lines of streaming output shown under an expanded row that is still running. */
59
+ const STREAMING_TAIL_LINES = 10;
60
+
61
+ /**
62
+ * Per-row state pi shares between the two render slots.
63
+ *
64
+ * `startedAt` and `endedAt` are the names pi's own bash renderer uses on this
65
+ * object. An expanded row forwards this same state to the built-in slots, so
66
+ * writing the timings here is what lets pi's expanded view report the real
67
+ * duration of a call whose collapsed row we rendered ourselves.
68
+ */
69
+ type RowMemory = {
70
+ startedAt: number | undefined;
71
+ endedAt: number | undefined;
72
+ builtinCallComponent: Component | undefined;
73
+ builtinResultComponent: Component | undefined;
74
+ groupSlot: GroupSlot | undefined;
75
+ groupCounted: boolean | undefined;
76
+ };
77
+
78
+ type RowView<TArgs> = {
79
+ readonly args: TArgs;
80
+ readonly state: RowMemory;
81
+ readonly lastComponent: Component | undefined;
82
+ readonly cwd: string;
83
+ readonly executionStarted: boolean;
84
+ readonly isPartial: boolean;
85
+ readonly expanded: boolean;
86
+ readonly isError: boolean;
87
+ };
88
+
89
+ function rowView<TArgs>(context: ForwardedRenderContext): RowView<TArgs> {
90
+ return context;
91
+ }
92
+
93
+ /** How a tool's arguments and result become one row. */
94
+ type RowSpec<TArgs, TDetails> = {
95
+ /** Present-tense action, at most five columns. */
96
+ readonly verb: string;
97
+ /** The label this tool folds under, or undefined for a tool that always keeps its own row. */
98
+ readonly group: GroupLabel | undefined;
99
+ /** What the tool acted on. */
100
+ readonly subject: (args: TArgs, cwd: string) => RowSubject;
101
+ /** Detail shown once the call settles successfully. */
102
+ readonly detail: (result: AgentToolResult<TDetails>, args: TArgs, elapsedMs: number | undefined) => RowDetail | undefined;
103
+ /** Reason shown when the call fails. Defaults to the first line of output. */
104
+ readonly failure?: (output: string) => string | undefined;
105
+ /** Lines shown under a settled row, for a tool whose change is the point of it. */
106
+ readonly body?: (result: AgentToolResult<TDetails>) => readonly DiffLine[] | undefined;
107
+ };
108
+
109
+ /** A row that renders itself at the terminal's current width. */
110
+ class ToolRowComponent implements Component {
111
+ private verb: string;
112
+ private subject: RowSubject;
113
+ private detail: RowDetail | undefined;
114
+ private outcome: RowOutcome;
115
+ private palette: RowPalette;
116
+ private body: readonly DiffLine[] | undefined;
117
+ private slot: GroupSlot | undefined;
118
+
119
+ constructor(
120
+ verb: string,
121
+ subject: RowSubject,
122
+ detail: RowDetail | undefined,
123
+ outcome: RowOutcome,
124
+ palette: RowPalette,
125
+ body: readonly DiffLine[] | undefined,
126
+ slot: GroupSlot | undefined,
127
+ ) {
128
+ this.verb = verb;
129
+ this.subject = subject;
130
+ this.detail = detail;
131
+ this.outcome = outcome;
132
+ this.palette = palette;
133
+ this.body = body;
134
+ this.slot = slot;
135
+ }
136
+
137
+ /**
138
+ * Replace what the row shows.
139
+ *
140
+ * @param verb - Present-tense action.
141
+ * @param subject - What the tool acted on.
142
+ * @param detail - Outcome detail, when there is one.
143
+ * @param outcome - Where the call has got to.
144
+ * @param palette - The active theme.
145
+ * @param body - Diff lines to show under the row, when there are any.
146
+ * @param slot - This call's place in a run of folded calls, if it is in one.
147
+ */
148
+ update(
149
+ verb: string,
150
+ subject: RowSubject,
151
+ detail: RowDetail | undefined,
152
+ outcome: RowOutcome,
153
+ palette: RowPalette,
154
+ body: readonly DiffLine[] | undefined,
155
+ slot: GroupSlot | undefined,
156
+ ): void {
157
+ this.verb = verb;
158
+ this.subject = subject;
159
+ this.detail = detail;
160
+ this.outcome = outcome;
161
+ this.palette = palette;
162
+ this.body = body;
163
+ this.slot = slot;
164
+ }
165
+
166
+ /** Required by Pi's component contract; the row holds no cached layout. */
167
+ invalidate(): void {}
168
+
169
+ /**
170
+ * Render the row, or the line that stands in for its whole run.
171
+ *
172
+ * Every choice here is made at render time rather than when the call settled,
173
+ * because pi renders a component on every frame but only asks the render slots
174
+ * again when something changes. A folded call therefore has to be able to
175
+ * change its mind: to become a count when a second call joins its run, or to
176
+ * start showing its run's line when the call that was showing it drops out.
177
+ *
178
+ * @param width - Available terminal width.
179
+ * @returns The lines to draw, which may be none.
180
+ */
181
+ render(width: number): string[] {
182
+ const slot = this.slot;
183
+ if (slot !== undefined) {
184
+ if (!slot.group.leads(slot.id)) return [];
185
+ if (slot.group.total() > 1) return [formatGroupLine(slot.group.snapshot(), width, this.palette)];
186
+ }
187
+
188
+ const row = formatToolRow(
189
+ { verb: this.verb, subject: this.subject, detail: this.detail, outcome: this.outcome },
190
+ width,
191
+ this.palette,
192
+ );
193
+ if (this.body === undefined) return [row];
194
+ return [row, ...renderDiff(this.body, width, this.palette)];
195
+ }
196
+ }
197
+
198
+ function rowComponent(
199
+ last: Component | undefined,
200
+ verb: string,
201
+ subject: RowSubject,
202
+ detail: RowDetail | undefined,
203
+ outcome: RowOutcome,
204
+ palette: RowPalette,
205
+ body?: readonly DiffLine[] | undefined,
206
+ slot?: GroupSlot | undefined,
207
+ ): Component {
208
+ if (last instanceof ToolRowComponent) {
209
+ last.update(verb, subject, detail, outcome, palette, body, slot);
210
+ return last;
211
+ }
212
+ return new ToolRowComponent(verb, subject, detail, outcome, palette, body, slot);
213
+ }
214
+
215
+ function streamingTail(output: string, theme: Theme): Component {
216
+ const lines = output.split("\n").filter((line) => line.trim() !== "");
217
+ const tail = lines.slice(-STREAMING_TAIL_LINES);
218
+ if (tail.length === 0) return new Container();
219
+ return new Text(tail.map((line) => theme.fg("dim", line)).join("\n"), 2, 0);
220
+ }
221
+
222
+ function elapsedOf(memory: RowMemory): number | undefined {
223
+ const startedAt = memory.startedAt;
224
+ const endedAt = memory.endedAt;
225
+ if (startedAt === undefined || endedAt === undefined) return undefined;
226
+ return endedAt - startedAt;
227
+ }
228
+
229
+ function boxed(component: Component): Component {
230
+ const box = new Box(1, 0);
231
+ box.addChild(component);
232
+ return box;
233
+ }
234
+
235
+ /**
236
+ * Build the Zen render slots for one built-in tool.
237
+ *
238
+ * Collapsed rows are one line; an expanded row is rendered by Pi's own built-in
239
+ * slots, so diffs, syntax highlighting, images, and truncation notices are
240
+ * unchanged. While a call is still running, an expanded row shows the tail of
241
+ * the streaming output instead, which keeps Pi's per-second refresh timers out
242
+ * of the picture.
243
+ *
244
+ * @template TArgs - The tool's argument type.
245
+ * @template TDetails - The tool's result detail type.
246
+ * @param spec - How this tool's arguments and result become a row.
247
+ * @param builtinCall - The built-in `renderCall`.
248
+ * @param builtinResult - The built-in `renderResult`.
249
+ * @param grouper - Tracks the open run of folded calls.
250
+ * @param isActive - Whether Zen currently owns the presentation.
251
+ * @returns The `renderCall` and `renderResult` slots to register.
252
+ */
253
+ function zenSlots<TArgs, TDetails>(
254
+ spec: RowSpec<TArgs, TDetails>,
255
+ builtinCall: BuiltinCallSlot<TArgs> | undefined,
256
+ builtinResult: BuiltinResultSlot<TDetails> | undefined,
257
+ grouper: CallGrouper,
258
+ isActive: () => boolean,
259
+ ) {
260
+ return {
261
+ renderCall: (args: TArgs, theme: Theme, context: ForwardedRenderContext): Component => {
262
+ const view = rowView<TArgs>(context);
263
+ const memory = view.state;
264
+ if (view.executionStarted && memory.startedAt === undefined) memory.startedAt = Date.now();
265
+ // Joined while the call is still streaming, so the run's line lands at the
266
+ // position of the first call in the run, not the first one to come back.
267
+ if (spec.group !== undefined && isActive()) memory.groupSlot ??= grouper.claim();
268
+
269
+ // Zen off, or a row the user expanded: pi renders its own call, in full.
270
+ if (!isActive() || (view.expanded && !view.isPartial)) {
271
+ const forwarded = forwardCall(builtinCall, args, theme, context, memory.builtinCallComponent);
272
+ memory.builtinCallComponent = forwarded;
273
+ return forwarded ?? new Container();
274
+ }
275
+ if (!view.isPartial) return new Container();
276
+
277
+ return rowComponent(
278
+ view.lastComponent,
279
+ spec.verb,
280
+ spec.subject(args, view.cwd),
281
+ undefined,
282
+ { kind: "running" },
283
+ theme,
284
+ );
285
+ },
286
+
287
+ renderResult: (
288
+ result: AgentToolResult<TDetails>,
289
+ options: ToolRenderResultOptions,
290
+ theme: Theme,
291
+ context: ForwardedRenderContext,
292
+ ): Component => {
293
+ const view = rowView<TArgs>(context);
294
+ const memory = view.state;
295
+
296
+ const zenOff = !isActive();
297
+ if (options.isPartial && !zenOff) {
298
+ return view.expanded ? streamingTail(textOf(result.content), theme) : new Container();
299
+ }
300
+
301
+ // A settled row must report a fixed duration, not one that grows on every
302
+ // redraw — and a call that is still streaming has not ended at all.
303
+ if (!options.isPartial) memory.endedAt ??= Date.now();
304
+
305
+ if (zenOff || view.expanded) {
306
+ const forwarded = forwardResult(builtinResult, result, options, theme, context, memory.builtinResultComponent);
307
+ memory.builtinResultComponent = forwarded;
308
+ if (forwarded === undefined) return new Container();
309
+ // The box replaces the padding pi's own shell would have put around it.
310
+ return zenOff ? forwarded : boxed(forwarded);
311
+ }
312
+
313
+ if (view.isError) {
314
+ // A failure is an outcome, so it always keeps its own row — and it closes the
315
+ // open run, so the transcript still reads in the order things happened.
316
+ grouper.close();
317
+ const slot = memory.groupSlot;
318
+ if (slot !== undefined) slot.group.leave(slot.id);
319
+ const failureOf = spec.failure ?? firstActionableLine;
320
+ const failed: RowOutcome = { kind: "failed", reason: failureOf(textOf(result.content)) };
321
+ return rowComponent(view.lastComponent, spec.verb, spec.subject(view.args, view.cwd), undefined, failed, theme);
322
+ }
323
+
324
+ if (spec.group === undefined) {
325
+ // A code edit is what the user came to see, so it interrupts the run.
326
+ grouper.close();
327
+ } else {
328
+ const slot = memory.groupSlot;
329
+ if (slot !== undefined && !memory.groupCounted) {
330
+ slot.group.add(spec.group);
331
+ memory.groupCounted = true;
332
+ }
333
+ }
334
+
335
+ const detail = spec.detail(result, view.args, elapsedOf(memory));
336
+ const settled: RowOutcome = { kind: "settled" };
337
+ const body = spec.body?.(result);
338
+ return rowComponent(
339
+ view.lastComponent,
340
+ spec.verb,
341
+ spec.subject(view.args, view.cwd),
342
+ detail,
343
+ settled,
344
+ theme,
345
+ body,
346
+ memory.groupSlot,
347
+ );
348
+ },
349
+ };
350
+ }
351
+
352
+ function quiet(text: string): RowDetail {
353
+ return { text, emphasis: "quiet" };
354
+ }
355
+
356
+ function attention(text: string): RowDetail {
357
+ return { text, emphasis: "attention" };
358
+ }
359
+
360
+ function pathSubject(path: string | undefined, cwd: string): RowSubject {
361
+ return { text: path === undefined || path === "" ? "…" : displayPath(path, cwd), keep: "end" };
362
+ }
363
+
364
+ function truncationDetail(truncated: boolean | undefined, fallback: RowDetail | undefined): RowDetail | undefined {
365
+ if (truncated !== true) return fallback;
366
+ return attention(fallback === undefined ? "truncated" : `${fallback.text} · truncated`);
367
+ }
368
+
369
+ function bashOptionsFrom(options: BuiltinToolOptions): BashToolOptions {
370
+ // Assigned field by field: Pi's options are optional properties, and this
371
+ // project forbids handing them an explicit undefined.
372
+ const bash: Pick<BashToolOptions, "commandPrefix" | "shellPath"> = {};
373
+ if (options.shellCommandPrefix !== undefined) bash.commandPrefix = options.shellCommandPrefix;
374
+ if (options.shellPath !== undefined) bash.shellPath = options.shellPath;
375
+ return bash;
376
+ }
377
+
378
+ /**
379
+ * Register one-line renderers for Pi's built-in tools.
380
+ *
381
+ * Pi resolves renderers per slot but only from a registered tool, so each
382
+ * override re-creates the built-in definition — with the same options the app
383
+ * built it from — and replaces nothing but the two render slots. Execution,
384
+ * schemas, prompt metadata, cancellation, truncation, and result details all
385
+ * stay the built-in behaviour.
386
+ *
387
+ * @param pi - The extension API.
388
+ * @param cwd - The session's working directory.
389
+ * @param options - The options Pi built its own tools with.
390
+ * @param grouper - Tracks the open run of folded calls.
391
+ * @param isActive - Whether Zen currently owns the presentation.
392
+ */
393
+ export function registerCompactTools(
394
+ pi: ExtensionAPI,
395
+ cwd: string,
396
+ options: BuiltinToolOptions,
397
+ grouper: CallGrouper,
398
+ isActive: () => boolean,
399
+ ): void {
400
+ const read = createReadToolDefinition(cwd, { autoResizeImages: options.autoResizeImages });
401
+ pi.registerTool({
402
+ ...read,
403
+ renderShell: "self",
404
+ ...zenSlots<{ path: string; offset?: number; limit?: number }, ReadToolDetails | undefined>(
405
+ {
406
+ verb: "read",
407
+ group: "read",
408
+ subject: (args, sessionCwd) => pathSubject(args.path, sessionCwd),
409
+ detail: (result, args) => {
410
+ if (hasImage(result.content)) return quiet("image");
411
+ const range = args.offset === undefined ? undefined : quiet(`from ${args.offset}`);
412
+ return truncationDetail(result.details?.truncation?.truncated, range);
413
+ },
414
+ },
415
+ read.renderCall,
416
+ read.renderResult,
417
+ grouper,
418
+ isActive,
419
+ ),
420
+ });
421
+
422
+ const bash = createBashToolDefinition(cwd, bashOptionsFrom(options));
423
+ pi.registerTool({
424
+ ...bash,
425
+ renderShell: "self",
426
+ ...zenSlots<{ command: string; timeout?: number }, BashToolDetails | undefined>(
427
+ {
428
+ verb: "run",
429
+ group: "run",
430
+ subject: (args) => ({ text: commandHead(args.command ?? ""), keep: "start" }),
431
+ failure: bashFailureSummary,
432
+ detail: (result, _args, elapsedMs) => {
433
+ const elapsed = elapsedMs === undefined ? undefined : quiet(formatDuration(elapsedMs));
434
+ return truncationDetail(result.details?.truncation?.truncated, elapsed);
435
+ },
436
+ },
437
+ bash.renderCall,
438
+ bash.renderResult,
439
+ grouper,
440
+ isActive,
441
+ ),
442
+ });
443
+
444
+ const edit = createEditToolDefinition(cwd);
445
+ pi.registerTool({
446
+ ...edit,
447
+ renderShell: "self",
448
+ ...zenSlots<{ path: string; edits: { oldText: string; newText: string }[] }, EditToolDetails | undefined>(
449
+ {
450
+ verb: "edit",
451
+ group: undefined,
452
+ subject: (args, sessionCwd) => pathSubject(args.path, sessionCwd),
453
+ body: (result) => {
454
+ const patch = result.details?.patch;
455
+ return patch === undefined ? undefined : compactDiff(patch);
456
+ },
457
+ detail: (result) => {
458
+ const patch = result.details?.patch;
459
+ if (patch === undefined) return undefined;
460
+ const counts = countPatchChanges(patch);
461
+ const change = formatEditChange(counts);
462
+ if (change === undefined) return undefined;
463
+
464
+ const hunks = countPatchHunks(patch);
465
+ const isLarge = counts.added + counts.removed > MAX_DIFF_LINES;
466
+ return quiet(isLarge && hunks > 1 ? `${change} · ${hunks} hunks` : change);
467
+ },
468
+ },
469
+ edit.renderCall,
470
+ edit.renderResult,
471
+ grouper,
472
+ isActive,
473
+ ),
474
+ });
475
+
476
+ const write = createWriteToolDefinition(cwd);
477
+ pi.registerTool({
478
+ ...write,
479
+ renderShell: "self",
480
+ ...zenSlots<{ path: string; content: string }, undefined>(
481
+ {
482
+ verb: "write",
483
+ group: undefined,
484
+ subject: (args, sessionCwd) => pathSubject(args.path, sessionCwd),
485
+ detail: (_result, args) => {
486
+ const lines = countResultLines(args.content ?? "");
487
+ return quiet(lines === 1 ? "1 line" : `${lines} lines`);
488
+ },
489
+ },
490
+ write.renderCall,
491
+ write.renderResult,
492
+ grouper,
493
+ isActive,
494
+ ),
495
+ });
496
+
497
+ const grep = createGrepToolDefinition(cwd);
498
+ pi.registerTool({
499
+ ...grep,
500
+ renderShell: "self",
501
+ ...zenSlots<{ pattern: string; path?: string; glob?: string }, GrepToolDetails | undefined>(
502
+ {
503
+ verb: "grep",
504
+ group: "grep",
505
+ subject: (args) => ({ text: args.pattern ?? "…", keep: "start" }),
506
+ detail: (result) => {
507
+ const matches = countMatchLines(textOf(result.content));
508
+ const summary = quiet(matches === 1 ? "1 match" : `${matches} matches`);
509
+ if (result.details?.matchLimitReached !== undefined) return attention(`${summary.text} · limit`);
510
+ return truncationDetail(result.details?.truncation?.truncated, summary);
511
+ },
512
+ },
513
+ grep.renderCall,
514
+ grep.renderResult,
515
+ grouper,
516
+ isActive,
517
+ ),
518
+ });
519
+
520
+ const find = createFindToolDefinition(cwd);
521
+ pi.registerTool({
522
+ ...find,
523
+ renderShell: "self",
524
+ ...zenSlots<{ pattern: string; path?: string }, FindToolDetails | undefined>(
525
+ {
526
+ verb: "find",
527
+ group: "find",
528
+ subject: (args) => ({ text: args.pattern ?? "…", keep: "start" }),
529
+ detail: (result) => {
530
+ const files = countResultLines(textOf(result.content));
531
+ const summary = quiet(files === 1 ? "1 file" : `${files} files`);
532
+ if (result.details?.resultLimitReached !== undefined) return attention(`${summary.text} · limit`);
533
+ return truncationDetail(result.details?.truncation?.truncated, summary);
534
+ },
535
+ },
536
+ find.renderCall,
537
+ find.renderResult,
538
+ grouper,
539
+ isActive,
540
+ ),
541
+ });
542
+
543
+ const ls = createLsToolDefinition(cwd);
544
+ pi.registerTool({
545
+ ...ls,
546
+ renderShell: "self",
547
+ ...zenSlots<{ path?: string }, LsToolDetails | undefined>(
548
+ {
549
+ verb: "list",
550
+ group: "list",
551
+ subject: (args, sessionCwd) => pathSubject(args.path ?? ".", sessionCwd),
552
+ detail: (result) => {
553
+ const entries = countResultLines(textOf(result.content));
554
+ const summary = quiet(entries === 1 ? "1 entry" : `${entries} entries`);
555
+ if (result.details?.entryLimitReached !== undefined) return attention(`${summary.text} · limit`);
556
+ return truncationDetail(result.details?.truncation?.truncated, summary);
557
+ },
558
+ },
559
+ ls.renderCall,
560
+ ls.renderResult,
561
+ grouper,
562
+ isActive,
563
+ ),
564
+ });
565
+ }
@@ -0,0 +1,23 @@
1
+ import { homedir } from "node:os";
2
+ import { relative, resolve } from "node:path";
3
+
4
+ /**
5
+ * Shorten a path for a tool row: paths inside the session directory become
6
+ * relative, paths inside the home directory keep a `~`, and everything else is
7
+ * left alone.
8
+ *
9
+ * @param path - Path as the model wrote it.
10
+ * @param cwd - The session's working directory.
11
+ * @returns The path to display.
12
+ */
13
+ export function displayPath(path: string, cwd: string): string {
14
+ if (path === "") return path;
15
+
16
+ const absolute = resolve(cwd, path);
17
+ const inside = relative(cwd, absolute);
18
+ if (inside !== "" && !inside.startsWith("..")) return inside;
19
+
20
+ const home = homedir();
21
+ if (home !== "" && absolute.startsWith(`${home}/`)) return `~${absolute.slice(home.length)}`;
22
+ return absolute;
23
+ }