@adeildo/pi-look 5.9.2 → 5.10.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.
@@ -1,6 +1,7 @@
1
1
  // Draws every tool call in a frame of its own: the mark and the name on the top rule, the call and
2
2
  // its result inside, and how it went on the bottom rule.
3
3
  import {
4
+ CUT,
4
5
  type CallRuling,
5
6
  type FeatureScope,
6
7
  FRAMED,
@@ -8,6 +9,8 @@ import {
8
9
  RULING_CHANGED,
9
10
  type RulingTone,
10
11
  rulingOf,
12
+ GLYPH,
13
+ LINE,
11
14
  } from "@adeildo/pi-kit";
12
15
  import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
13
16
  import {
@@ -19,7 +22,9 @@ import {
19
22
  visibleWidth,
20
23
  } from "@earendil-works/pi-tui";
21
24
 
22
- import { type IconSet, resolveIcons } from "../render/glyphs.ts";
25
+ import { tabs } from "../render/format.ts";
26
+ import { type IconSet, glyphsOf } from "../render/glyphs.ts";
27
+ import { type Stream, STREAMERS } from "../render/streaming.ts";
23
28
  import { stateMark, toolIcon, toolLabel } from "../render/tools.ts";
24
29
  import { icons } from "../settings.ts";
25
30
 
@@ -40,6 +45,8 @@ const RESULT_PREVIEW_LINES = 10;
40
45
 
41
46
  export function registerToolFrames(scope: FeatureScope): void {
42
47
  const redraws = new Map<string, () => void>();
48
+ // The set is asked for on every drawing of every frame, and it changes only with the setting.
49
+ const set = glyphsOf(() => icons.get(scope));
43
50
  scope.events.on(FRAMED, (data) => {
44
51
  if (isObject(data)) data.framed = true;
45
52
  });
@@ -50,7 +57,7 @@ export function registerToolFrames(scope: FeatureScope): void {
50
57
  scope.registerToolRenderer((toolName, next) =>
51
58
  framed(scope, next(), {
52
59
  name: toolName,
53
- set: () => resolveIcons(icons.get(scope)).set,
60
+ set: () => set().set,
54
61
  redraws,
55
62
  }),
56
63
  );
@@ -73,10 +80,12 @@ function framed(
73
80
  const result = base?.renderResult ?? genericResult;
74
81
 
75
82
  // A renderer of pi's own mutates the component it returned last time, which sits inside the
76
- // frame, so it gets that one back instead of the frame.
77
- const inner = new WeakMap<Component, Component>();
83
+ // frame, so it gets that one back instead of the frame. A frame that showed the stream instead
84
+ // holds no component of pi's.
85
+ const inner = new WeakMap<Component, Component | undefined>();
78
86
  const passed = (last: Component | undefined): Component | undefined =>
79
- last === undefined ? undefined : (inner.get(last) ?? last);
87
+ last === undefined || !inner.has(last) ? last : inner.get(last);
88
+ const streamer = STREAMERS[frame.name];
80
89
 
81
90
  return {
82
91
  ...base,
@@ -89,7 +98,17 @@ function framed(
89
98
  clock.writingAt ??= now;
90
99
  if (context.argsComplete) clock.writtenAt ??= now;
91
100
  if (context.executionStarted) clock.startedAt ??= now;
92
- const body = call(args, theme, { ...context, lastComponent: passed(context.lastComponent) });
101
+ const stream = streamer?.(args, theme, {
102
+ cwd: context.cwd,
103
+ expanded: context.expanded,
104
+ state: context.state as object,
105
+ });
106
+ const writing = stream !== undefined && !context.argsComplete;
107
+ // While the model writes, pi's renderer has nothing new to show, so it is not asked.
108
+ const own = writing
109
+ ? undefined
110
+ : call(args, theme, { ...context, lastComponent: passed(context.lastComponent) });
111
+ const body = own === undefined ? drawnBody(stream?.lines ?? []) : orStream(own, stream);
93
112
 
94
113
  const box = new CallFrame({
95
114
  theme,
@@ -102,8 +121,12 @@ function framed(
102
121
  mark: stateMark,
103
122
  set: frame.set,
104
123
  elapsed: elapsedOf(clock, context),
124
+ progress: writing ? stream.progress : undefined,
125
+ clock,
126
+ executing: context.isPartial && context.executionStarted,
127
+ runningAs: RUNNING_WORDS[frame.name],
105
128
  });
106
- inner.set(box, body);
129
+ inner.set(box, own);
107
130
  return box;
108
131
  },
109
132
  renderResult(shown, options, theme, context) {
@@ -113,6 +136,7 @@ function framed(
113
136
  // Pi draws the pictures of a result right below the frame, so the frame counts them.
114
137
  const images = shown.content.filter((block) => block.type === "image").length;
115
138
  if (!options.isPartial) clock.endedAt ??= Date.now();
139
+ clock.resulted = true;
116
140
  const body = result(shown, options, theme, {
117
141
  ...context,
118
142
  lastComponent: passed(context.lastComponent),
@@ -123,7 +147,7 @@ function framed(
123
147
  body,
124
148
  quiet: blocked,
125
149
  phase,
126
- mark: stateMark(phase, frame.set()),
150
+ set: frame.set,
127
151
  elapsed: ranFor(clock, phase === "running"),
128
152
  wrote: wroteFor(clock),
129
153
  images: images === 0 ? undefined : `${images} image${images === 1 ? "" : "s"}`,
@@ -144,6 +168,11 @@ const genericCall: NonNullable<ToolRenderers["renderCall"]> = (args, theme, cont
144
168
  const genericResult: NonNullable<ToolRenderers["renderResult"]> = (result, options, theme) =>
145
169
  new Text(resultText(result, options.expanded, theme), 0, 0);
146
170
 
171
+ /** What a running call is doing, for tools whose running means something else. */
172
+ const RUNNING_WORDS: Readonly<Record<string, string>> = {
173
+ ask_questions: "waiting for your answer",
174
+ };
175
+
147
176
  /**
148
177
  * The phase the call closes with while it still has no result: writing the arguments, then waiting
149
178
  * to run. A call with a result, live or replayed, stays open for the result half to close.
@@ -168,7 +197,7 @@ function callText(args: unknown, theme: Theme, expanded: boolean): string {
168
197
  const lines = entries.map(([key, value]) => {
169
198
  const text =
170
199
  typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
171
- return ` ${key}: ${replaceTabs(text).split("\n").join("\n ")}`;
200
+ return ` ${key}: ${tabs(text).split("\n").join("\n ")}`;
172
201
  });
173
202
  return theme.fg("muted", lines.join("\n"));
174
203
  }
@@ -208,10 +237,6 @@ function expandHint(theme: Theme): string {
208
237
  return `${theme.fg("dim", keys.join("/"))}${theme.fg("muted", " to expand")}`;
209
238
  }
210
239
 
211
- function replaceTabs(text: string): string {
212
- return text.replace(/\t/g, " ");
213
- }
214
-
215
240
  interface FrameParts {
216
241
  theme: Theme;
217
242
  body: Component;
@@ -229,9 +254,17 @@ class CallFrame implements Component {
229
254
  }
230
255
 
231
256
  render(width: number): string[] {
232
- const { theme, icon, label, ruling, expanded, phase } = this.parts;
257
+ const { theme, icon, label, ruling, expanded, clock } = this.parts;
258
+ // A running call closes too until its result draws the rest of the frame, as a call that
259
+ // waits for you does while its dialog is open.
260
+ const running = this.parts.executing === true && clock?.resulted !== true;
261
+ const phase = this.parts.phase ?? (running ? "running" : undefined);
262
+ const time = running && clock !== undefined ? ranFor(clock, true) : this.parts.elapsed;
263
+ // The mark of a phase that lasts moves, so it is picked on every drawing.
264
+ const mark = phase === undefined ? undefined : this.parts.mark(phase, this.parts.set());
233
265
  const body = this.parts.body.render(room(width));
234
- const drawn = { width, body, rest: [ruling, expanded, phase, this.parts.elapsed] };
266
+ const progress = this.parts.progress?.join("\u0000");
267
+ const drawn = { width, body, rest: [ruling, expanded, phase, time, progress, mark] };
235
268
  if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
236
269
 
237
270
  const title = `${theme.fg("toolTitle", icon)} ${theme.fg("toolTitle", theme.bold(label))}`;
@@ -240,12 +273,11 @@ class CallFrame implements Component {
240
273
  if (ruling !== undefined)
241
274
  lines.push(...inside([rulingText(ruling, theme, expanded)], width, theme));
242
275
  // The call has nothing else to show yet, so the frame closes with what it is doing.
243
- if (phase !== undefined) {
244
- const mark = this.parts.mark(phase, this.parts.set());
245
- const times = [this.parts.elapsed];
246
- lines.push(
247
- rule("bottom", phaseLabel(theme, mark, phase, times), width, theme, PHASE_COLORS[phase]),
248
- );
276
+ if (phase !== undefined && mark !== undefined) {
277
+ const times = [time, ...(this.parts.progress ?? [])];
278
+ const word = phase === "running" ? this.parts.runningAs : undefined;
279
+ const closing = phaseLabel(theme, mark, phase, times, word);
280
+ lines.push(rule("bottom", closing, width, theme, PHASE_COLORS[phase]));
249
281
  }
250
282
  this.#drawn = { ...drawn, lines };
251
283
  return lines;
@@ -261,6 +293,33 @@ interface CallParts {
261
293
  mark: (phase: Phase, set: IconSet) => string;
262
294
  set: () => IconSet;
263
295
  elapsed?: string;
296
+ /** What the model has written so far, while it writes. */
297
+ progress?: readonly string[];
298
+ clock?: Clock;
299
+ /** The call runs and its result is not final. */
300
+ executing?: boolean;
301
+ /** The word for running, when the tool's running means something else. */
302
+ runningAs?: string;
303
+ }
304
+
305
+ /** Lines that are already drawn. The frame cuts the ones too wide for it. */
306
+ function drawnBody(drawn: readonly string[]): Component {
307
+ return { render: () => [...drawn], invalidate: () => {} };
308
+ }
309
+
310
+ /**
311
+ * Pi's own body, or the stream while pi's body is only its first line, as when an edit's diff is
312
+ * still being read from the file after the model finished writing.
313
+ */
314
+ function orStream(own: Component, stream: Stream | undefined): Component {
315
+ if (stream === undefined) return own;
316
+ return {
317
+ render(width) {
318
+ const drawn = own.render(width);
319
+ return withoutBlanks(drawn).length > 1 ? drawn : [...stream.lines];
320
+ },
321
+ invalidate: () => own.invalidate(),
322
+ };
264
323
  }
265
324
 
266
325
  /** The result, behind a cut, and the bottom rule with how it went and how long it took. */
@@ -275,13 +334,15 @@ class ResultFrame implements Component {
275
334
  }
276
335
 
277
336
  render(width: number): string[] {
278
- const { theme, phase, mark, wrote, quiet, images } = this.parts;
337
+ const { theme, phase, wrote, quiet, images } = this.parts;
279
338
  const time = this.parts.elapsed;
339
+ const mark = stateMark(phase, this.parts.set());
280
340
  const body = this.parts.body.render(room(width));
281
341
  const drawn = { width, body, rest: [phase, mark, time, wrote, images, quiet] };
282
342
  if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
283
343
 
284
- const printed = quiet === true ? [] : withoutBlanks(dropTime(body));
344
+ const printed =
345
+ quiet === true ? [] : withoutBlanks(dropTime(body)).map((line) => hidden(line, theme));
285
346
  const lines = [
286
347
  // The cut opens what the call printed, and a call with nothing to print has no cut.
287
348
  ...(printed.length === 0 ? [] : [cut(width, theme), ...inside(printed, width, theme)]),
@@ -304,7 +365,7 @@ class ResultFrame implements Component {
304
365
 
305
366
  interface ResultParts {
306
367
  phase: Phase;
307
- mark: string;
368
+ set: () => IconSet;
308
369
  elapsed?: string;
309
370
  wrote?: string;
310
371
  /** How many pictures follow the frame, which pi draws outside it. */
@@ -314,28 +375,31 @@ interface ResultParts {
314
375
  }
315
376
 
316
377
  /** One rule of the frame: two corners, and what sits on the left. */
317
- function rule(
378
+ export function rule(
318
379
  edge: "top" | "bottom",
319
380
  left: string,
320
381
  width: number,
321
382
  theme: Theme,
322
383
  color: "accent" | "success" | "error" | "muted" | "border" = "border",
323
384
  ): string {
324
- const [start, end] = edge === "top" ? ["\u256d", "\u256e"] : ["\u2570", "\u256f"];
385
+ const [start, end] =
386
+ edge === "top" ? [LINE.topLeft, LINE.topRight] : [LINE.bottomLeft, LINE.bottomRight];
325
387
  const paint = (text: string) => theme.fg(color, text);
326
388
  const cell = left === "" ? "" : ` ${left} `;
327
389
  const fill = Math.max(1, width - BODY_PAD - visibleWidth(cell));
328
390
  const line =
329
- paint(`${start}\u2500`) + cell + paint("\u2500".repeat(fill)) + paint(`\u2500${end}`);
330
- return truncateToWidth(line, width, "");
391
+ paint(`${start}${LINE.h}`) + cell + paint(LINE.h.repeat(fill)) + paint(`${LINE.h}${end}`);
392
+ // A rule with room to fill is the width already; only a label that filled the row needs cutting.
393
+ return fill === 1 ? truncateToWidth(line, width, "") : line;
331
394
  }
332
395
 
333
396
  /** The line that ends the call and starts what it printed. */
334
397
  function cut(width: number, theme: Theme): string {
335
398
  const paint = (text: string) => theme.fg("border", text);
336
399
  const fill = Math.max(1, width - BODY_PAD);
337
- const line = `${paint("\u251c")}${paint("\u2500".repeat(fill + 2))}${paint("\u2524")}`;
338
- return truncateToWidth(line, width, "");
400
+ const line = `${paint(LINE.teeLeft)}${paint(LINE.h.repeat(fill + 2))}${paint(LINE.teeRight)}`;
401
+ // Room for the fill means the cut is the width already.
402
+ return fill === 1 ? truncateToWidth(line, width, "") : line;
339
403
  }
340
404
 
341
405
  /** The bottom rule: what the call is doing, how long it ran, and how long the model took to write it. */
@@ -344,13 +408,12 @@ function phaseLabel(
344
408
  mark: string,
345
409
  phase: Phase,
346
410
  times: readonly (string | undefined)[],
411
+ word: string = phase,
347
412
  ): string {
348
413
  const cells = times
349
414
  .filter((time) => time !== undefined)
350
415
  .map((time) => theme.fg("dim", time ?? ""));
351
- return [`${theme.fg(PHASE_COLORS[phase], mark)} ${theme.fg("muted", phase)}`, ...cells].join(
352
- " ",
353
- );
416
+ return [`${theme.fg(PHASE_COLORS[phase], mark)} ${theme.fg("muted", word)}`, ...cells].join(" ");
354
417
  }
355
418
 
356
419
  function inside(lines: readonly string[], width: number, theme: Theme): string[] {
@@ -358,9 +421,9 @@ function inside(lines: readonly string[], width: number, theme: Theme): string[]
358
421
  const inner = room(width);
359
422
  return trim(lines).map((line) => {
360
423
  // Measuring is cheap, and cutting a line that already fits is not.
361
- const text = visibleWidth(line) > inner ? truncateToWidth(line, inner, "...", true) : line;
424
+ const text = visibleWidth(line) > inner ? truncateToWidth(line, inner, CUT, true) : line;
362
425
  const pad = " ".repeat(Math.max(0, inner - visibleWidth(text)));
363
- return `${paint("\u2502")} ${text}${pad} ${paint("\u2502")}`;
426
+ return `${paint(LINE.v)} ${text}${pad} ${paint(LINE.v)}`;
364
427
  });
365
428
  }
366
429
 
@@ -378,13 +441,27 @@ function unchanged(drawn: Drawn | undefined, next: Omit<Drawn, "lines">): drawn
378
441
  if (drawn.body.length !== next.body.length || drawn.rest.length !== next.rest.length)
379
442
  return false;
380
443
  if (drawn.rest.some((part, index) => part !== next.rest[index])) return false;
381
- return drawn.body.every((line, index) => line === next.body[index]);
444
+ // A renderer that kept its lines hands back the same array, which saves comparing a long output.
445
+ return drawn.body === next.body || drawn.body.every((line, index) => line === next.body[index]);
382
446
  }
383
447
 
384
448
  function room(width: number): number {
385
449
  return Math.max(1, width - BODY_PAD);
386
450
  }
387
451
 
452
+ /** Pi's note on the lines it cut from an output, as `... (240 earlier lines, ctrl+o to expand)`. */
453
+ const HIDDEN = /^\.\.\. \((\d+) (earlier|more) lines, (.+?) to expand\)$/u;
454
+
455
+ /** Pi's note on cut lines, in the frame's words: which way they are, how many, and the key. */
456
+ export function hidden(line: string, theme: Theme): string {
457
+ const match = HIDDEN.exec(stripTerminalSequences(line).trim());
458
+ if (match === null) return line;
459
+ const [, count, side, key] = match;
460
+ const where = side === "earlier" ? "above" : "below";
461
+ const lines = theme.fg("muted", `${count} lines ${where}`);
462
+ return `${theme.fg("dim", GLYPH.ellipsisUp)} ${lines} ${theme.fg("dim", key ?? "")}`;
463
+ }
464
+
388
465
  /** Pi prints the time of a call itself, and the frame says it on the bottom rule instead. */
389
466
  function dropTime(lines: readonly string[]): string[] {
390
467
  const at = lines.findLastIndex((line) => !isBlank(line));
@@ -401,6 +478,8 @@ interface Clock {
401
478
  /** When the call started running, and when it answered. */
402
479
  startedAt?: number;
403
480
  endedAt?: number;
481
+ /** Pi drew the result half, which closes the frame from then on. */
482
+ resulted?: boolean;
404
483
  }
405
484
 
406
485
  /** Pi shares one state object between the two halves of a call. */
@@ -448,15 +527,15 @@ function withoutBlanks(lines: readonly string[]): string[] {
448
527
  return lines.filter((line) => !isBlank(line));
449
528
  }
450
529
 
451
- function isBlank(line: string | undefined): boolean {
530
+ export function isBlank(line: string | undefined): boolean {
452
531
  // A line of nothing but colour codes reads as blank, and pi's renderers end their parts with one.
453
532
  return stripTerminalSequences(line ?? "").trim() === "";
454
533
  }
455
534
 
456
535
  export function rulingText(ruling: CallRuling, theme: Theme, expanded: boolean): string {
457
536
  const color = PHASE_COLORS[stateOf(ruling.tone)];
458
- let line = `${theme.fg(color, "\u25c8")} ${theme.fg(color, ruling.head)}`;
459
- if (ruling.note) line += ` ${theme.fg("accent", "\u203a")} ${ruling.note}`;
537
+ let line = `${theme.fg(color, GLYPH.judge)} ${theme.fg(color, ruling.head)}`;
538
+ if (ruling.note) line += ` ${theme.fg("accent", GLYPH.chevron)} ${ruling.note}`;
460
539
  if (ruling.why) line += ` ${theme.fg("muted", ruling.why)}`;
461
540
  if (expanded && ruling.detail) line += ` ${theme.fg("dim", `(${ruling.detail})`)}`;
462
541
  return line;