@adeildo/pi-look 5.9.2 → 5.10.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/README.md +22 -8
- package/assets/preview.png +0 -0
- package/package.json +2 -2
- package/src/data/git.ts +12 -9
- package/src/data/github.ts +141 -0
- package/src/data/runner.ts +25 -0
- package/src/data/snapshot.ts +6 -3
- package/src/data/start-notes.ts +108 -0
- package/src/data/telemetry.ts +6 -1
- package/src/data/totals.ts +12 -11
- package/src/data/visits.ts +38 -0
- package/src/index.ts +147 -71
- package/src/render/fit.ts +2 -1
- package/src/render/format.ts +9 -12
- package/src/render/glyphs.ts +14 -0
- package/src/render/segments.ts +30 -31
- package/src/render/streaming.ts +217 -0
- package/src/render/tools.ts +76 -12
- package/src/settings.ts +61 -210
- package/src/ui/editor.ts +8 -2
- package/src/ui/footer.ts +64 -5
- package/src/ui/header.ts +175 -71
- package/src/ui/outputs.ts +173 -0
- package/src/ui/pi-entries.ts +185 -0
- package/src/ui/previews.ts +76 -0
- package/src/ui/queue.ts +75 -0
- package/src/ui/screen.ts +8 -0
- package/src/ui/tool-frame.ts +118 -39
package/src/ui/tool-frame.ts
CHANGED
|
@@ -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 {
|
|
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: () =>
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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,
|
|
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
|
-
|
|
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}: ${
|
|
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,
|
|
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
|
|
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
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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] =
|
|
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}
|
|
330
|
-
|
|
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(
|
|
338
|
-
|
|
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",
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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,
|
|
459
|
-
if (ruling.note) line += ` ${theme.fg("accent",
|
|
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;
|