@adeildo/pi-look 5.10.1 → 5.11.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.
- package/assets/preview.png +0 -0
- package/package.json +2 -2
- package/src/render/streaming.ts +52 -19
- package/src/ui/tool-frame.ts +98 -37
package/assets/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-look",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.11.1",
|
|
4
4
|
"description": "Pi's start screen, a framed editor and the footer, redrawn around the data that matters and painted only with the theme's colours.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@adeildo/pi-kit": "5.
|
|
41
|
+
"@adeildo/pi-kit": "5.11.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@earendil-works/pi-ai": "*",
|
package/src/render/streaming.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { resolve } from "node:path";
|
|
|
7
7
|
// where the model is writing, and what it has written so far on the bottom rule.
|
|
8
8
|
import { GLYPH, isObject } from "@adeildo/pi-kit";
|
|
9
9
|
import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
10
11
|
|
|
11
12
|
import { tabs } from "./format.ts";
|
|
12
13
|
|
|
@@ -19,6 +20,8 @@ export interface Stream {
|
|
|
19
20
|
export interface StreamContext {
|
|
20
21
|
cwd: string;
|
|
21
22
|
expanded: boolean;
|
|
23
|
+
/** The model is still writing the call, so the cursor shows and the last key may be cut short. */
|
|
24
|
+
writing: boolean;
|
|
22
25
|
/** Shared by every render of one call, which keeps what was read from disk. */
|
|
23
26
|
state: { streamTarget?: Target };
|
|
24
27
|
}
|
|
@@ -50,26 +53,33 @@ export function writeStream(
|
|
|
50
53
|
theme: Theme,
|
|
51
54
|
context: StreamContext,
|
|
52
55
|
): Stream | undefined {
|
|
53
|
-
|
|
56
|
+
// The model may write the content before the path, so the stream does not wait for the path.
|
|
54
57
|
const content = field(args, "content");
|
|
55
|
-
if (
|
|
58
|
+
if (content === undefined) return undefined;
|
|
59
|
+
const path = pathOf(args);
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
const
|
|
61
|
+
// A path still arriving is not read: it may name another file, or a folder, on the way.
|
|
62
|
+
const target =
|
|
63
|
+
path !== undefined && pathSettled(args, context) ? targetOf(path, context) : undefined;
|
|
64
|
+
const note = target === undefined ? "" : ` ${theme.fg("muted", replaces(target))}`;
|
|
59
65
|
const lines = content.split("\n");
|
|
60
66
|
const shown = context.expanded ? lines : lines.slice(-WRITE_TAIL);
|
|
61
67
|
const first = lines.length - shown.length;
|
|
62
68
|
const gutter = String(lines.length).length;
|
|
63
|
-
const lang = getLanguageFromPath(path);
|
|
69
|
+
const lang = path === undefined ? undefined : getLanguageFromPath(path);
|
|
64
70
|
const painted = lang
|
|
65
|
-
? highlightCode(
|
|
66
|
-
: shown.map((line) => theme.fg("toolOutput",
|
|
71
|
+
? highlightCode(shown.map(printable).join("\n"), lang)
|
|
72
|
+
: shown.map((line) => theme.fg("toolOutput", printable(line)));
|
|
67
73
|
const body = shown.map(
|
|
68
74
|
(_, index) =>
|
|
69
75
|
`${theme.fg("dim", String(first + index + 1).padStart(gutter))} ${painted[index] ?? ""}`,
|
|
70
76
|
);
|
|
71
77
|
return {
|
|
72
|
-
lines: [
|
|
78
|
+
lines: [
|
|
79
|
+
pathLine("write", path, theme) + note,
|
|
80
|
+
...above(first, gutter, theme),
|
|
81
|
+
...withCursor(body, theme, context.writing),
|
|
82
|
+
],
|
|
73
83
|
progress: [`${lines.length} line${lines.length === 1 ? "" : "s"}`, size(content)],
|
|
74
84
|
};
|
|
75
85
|
}
|
|
@@ -81,7 +91,7 @@ export function editStream(
|
|
|
81
91
|
): Stream | undefined {
|
|
82
92
|
const path = pathOf(args);
|
|
83
93
|
const edits = editsOf(args);
|
|
84
|
-
if (
|
|
94
|
+
if (edits.length === 0) return undefined;
|
|
85
95
|
|
|
86
96
|
let removed = 0;
|
|
87
97
|
let added = 0;
|
|
@@ -89,18 +99,22 @@ export function editStream(
|
|
|
89
99
|
for (const [index, edit] of edits.entries()) {
|
|
90
100
|
if (index > 0) diff.push(theme.fg("dim", GLYPH.ellipsisAcross));
|
|
91
101
|
// Only the last edit can still be arriving, so only the others have a known end.
|
|
92
|
-
const lines = editLines(edit, index < edits.length - 1);
|
|
93
|
-
for (const line of lines.before) diff.push(theme.fg("toolDiffContext", ` ${
|
|
94
|
-
for (const line of lines.out) diff.push(theme.fg("toolDiffRemoved", `-${
|
|
95
|
-
for (const line of lines.in) diff.push(theme.fg("toolDiffAdded", `+${
|
|
96
|
-
for (const line of lines.after) diff.push(theme.fg("toolDiffContext", ` ${
|
|
102
|
+
const lines = editLines(edit, !context.writing || index < edits.length - 1);
|
|
103
|
+
for (const line of lines.before) diff.push(theme.fg("toolDiffContext", ` ${printable(line)}`));
|
|
104
|
+
for (const line of lines.out) diff.push(theme.fg("toolDiffRemoved", `-${printable(line)}`));
|
|
105
|
+
for (const line of lines.in) diff.push(theme.fg("toolDiffAdded", `+${printable(line)}`));
|
|
106
|
+
for (const line of lines.after) diff.push(theme.fg("toolDiffContext", ` ${printable(line)}`));
|
|
97
107
|
removed += lines.out.length;
|
|
98
108
|
added += lines.in.length;
|
|
99
109
|
}
|
|
100
110
|
const shown = context.expanded ? diff : diff.slice(-EDIT_TAIL);
|
|
101
111
|
const hidden = diff.length - shown.length;
|
|
102
112
|
return {
|
|
103
|
-
lines: [
|
|
113
|
+
lines: [
|
|
114
|
+
pathLine("edit", path, theme),
|
|
115
|
+
...above(hidden, 0, theme),
|
|
116
|
+
...withCursor(shown, theme, context.writing),
|
|
117
|
+
],
|
|
104
118
|
progress: [...(edits.length > 1 ? [`edit ${edits.length}`] : []), `-${removed} +${added}`],
|
|
105
119
|
};
|
|
106
120
|
}
|
|
@@ -136,10 +150,29 @@ export function editLines(
|
|
|
136
150
|
}
|
|
137
151
|
|
|
138
152
|
/** The first line pi's own renderer draws, so nothing moves when it takes over. */
|
|
139
|
-
function pathLine(tool: string, path: string, theme: Theme): string {
|
|
153
|
+
function pathLine(tool: string, path: string | undefined, theme: Theme): string {
|
|
154
|
+
const title = theme.fg("toolTitle", theme.bold(tool));
|
|
155
|
+
// Pi's own stand-in for a path not written yet.
|
|
156
|
+
if (path === undefined) return `${title} ${theme.fg("toolOutput", "...")}`;
|
|
140
157
|
const home = homedir();
|
|
141
158
|
const shown = path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
142
|
-
return `${
|
|
159
|
+
return `${title} ${theme.fg("accent", printable(shown))}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A line as the terminal can print it inside the frame: a carriage return would send the cursor
|
|
164
|
+
* back over the border, and a colour code or a bell in the file would reach the terminal.
|
|
165
|
+
*/
|
|
166
|
+
function printable(line: string): string {
|
|
167
|
+
// oxlint-disable-next-line no-control-regex -- control characters are what this removes
|
|
168
|
+
return tabs(stripTerminalSequences(line).replace(/[\u0000-\u0008\u000a-\u001f\u007f]/g, ""));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A key the model has moved past is complete, and so is every key once the call is written. */
|
|
172
|
+
function pathSettled(args: unknown, context: StreamContext): boolean {
|
|
173
|
+
if (!context.writing || !isObject(args)) return true;
|
|
174
|
+
const last = Object.keys(args).at(-1);
|
|
175
|
+
return last !== "path" && last !== "file_path";
|
|
143
176
|
}
|
|
144
177
|
|
|
145
178
|
function above(hidden: number, gutter: number, theme: Theme): string[] {
|
|
@@ -150,8 +183,8 @@ function above(hidden: number, gutter: number, theme: Theme): string[] {
|
|
|
150
183
|
];
|
|
151
184
|
}
|
|
152
185
|
|
|
153
|
-
function withCursor(lines: readonly string[], theme: Theme): string[] {
|
|
154
|
-
if (lines.length === 0) return [];
|
|
186
|
+
function withCursor(lines: readonly string[], theme: Theme, writing: boolean): string[] {
|
|
187
|
+
if (!writing || lines.length === 0) return [...lines];
|
|
155
188
|
return [...lines.slice(0, -1), `${lines.at(-1)}${theme.fg("accent", CURSOR)}`];
|
|
156
189
|
}
|
|
157
190
|
|
package/src/ui/tool-frame.ts
CHANGED
|
@@ -45,6 +45,7 @@ const RESULT_PREVIEW_LINES = 10;
|
|
|
45
45
|
|
|
46
46
|
export function registerToolFrames(scope: FeatureScope): void {
|
|
47
47
|
const redraws = new Map<string, () => void>();
|
|
48
|
+
const written = new Map<string, number>();
|
|
48
49
|
// The set is asked for on every drawing of every frame, and it changes only with the setting.
|
|
49
50
|
const set = glyphsOf(() => icons.get(scope));
|
|
50
51
|
scope.events.on(FRAMED, (data) => {
|
|
@@ -53,12 +54,20 @@ export function registerToolFrames(scope: FeatureScope): void {
|
|
|
53
54
|
scope.events.on(RULING_CHANGED, (data) => {
|
|
54
55
|
if (isObject(data) && typeof data.toolCallId === "string") redraws.get(data.toolCallId)?.();
|
|
55
56
|
});
|
|
57
|
+
// Pi completes every call of a message only when the message ends, so a call the model finished
|
|
58
|
+
// before writing the next one would go on writing. The provider says when each one ends. Pi tells
|
|
59
|
+
// the extensions first and hands every call its arguments right after, which draws the frame.
|
|
60
|
+
scope.on("message_update", (event) => {
|
|
61
|
+
const update = event.assistantMessageEvent;
|
|
62
|
+
if (update.type === "toolcall_end") written.set(update.toolCall.id, Date.now());
|
|
63
|
+
});
|
|
56
64
|
|
|
57
65
|
scope.registerToolRenderer((toolName, next) =>
|
|
58
66
|
framed(scope, next(), {
|
|
59
67
|
name: toolName,
|
|
60
68
|
set: () => set().set,
|
|
61
69
|
redraws,
|
|
70
|
+
written,
|
|
62
71
|
}),
|
|
63
72
|
);
|
|
64
73
|
}
|
|
@@ -67,6 +76,8 @@ interface FrameContext {
|
|
|
67
76
|
name: string;
|
|
68
77
|
set: () => IconSet;
|
|
69
78
|
redraws: Map<string, () => void>;
|
|
79
|
+
/** When the model finished writing each call, until its frame reads it. */
|
|
80
|
+
written: Map<string, number>;
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
function framed(
|
|
@@ -95,37 +106,72 @@ function framed(
|
|
|
95
106
|
frame.redraws.set(context.toolCallId, context.invalidate);
|
|
96
107
|
const clock = clockOf(context);
|
|
97
108
|
const now = Date.now();
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
const ended = frame.written.get(context.toolCallId);
|
|
110
|
+
frame.written.delete(context.toolCallId);
|
|
111
|
+
// A call replayed from the session, or one already running, was written long ago.
|
|
112
|
+
const done =
|
|
113
|
+
ended !== undefined ||
|
|
114
|
+
context.argsComplete ||
|
|
115
|
+
context.executionStarted ||
|
|
116
|
+
!context.isPartial;
|
|
117
|
+
// Only a call seen while the model wrote it has a time it took to write.
|
|
118
|
+
if (!done) clock.writingAt ??= now;
|
|
119
|
+
else clock.writtenAt ??= ended ?? now;
|
|
100
120
|
if (context.executionStarted) clock.startedAt ??= now;
|
|
121
|
+
const writing = clock.writtenAt === undefined;
|
|
122
|
+
const ruling = rulingOf(scope.events, context.toolCallId);
|
|
123
|
+
|
|
124
|
+
// Pi hands every call of a message its arguments again on each piece the model writes, so a
|
|
125
|
+
// call that did not change keeps the frame it has, and its renderer is not asked again.
|
|
126
|
+
const inputs = [
|
|
127
|
+
args,
|
|
128
|
+
theme,
|
|
129
|
+
ruling,
|
|
130
|
+
writing,
|
|
131
|
+
context.expanded,
|
|
132
|
+
context.isPartial,
|
|
133
|
+
context.executionStarted,
|
|
134
|
+
];
|
|
135
|
+
const last = context.lastComponent;
|
|
136
|
+
if (last instanceof CallFrame && last.drawnFrom(inputs)) return last;
|
|
137
|
+
|
|
101
138
|
const stream = streamer?.(args, theme, {
|
|
102
139
|
cwd: context.cwd,
|
|
103
140
|
expanded: context.expanded,
|
|
141
|
+
writing,
|
|
104
142
|
state: context.state as object,
|
|
105
143
|
});
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
const
|
|
144
|
+
// While the model writes, pi's renderer has nothing new to show, so it is not asked. The
|
|
145
|
+
// frame pads its inside itself, so pi's renderer adds no padding of its own.
|
|
146
|
+
const streaming = writing && stream !== undefined;
|
|
147
|
+
const own = streaming
|
|
109
148
|
? undefined
|
|
110
|
-
: call(args, theme, {
|
|
149
|
+
: call(args, theme, {
|
|
150
|
+
...context,
|
|
151
|
+
argsComplete: !writing,
|
|
152
|
+
outputPad: 0,
|
|
153
|
+
lastComponent: passed(last),
|
|
154
|
+
});
|
|
111
155
|
const body = own === undefined ? drawnBody(stream?.lines ?? []) : orStream(own, stream);
|
|
112
156
|
|
|
113
|
-
const box = new CallFrame(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
157
|
+
const box = new CallFrame(
|
|
158
|
+
{
|
|
159
|
+
theme,
|
|
160
|
+
icon: toolIcon(frame.name, frame.set()),
|
|
161
|
+
label: toolLabel(frame.name),
|
|
162
|
+
body,
|
|
163
|
+
ruling,
|
|
164
|
+
expanded: context.expanded,
|
|
165
|
+
phase: phaseOf(context, writing),
|
|
166
|
+
mark: stateMark,
|
|
167
|
+
set: frame.set,
|
|
168
|
+
progress: writing ? stream?.progress : undefined,
|
|
169
|
+
clock,
|
|
170
|
+
executing: context.isPartial && context.executionStarted,
|
|
171
|
+
runningAs: RUNNING_WORDS[frame.name],
|
|
172
|
+
},
|
|
173
|
+
inputs,
|
|
174
|
+
);
|
|
129
175
|
inner.set(box, own);
|
|
130
176
|
return box;
|
|
131
177
|
},
|
|
@@ -139,6 +185,7 @@ function framed(
|
|
|
139
185
|
clock.resulted = true;
|
|
140
186
|
const body = result(shown, options, theme, {
|
|
141
187
|
...context,
|
|
188
|
+
outputPad: 0,
|
|
142
189
|
lastComponent: passed(context.lastComponent),
|
|
143
190
|
});
|
|
144
191
|
const phase: Phase = options.isPartial ? "running" : context.isError ? "failed" : "done";
|
|
@@ -177,13 +224,12 @@ const RUNNING_WORDS: Readonly<Record<string, string>> = {
|
|
|
177
224
|
* The phase the call closes with while it still has no result: writing the arguments, then waiting
|
|
178
225
|
* to run. A call with a result, live or replayed, stays open for the result half to close.
|
|
179
226
|
*/
|
|
180
|
-
function phaseOf(
|
|
181
|
-
isPartial: boolean;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}): Phase | undefined {
|
|
227
|
+
function phaseOf(
|
|
228
|
+
context: { isPartial: boolean; executionStarted: boolean },
|
|
229
|
+
writing: boolean,
|
|
230
|
+
): Phase | undefined {
|
|
185
231
|
if (!context.isPartial || context.executionStarted) return undefined;
|
|
186
|
-
return
|
|
232
|
+
return writing ? "writing" : "waiting";
|
|
187
233
|
}
|
|
188
234
|
|
|
189
235
|
function callText(args: unknown, theme: Theme, expanded: boolean): string {
|
|
@@ -245,11 +291,26 @@ interface FrameParts {
|
|
|
245
291
|
/** The top rule, the call inside it, and the phase while the call has nothing to show yet. */
|
|
246
292
|
class CallFrame implements Component {
|
|
247
293
|
#drawn?: Drawn;
|
|
248
|
-
|
|
249
|
-
|
|
294
|
+
/** Set when pi or the terminal asks for a fresh drawing, which asks the renderers again. */
|
|
295
|
+
#stale = false;
|
|
296
|
+
|
|
297
|
+
constructor(
|
|
298
|
+
private readonly parts: FrameParts & CallParts,
|
|
299
|
+
private readonly inputs: readonly unknown[],
|
|
300
|
+
) {}
|
|
301
|
+
|
|
302
|
+
/** Whether this frame was made from these inputs and nothing asked for it to be made again. */
|
|
303
|
+
drawnFrom(inputs: readonly unknown[]): boolean {
|
|
304
|
+
return (
|
|
305
|
+
!this.#stale &&
|
|
306
|
+
inputs.length === this.inputs.length &&
|
|
307
|
+
inputs.every((input, index) => input === this.inputs[index])
|
|
308
|
+
);
|
|
309
|
+
}
|
|
250
310
|
|
|
251
311
|
invalidate(): void {
|
|
252
312
|
this.#drawn = undefined;
|
|
313
|
+
this.#stale = true;
|
|
253
314
|
this.parts.body.invalidate();
|
|
254
315
|
}
|
|
255
316
|
|
|
@@ -259,7 +320,8 @@ class CallFrame implements Component {
|
|
|
259
320
|
// waits for you does while its dialog is open.
|
|
260
321
|
const running = this.parts.executing === true && clock?.resulted !== true;
|
|
261
322
|
const phase = this.parts.phase ?? (running ? "running" : undefined);
|
|
262
|
-
|
|
323
|
+
// The time is read on every drawing, so it goes on while nothing new arrives.
|
|
324
|
+
const time = clock === undefined || phase === undefined ? undefined : timeOf(phase, clock);
|
|
263
325
|
// The mark of a phase that lasts moves, so it is picked on every drawing.
|
|
264
326
|
const mark = phase === undefined ? undefined : this.parts.mark(phase, this.parts.set());
|
|
265
327
|
const body = this.parts.body.render(room(width));
|
|
@@ -292,7 +354,6 @@ interface CallParts {
|
|
|
292
354
|
phase?: Phase;
|
|
293
355
|
mark: (phase: Phase, set: IconSet) => string;
|
|
294
356
|
set: () => IconSet;
|
|
295
|
-
elapsed?: string;
|
|
296
357
|
/** What the model has written so far, while it writes. */
|
|
297
358
|
progress?: readonly string[];
|
|
298
359
|
clock?: Clock;
|
|
@@ -499,11 +560,11 @@ function ranFor(clock: Clock, running: boolean): string | undefined {
|
|
|
499
560
|
return end === undefined ? undefined : elapsed(end - clock.startedAt);
|
|
500
561
|
}
|
|
501
562
|
|
|
502
|
-
/** How long the call has
|
|
503
|
-
function
|
|
504
|
-
if (
|
|
505
|
-
const from =
|
|
506
|
-
return elapsed(Date.now() - from);
|
|
563
|
+
/** How long the call has been in the phase the call half of the frame closes with. */
|
|
564
|
+
function timeOf(phase: Phase, clock: Clock): string | undefined {
|
|
565
|
+
if (phase === "running") return ranFor(clock, true);
|
|
566
|
+
const from = phase === "waiting" ? clock.writtenAt : clock.writingAt;
|
|
567
|
+
return from === undefined ? undefined : elapsed(Date.now() - from);
|
|
507
568
|
}
|
|
508
569
|
|
|
509
570
|
function elapsed(ms: number): string {
|