@adeildo/pi-look 5.1.0 → 5.3.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 +46 -36
- package/assets/preview.png +0 -0
- package/package.json +2 -2
- package/src/data/snapshot.ts +5 -0
- package/src/index.ts +8 -48
- package/src/render/format.ts +1 -0
- package/src/render/segments.ts +43 -4
- package/src/render/tools.ts +105 -0
- package/src/settings.ts +10 -42
- package/src/ui/footer.ts +1 -1
- package/src/ui/screen.ts +8 -1
- package/src/ui/tool-frame.ts +474 -0
- package/src/desktop/palette.ts +0 -193
- package/src/desktop/sync.ts +0 -79
package/src/ui/screen.ts
CHANGED
|
@@ -6,7 +6,14 @@ import type { Paint } from "../render/paint.ts";
|
|
|
6
6
|
import { renderSegments, type SegmentId, type SegmentOptions } from "../render/segments.ts";
|
|
7
7
|
import type { CursorStyle } from "../settings.ts";
|
|
8
8
|
|
|
9
|
-
export type SlotName =
|
|
9
|
+
export type SlotName =
|
|
10
|
+
| "strip"
|
|
11
|
+
| "topLeft"
|
|
12
|
+
| "topRight"
|
|
13
|
+
| "bottomLeft"
|
|
14
|
+
| "bottomRight"
|
|
15
|
+
| "below"
|
|
16
|
+
| "belowRight";
|
|
10
17
|
|
|
11
18
|
export interface Screen {
|
|
12
19
|
snapshot(): Snapshot;
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// Draws every tool call in a frame of its own: the mark and the name on the top rule, the call and
|
|
2
|
+
// its result inside, and how it went on the bottom rule.
|
|
3
|
+
import {
|
|
4
|
+
type CallRuling,
|
|
5
|
+
type FeatureScope,
|
|
6
|
+
FRAMED,
|
|
7
|
+
isObject,
|
|
8
|
+
RULING_CHANGED,
|
|
9
|
+
type RulingTone,
|
|
10
|
+
rulingOf,
|
|
11
|
+
} from "@adeildo/pi-kit";
|
|
12
|
+
import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
type Component,
|
|
15
|
+
getKeybindings,
|
|
16
|
+
stripTerminalSequences,
|
|
17
|
+
Text,
|
|
18
|
+
truncateToWidth,
|
|
19
|
+
visibleWidth,
|
|
20
|
+
} from "@earendil-works/pi-tui";
|
|
21
|
+
|
|
22
|
+
import { type IconSet, resolveIcons } from "../render/glyphs.ts";
|
|
23
|
+
import { stateMark, toolIcon, toolLabel } from "../render/tools.ts";
|
|
24
|
+
import { icons } from "../settings.ts";
|
|
25
|
+
|
|
26
|
+
/** What the call is doing, which is what the bottom rule says while it has no result yet. */
|
|
27
|
+
type Phase = "writing" | "waiting" | "running" | "done" | "failed";
|
|
28
|
+
|
|
29
|
+
const PHASE_COLORS = {
|
|
30
|
+
writing: "muted",
|
|
31
|
+
waiting: "accent",
|
|
32
|
+
running: "accent",
|
|
33
|
+
done: "success",
|
|
34
|
+
failed: "error",
|
|
35
|
+
} as const;
|
|
36
|
+
const BODY_PAD = 4;
|
|
37
|
+
const COLLAPSED_ARGS_CHARS = 100;
|
|
38
|
+
/** How many lines of a result the frame prints before telling you the rest is there. */
|
|
39
|
+
const RESULT_PREVIEW_LINES = 10;
|
|
40
|
+
|
|
41
|
+
export function registerToolFrames(scope: FeatureScope): void {
|
|
42
|
+
const redraws = new Map<string, () => void>();
|
|
43
|
+
scope.events.on(FRAMED, (data) => {
|
|
44
|
+
if (isObject(data)) data.framed = true;
|
|
45
|
+
});
|
|
46
|
+
scope.events.on(RULING_CHANGED, (data) => {
|
|
47
|
+
if (isObject(data) && typeof data.toolCallId === "string") redraws.get(data.toolCallId)?.();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
scope.registerToolRenderer((toolName, next) =>
|
|
51
|
+
framed(scope, next(), {
|
|
52
|
+
name: toolName,
|
|
53
|
+
set: () => resolveIcons(icons.get(scope)).set,
|
|
54
|
+
redraws,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface FrameContext {
|
|
60
|
+
name: string;
|
|
61
|
+
set: () => IconSet;
|
|
62
|
+
redraws: Map<string, () => void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function framed(
|
|
66
|
+
scope: FeatureScope,
|
|
67
|
+
base: ToolRenderers | undefined,
|
|
68
|
+
frame: FrameContext,
|
|
69
|
+
): ToolRenderers {
|
|
70
|
+
// A tool may bring renderers of its own, one of them, or none: the frame wraps whichever half
|
|
71
|
+
// exists, and the arguments or the text of the result stand in for the rest.
|
|
72
|
+
const call = base?.renderCall ?? genericCall;
|
|
73
|
+
const result = base?.renderResult ?? genericResult;
|
|
74
|
+
|
|
75
|
+
// 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>();
|
|
78
|
+
const passed = (last: Component | undefined): Component | undefined =>
|
|
79
|
+
last === undefined ? undefined : (inner.get(last) ?? last);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...base,
|
|
83
|
+
// The frame is the shell, so pi does not wrap it in a box of its own.
|
|
84
|
+
renderShell: "self",
|
|
85
|
+
renderCall(args, theme, context) {
|
|
86
|
+
frame.redraws.set(context.toolCallId, context.invalidate);
|
|
87
|
+
const clock = clockOf(context);
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
clock.writingAt ??= now;
|
|
90
|
+
if (context.argsComplete) clock.writtenAt ??= now;
|
|
91
|
+
if (context.executionStarted) clock.startedAt ??= now;
|
|
92
|
+
const body = call(args, theme, { ...context, lastComponent: passed(context.lastComponent) });
|
|
93
|
+
|
|
94
|
+
const box = new CallFrame({
|
|
95
|
+
theme,
|
|
96
|
+
icon: toolIcon(frame.name, frame.set()),
|
|
97
|
+
label: toolLabel(frame.name),
|
|
98
|
+
body,
|
|
99
|
+
ruling: rulingOf(scope.events, context.toolCallId),
|
|
100
|
+
expanded: context.expanded,
|
|
101
|
+
phase: phaseOf(context),
|
|
102
|
+
mark: stateMark,
|
|
103
|
+
set: frame.set,
|
|
104
|
+
elapsed: elapsedOf(clock, context),
|
|
105
|
+
});
|
|
106
|
+
inner.set(box, body);
|
|
107
|
+
return box;
|
|
108
|
+
},
|
|
109
|
+
renderResult(shown, options, theme, context) {
|
|
110
|
+
const clock = clockOf(context);
|
|
111
|
+
// A blocked call prints nothing but the reason the line above already gives.
|
|
112
|
+
const blocked = rulingOf(scope.events, context.toolCallId)?.blocked === true;
|
|
113
|
+
// Pi draws the pictures of a result right below the frame, so the frame counts them.
|
|
114
|
+
const images = shown.content.filter((block) => block.type === "image").length;
|
|
115
|
+
if (!options.isPartial) clock.endedAt ??= Date.now();
|
|
116
|
+
const body = result(shown, options, theme, {
|
|
117
|
+
...context,
|
|
118
|
+
lastComponent: passed(context.lastComponent),
|
|
119
|
+
});
|
|
120
|
+
const phase: Phase = options.isPartial ? "running" : context.isError ? "failed" : "done";
|
|
121
|
+
const box = new ResultFrame({
|
|
122
|
+
theme,
|
|
123
|
+
body,
|
|
124
|
+
quiet: blocked,
|
|
125
|
+
phase,
|
|
126
|
+
mark: stateMark(phase, frame.set()),
|
|
127
|
+
elapsed: ranFor(clock, phase === "running"),
|
|
128
|
+
wrote: wroteFor(clock),
|
|
129
|
+
images: images === 0 ? undefined : `${images} image${images === 1 ? "" : "s"}`,
|
|
130
|
+
});
|
|
131
|
+
inner.set(box, body);
|
|
132
|
+
return box;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* The stand-in for a tool that brings no renderer of its own: the arguments, under the top rule
|
|
139
|
+
* that already names the tool, and the text of the result.
|
|
140
|
+
*/
|
|
141
|
+
const genericCall: NonNullable<ToolRenderers["renderCall"]> = (args, theme, context) =>
|
|
142
|
+
new Text(callText(args, theme, context.expanded), 0, 0);
|
|
143
|
+
|
|
144
|
+
const genericResult: NonNullable<ToolRenderers["renderResult"]> = (result, options, theme) =>
|
|
145
|
+
new Text(resultText(result, options.expanded, theme), 0, 0);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The phase the call closes with while it still has no result: writing the arguments, then waiting
|
|
149
|
+
* to run. A call with a result, live or replayed, stays open for the result half to close.
|
|
150
|
+
*/
|
|
151
|
+
function phaseOf(context: {
|
|
152
|
+
isPartial: boolean;
|
|
153
|
+
executionStarted: boolean;
|
|
154
|
+
argsComplete: boolean;
|
|
155
|
+
}): Phase | undefined {
|
|
156
|
+
if (!context.isPartial || context.executionStarted) return undefined;
|
|
157
|
+
return context.argsComplete ? "waiting" : "writing";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function callText(args: unknown, theme: Theme, expanded: boolean): string {
|
|
161
|
+
if (args === null || args === undefined) return "";
|
|
162
|
+
|
|
163
|
+
const entries: [string, unknown][] =
|
|
164
|
+
typeof args === "object" && !Array.isArray(args) ? Object.entries(args) : [["args", args]];
|
|
165
|
+
if (entries.length === 0) return "";
|
|
166
|
+
|
|
167
|
+
if (expanded) {
|
|
168
|
+
const lines = entries.map(([key, value]) => {
|
|
169
|
+
const text =
|
|
170
|
+
typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
|
|
171
|
+
return ` ${key}: ${replaceTabs(text).split("\n").join("\n ")}`;
|
|
172
|
+
});
|
|
173
|
+
return theme.fg("muted", lines.join("\n"));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const pairs = entries
|
|
177
|
+
.map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`)
|
|
178
|
+
.join(" ");
|
|
179
|
+
const preview =
|
|
180
|
+
pairs.length > COLLAPSED_ARGS_CHARS ? `${pairs.slice(0, COLLAPSED_ARGS_CHARS - 3)}...` : pairs;
|
|
181
|
+
return theme.fg("muted", preview);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resultText(
|
|
185
|
+
result: { content: readonly { type: string; text?: string }[] },
|
|
186
|
+
expanded: boolean,
|
|
187
|
+
theme: Theme,
|
|
188
|
+
): string {
|
|
189
|
+
const text = result.content
|
|
190
|
+
.filter((block) => block.type === "text")
|
|
191
|
+
.map((block) => stripTerminalSequences(block.text ?? "").replace(/\r/g, ""))
|
|
192
|
+
.join("\n");
|
|
193
|
+
if (text === "") return "";
|
|
194
|
+
|
|
195
|
+
const lines = text.split("\n");
|
|
196
|
+
const shown = expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES);
|
|
197
|
+
const printed = shown.map((line) => theme.fg("toolOutput", line));
|
|
198
|
+
if (shown.length < lines.length)
|
|
199
|
+
printed.push(
|
|
200
|
+
theme.fg("muted", `... (${lines.length - shown.length} more lines, ${expandHint(theme)})`),
|
|
201
|
+
);
|
|
202
|
+
return printed.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function expandHint(theme: Theme): string {
|
|
206
|
+
const keys = getKeybindings().getKeys("app.tools.expand");
|
|
207
|
+
if (keys.length === 0) return theme.fg("muted", "expand");
|
|
208
|
+
return `${theme.fg("dim", keys.join("/"))}${theme.fg("muted", " to expand")}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function replaceTabs(text: string): string {
|
|
212
|
+
return text.replace(/\t/g, " ");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface FrameParts {
|
|
216
|
+
theme: Theme;
|
|
217
|
+
body: Component;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The top rule, the call inside it, and the phase while the call has nothing to show yet. */
|
|
221
|
+
class CallFrame implements Component {
|
|
222
|
+
#drawn?: Drawn;
|
|
223
|
+
|
|
224
|
+
constructor(private readonly parts: FrameParts & CallParts) {}
|
|
225
|
+
|
|
226
|
+
invalidate(): void {
|
|
227
|
+
this.#drawn = undefined;
|
|
228
|
+
this.parts.body.invalidate();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
render(width: number): string[] {
|
|
232
|
+
const { theme, icon, label, ruling, expanded, phase } = this.parts;
|
|
233
|
+
const body = this.parts.body.render(room(width));
|
|
234
|
+
const drawn = { width, body, rest: [ruling, expanded, phase, this.parts.elapsed] };
|
|
235
|
+
if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
|
|
236
|
+
|
|
237
|
+
const title = `${theme.fg("toolTitle", icon)} ${theme.fg("toolTitle", theme.bold(label))}`;
|
|
238
|
+
const lines = [rule("top", title, width, theme), ...inside(withoutBlanks(body), width, theme)];
|
|
239
|
+
// The decision is about the call, so it stays with the call.
|
|
240
|
+
if (ruling !== undefined)
|
|
241
|
+
lines.push(...inside([rulingText(ruling, theme, expanded)], width, theme));
|
|
242
|
+
// 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
|
+
);
|
|
249
|
+
}
|
|
250
|
+
this.#drawn = { ...drawn, lines };
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface CallParts {
|
|
256
|
+
icon: string;
|
|
257
|
+
label: string;
|
|
258
|
+
ruling?: CallRuling;
|
|
259
|
+
expanded: boolean;
|
|
260
|
+
phase?: Phase;
|
|
261
|
+
mark: (phase: Phase, set: IconSet) => string;
|
|
262
|
+
set: () => IconSet;
|
|
263
|
+
elapsed?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The result, behind a cut, and the bottom rule with how it went and how long it took. */
|
|
267
|
+
class ResultFrame implements Component {
|
|
268
|
+
#drawn?: Drawn;
|
|
269
|
+
|
|
270
|
+
constructor(private readonly parts: FrameParts & ResultParts) {}
|
|
271
|
+
|
|
272
|
+
invalidate(): void {
|
|
273
|
+
this.#drawn = undefined;
|
|
274
|
+
this.parts.body.invalidate();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
render(width: number): string[] {
|
|
278
|
+
const { theme, phase, mark, wrote, quiet, images } = this.parts;
|
|
279
|
+
const time = this.parts.elapsed;
|
|
280
|
+
const body = this.parts.body.render(room(width));
|
|
281
|
+
const drawn = { width, body, rest: [phase, mark, time, wrote, images, quiet] };
|
|
282
|
+
if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
|
|
283
|
+
|
|
284
|
+
const printed = quiet === true ? [] : withoutBlanks(dropTime(body));
|
|
285
|
+
const lines = [
|
|
286
|
+
// The cut opens what the call printed, and a call with nothing to print has no cut.
|
|
287
|
+
...(printed.length === 0 ? [] : [cut(width, theme), ...inside(printed, width, theme)]),
|
|
288
|
+
rule(
|
|
289
|
+
"bottom",
|
|
290
|
+
phaseLabel(theme, mark, phase, [
|
|
291
|
+
time,
|
|
292
|
+
wrote === undefined ? undefined : `wrote ${wrote}`,
|
|
293
|
+
images === undefined ? undefined : `with ${images}`,
|
|
294
|
+
]),
|
|
295
|
+
width,
|
|
296
|
+
theme,
|
|
297
|
+
PHASE_COLORS[phase],
|
|
298
|
+
),
|
|
299
|
+
];
|
|
300
|
+
this.#drawn = { ...drawn, lines };
|
|
301
|
+
return lines;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
interface ResultParts {
|
|
306
|
+
phase: Phase;
|
|
307
|
+
mark: string;
|
|
308
|
+
elapsed?: string;
|
|
309
|
+
wrote?: string;
|
|
310
|
+
/** How many pictures follow the frame, which pi draws outside it. */
|
|
311
|
+
images?: string;
|
|
312
|
+
/** Nothing worth printing under the line, like a call the gate blocked. */
|
|
313
|
+
quiet?: boolean;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** One rule of the frame: two corners, and what sits on the left. */
|
|
317
|
+
function rule(
|
|
318
|
+
edge: "top" | "bottom",
|
|
319
|
+
left: string,
|
|
320
|
+
width: number,
|
|
321
|
+
theme: Theme,
|
|
322
|
+
color: "accent" | "success" | "error" | "muted" | "border" = "border",
|
|
323
|
+
): string {
|
|
324
|
+
const [start, end] = edge === "top" ? ["\u256d", "\u256e"] : ["\u2570", "\u256f"];
|
|
325
|
+
const paint = (text: string) => theme.fg(color, text);
|
|
326
|
+
const cell = left === "" ? "" : ` ${left} `;
|
|
327
|
+
const fill = Math.max(1, width - BODY_PAD - visibleWidth(cell));
|
|
328
|
+
const line =
|
|
329
|
+
paint(`${start}\u2500`) + cell + paint("\u2500".repeat(fill)) + paint(`\u2500${end}`);
|
|
330
|
+
return truncateToWidth(line, width, "");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The line that ends the call and starts what it printed. */
|
|
334
|
+
function cut(width: number, theme: Theme): string {
|
|
335
|
+
const paint = (text: string) => theme.fg("border", text);
|
|
336
|
+
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, "");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The bottom rule: what the call is doing, how long it ran, and how long the model took to write it. */
|
|
342
|
+
function phaseLabel(
|
|
343
|
+
theme: Theme,
|
|
344
|
+
mark: string,
|
|
345
|
+
phase: Phase,
|
|
346
|
+
times: readonly (string | undefined)[],
|
|
347
|
+
): string {
|
|
348
|
+
const cells = times
|
|
349
|
+
.filter((time) => time !== undefined)
|
|
350
|
+
.map((time) => theme.fg("dim", time ?? ""));
|
|
351
|
+
return [`${theme.fg(PHASE_COLORS[phase], mark)} ${theme.fg("muted", phase)}`, ...cells].join(
|
|
352
|
+
" ",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function inside(lines: readonly string[], width: number, theme: Theme): string[] {
|
|
357
|
+
const paint = (text: string) => theme.fg("border", text);
|
|
358
|
+
const inner = room(width);
|
|
359
|
+
return trim(lines).map((line) => {
|
|
360
|
+
// Measuring is cheap, and cutting a line that already fits is not.
|
|
361
|
+
const text = visibleWidth(line) > inner ? truncateToWidth(line, inner, "\u2026", true) : line;
|
|
362
|
+
const pad = " ".repeat(Math.max(0, inner - visibleWidth(text)));
|
|
363
|
+
return `${paint("\u2502")} ${text}${pad} ${paint("\u2502")}`;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
interface Drawn {
|
|
368
|
+
width: number;
|
|
369
|
+
body: readonly string[];
|
|
370
|
+
/** What the frame drew around the body: the ruling, the phase, the times. */
|
|
371
|
+
rest: readonly unknown[];
|
|
372
|
+
lines: string[];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Whether the frame was already drawn for this body and this state, which keeps typing cheap. */
|
|
376
|
+
function unchanged(drawn: Drawn | undefined, next: Omit<Drawn, "lines">): drawn is Drawn {
|
|
377
|
+
if (drawn === undefined || drawn.width !== next.width) return false;
|
|
378
|
+
if (drawn.body.length !== next.body.length || drawn.rest.length !== next.rest.length)
|
|
379
|
+
return false;
|
|
380
|
+
if (drawn.rest.some((part, index) => part !== next.rest[index])) return false;
|
|
381
|
+
return drawn.body.every((line, index) => line === next.body[index]);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function room(width: number): number {
|
|
385
|
+
return Math.max(1, width - BODY_PAD);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Pi prints the time of a call itself, and the frame says it on the bottom rule instead. */
|
|
389
|
+
function dropTime(lines: readonly string[]): string[] {
|
|
390
|
+
const at = lines.findLastIndex((line) => !isBlank(line));
|
|
391
|
+
if (at === -1) return [...lines];
|
|
392
|
+
if (!/^(Took|Elapsed) [\d.]/u.test(stripTerminalSequences(lines[at] ?? "").trim()))
|
|
393
|
+
return [...lines];
|
|
394
|
+
return lines.slice(0, at);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface Clock {
|
|
398
|
+
/** When the model started writing the call, and when it finished. */
|
|
399
|
+
writingAt?: number;
|
|
400
|
+
writtenAt?: number;
|
|
401
|
+
/** When the call started running, and when it answered. */
|
|
402
|
+
startedAt?: number;
|
|
403
|
+
endedAt?: number;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Pi shares one state object between the two halves of a call. */
|
|
407
|
+
function clockOf(context: { state: unknown }): Clock {
|
|
408
|
+
return context.state as Clock;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** How long the model took to write the call. */
|
|
412
|
+
function wroteFor(clock: Clock): string | undefined {
|
|
413
|
+
if (clock.writingAt === undefined || clock.writtenAt === undefined) return undefined;
|
|
414
|
+
return elapsed(clock.writtenAt - clock.writingAt);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function ranFor(clock: Clock, running: boolean): string | undefined {
|
|
418
|
+
if (clock.startedAt === undefined) return undefined;
|
|
419
|
+
const end = clock.endedAt ?? (running ? Date.now() : undefined);
|
|
420
|
+
return end === undefined ? undefined : elapsed(end - clock.startedAt);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** How long the call has spent waiting to run, which only the call half of the frame asks for. */
|
|
424
|
+
function elapsedOf(clock: Clock, context: { argsComplete: boolean }): string | undefined {
|
|
425
|
+
if (clock.writingAt === undefined) return undefined;
|
|
426
|
+
const from = context.argsComplete ? (clock.writtenAt ?? clock.writingAt) : clock.writingAt;
|
|
427
|
+
return elapsed(Date.now() - from);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function elapsed(ms: number): string {
|
|
431
|
+
const seconds = ms / 1000;
|
|
432
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
433
|
+
const total = Math.floor(seconds);
|
|
434
|
+
if (total < 3600) return `${Math.floor(total / 60)}m ${total % 60}s`;
|
|
435
|
+
return `${Math.floor(total / 3600)}h ${Math.floor((total % 3600) / 60)}m`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Pi's renderers pad their part with blank lines, which the frame does not need. */
|
|
439
|
+
function trim(lines: readonly string[]): string[] {
|
|
440
|
+
let [from, to] = [0, lines.length];
|
|
441
|
+
while (from < to && isBlank(lines[from])) from++;
|
|
442
|
+
while (to > from && isBlank(lines[to - 1])) to--;
|
|
443
|
+
return lines.slice(from, to);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A renderer that separates its parts with a blank line leaves a gap the frame does not need. */
|
|
447
|
+
function withoutBlanks(lines: readonly string[]): string[] {
|
|
448
|
+
return lines.filter((line) => !isBlank(line));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function isBlank(line: string | undefined): boolean {
|
|
452
|
+
// A line of nothing but colour codes reads as blank, and pi's renderers end their parts with one.
|
|
453
|
+
return stripTerminalSequences(line ?? "").trim() === "";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function rulingText(ruling: CallRuling, theme: Theme, expanded: boolean): string {
|
|
457
|
+
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}`;
|
|
460
|
+
if (ruling.why) line += ` ${theme.fg("muted", ruling.why)}`;
|
|
461
|
+
if (expanded && ruling.detail) line += ` ${theme.fg("dim", `(${ruling.detail})`)}`;
|
|
462
|
+
return line;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function stateOf(tone: RulingTone): Phase {
|
|
466
|
+
switch (tone) {
|
|
467
|
+
case "pending":
|
|
468
|
+
return "running";
|
|
469
|
+
case "success":
|
|
470
|
+
return "done";
|
|
471
|
+
default:
|
|
472
|
+
return "failed";
|
|
473
|
+
}
|
|
474
|
+
}
|
package/src/desktop/palette.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
export const DESKTOP_THEME = "desktop";
|
|
4
|
-
|
|
5
|
-
export type Mode = "dark" | "light";
|
|
6
|
-
|
|
7
|
-
export interface DesktopColors {
|
|
8
|
-
mode: Mode;
|
|
9
|
-
roles: Readonly<Record<string, string>>;
|
|
10
|
-
terminal: Readonly<Record<string, string>>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ThemeJson {
|
|
14
|
-
$schema: string;
|
|
15
|
-
name: string;
|
|
16
|
-
vars: Record<string, string>;
|
|
17
|
-
colors: Record<string, string>;
|
|
18
|
-
export: { pageBg: string; cardBg: string; infoBg: string };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const HEX = /^#[0-9a-f]{6}$/i;
|
|
22
|
-
|
|
23
|
-
export function parseDesktopColors(json: unknown, mode?: Mode): DesktopColors | undefined {
|
|
24
|
-
if (!isRecord(json) || !isRecord(json.colors) || !isRecord(json.dank16)) return undefined;
|
|
25
|
-
const chosen: Mode = mode ?? (json.mode === "light" ? "light" : "dark");
|
|
26
|
-
const roles = json.colors[chosen];
|
|
27
|
-
if (!isRecord(roles)) return undefined;
|
|
28
|
-
|
|
29
|
-
const terminal: Record<string, string> = {};
|
|
30
|
-
for (const [name, value] of Object.entries(json.dank16)) {
|
|
31
|
-
const hex = isRecord(value) ? (value[chosen] ?? value.default) : value;
|
|
32
|
-
if (typeof hex === "string" && HEX.test(hex)) terminal[name] = hex;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const material: Record<string, string> = {};
|
|
36
|
-
for (const [name, value] of Object.entries(roles)) {
|
|
37
|
-
if (typeof value === "string" && HEX.test(value)) material[name] = value;
|
|
38
|
-
}
|
|
39
|
-
return { mode: chosen, roles: material, terminal };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const REQUIRED_ROLES = [
|
|
43
|
-
"primary",
|
|
44
|
-
"secondary",
|
|
45
|
-
"tertiary",
|
|
46
|
-
"error",
|
|
47
|
-
"error_container",
|
|
48
|
-
"outline",
|
|
49
|
-
"outline_variant",
|
|
50
|
-
"on_surface",
|
|
51
|
-
"on_surface_variant",
|
|
52
|
-
"surface",
|
|
53
|
-
"surface_container_low",
|
|
54
|
-
"surface_container",
|
|
55
|
-
"surface_container_high",
|
|
56
|
-
"surface_container_highest",
|
|
57
|
-
];
|
|
58
|
-
const REQUIRED_TERMINAL = ["color1", "color2", "color3", "color6", "color9"];
|
|
59
|
-
|
|
60
|
-
export function missingColors(colors: DesktopColors): string[] {
|
|
61
|
-
return [
|
|
62
|
-
...REQUIRED_ROLES.filter((role) => colors.roles[role] === undefined),
|
|
63
|
-
...REQUIRED_TERMINAL.filter((name) => colors.terminal[name] === undefined),
|
|
64
|
-
];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function desktopTheme(colors: DesktopColors): ThemeJson {
|
|
68
|
-
const role = (name: string): string => colors.roles[name] ?? "#808080";
|
|
69
|
-
const term = (name: string): string => colors.terminal[name] ?? "#808080";
|
|
70
|
-
|
|
71
|
-
const vars: Record<string, string> = {
|
|
72
|
-
primary: role("primary"),
|
|
73
|
-
secondary: role("secondary"),
|
|
74
|
-
tertiary: role("tertiary"),
|
|
75
|
-
error: role("error"),
|
|
76
|
-
outline: role("outline"),
|
|
77
|
-
outlineVariant: role("outline_variant"),
|
|
78
|
-
text: role("on_surface"),
|
|
79
|
-
subtext: role("on_surface_variant"),
|
|
80
|
-
surface: role("surface"),
|
|
81
|
-
containerLow: role("surface_container_low"),
|
|
82
|
-
container: role("surface_container"),
|
|
83
|
-
containerHigh: role("surface_container_high"),
|
|
84
|
-
containerHighest: role("surface_container_highest"),
|
|
85
|
-
red: term("color1"),
|
|
86
|
-
green: term("color2"),
|
|
87
|
-
yellow: term("color3"),
|
|
88
|
-
cyan: term("color6"),
|
|
89
|
-
brightRed: term("color9"),
|
|
90
|
-
successBg: mix(role("surface_container_low"), term("color2"), 0.12),
|
|
91
|
-
errorBg: mix(role("surface_container_low"), role("error"), 0.14),
|
|
92
|
-
infoBg: mix(role("surface_container"), term("color3"), 0.1),
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const colorsByToken: Record<ThemeColor | BackgroundToken, string> = {
|
|
96
|
-
accent: "primary",
|
|
97
|
-
border: "outline",
|
|
98
|
-
borderAccent: "primary",
|
|
99
|
-
borderMuted: "outlineVariant",
|
|
100
|
-
success: "green",
|
|
101
|
-
error: "error",
|
|
102
|
-
warning: "yellow",
|
|
103
|
-
muted: "subtext",
|
|
104
|
-
dim: "outline",
|
|
105
|
-
text: "text",
|
|
106
|
-
thinkingText: "subtext",
|
|
107
|
-
|
|
108
|
-
selectedBg: "containerHigh",
|
|
109
|
-
scrollbarTrack: "outlineVariant",
|
|
110
|
-
scrollbarThumb: "subtext",
|
|
111
|
-
searchMatchBg: "containerHighest",
|
|
112
|
-
searchMatchText: "text",
|
|
113
|
-
userMessageBg: "container",
|
|
114
|
-
userMessageText: "text",
|
|
115
|
-
customMessageBg: "containerLow",
|
|
116
|
-
customMessageText: "text",
|
|
117
|
-
customMessageLabel: "tertiary",
|
|
118
|
-
toolPendingBg: "containerLow",
|
|
119
|
-
toolSuccessBg: "successBg",
|
|
120
|
-
toolErrorBg: "errorBg",
|
|
121
|
-
toolTitle: "text",
|
|
122
|
-
toolOutput: "subtext",
|
|
123
|
-
|
|
124
|
-
mdHeading: "tertiary",
|
|
125
|
-
mdLink: "primary",
|
|
126
|
-
mdLinkUrl: "outline",
|
|
127
|
-
mdCode: "secondary",
|
|
128
|
-
mdCodeBlock: "text",
|
|
129
|
-
mdCodeBlockBorder: "outlineVariant",
|
|
130
|
-
mdQuote: "subtext",
|
|
131
|
-
mdQuoteBorder: "outlineVariant",
|
|
132
|
-
mdHr: "outlineVariant",
|
|
133
|
-
mdListBullet: "primary",
|
|
134
|
-
|
|
135
|
-
toolDiffAdded: "green",
|
|
136
|
-
toolDiffRemoved: "red",
|
|
137
|
-
toolDiffContext: "subtext",
|
|
138
|
-
|
|
139
|
-
syntaxComment: "outline",
|
|
140
|
-
syntaxKeyword: "primary",
|
|
141
|
-
syntaxFunction: "tertiary",
|
|
142
|
-
syntaxVariable: "text",
|
|
143
|
-
syntaxString: "green",
|
|
144
|
-
syntaxNumber: "yellow",
|
|
145
|
-
syntaxType: "cyan",
|
|
146
|
-
syntaxOperator: "subtext",
|
|
147
|
-
syntaxPunctuation: "subtext",
|
|
148
|
-
|
|
149
|
-
thinkingOff: "outlineVariant",
|
|
150
|
-
thinkingMinimal: "outline",
|
|
151
|
-
thinkingLow: "secondary",
|
|
152
|
-
thinkingMedium: "primary",
|
|
153
|
-
thinkingHigh: "tertiary",
|
|
154
|
-
thinkingXhigh: "brightRed",
|
|
155
|
-
thinkingMax: "red",
|
|
156
|
-
|
|
157
|
-
bashMode: "green",
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
$schema:
|
|
162
|
-
"https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
163
|
-
name: DESKTOP_THEME,
|
|
164
|
-
vars,
|
|
165
|
-
colors: colorsByToken,
|
|
166
|
-
export: { pageBg: vars.surface ?? "", cardBg: vars.container ?? "", infoBg: vars.infoBg ?? "" },
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
type BackgroundToken =
|
|
171
|
-
| "selectedBg"
|
|
172
|
-
| "searchMatchBg"
|
|
173
|
-
| "userMessageBg"
|
|
174
|
-
| "customMessageBg"
|
|
175
|
-
| "toolPendingBg"
|
|
176
|
-
| "toolSuccessBg"
|
|
177
|
-
| "toolErrorBg";
|
|
178
|
-
|
|
179
|
-
export function mix(base: string, tint: string, amount: number): string {
|
|
180
|
-
const a = channels(base);
|
|
181
|
-
const b = channels(tint);
|
|
182
|
-
const mixed = a.map((value, index) => Math.round(value + ((b[index] ?? value) - value) * amount));
|
|
183
|
-
return `#${mixed.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function channels(hex: string): number[] {
|
|
187
|
-
const value = HEX.test(hex) ? hex.slice(1) : "808080";
|
|
188
|
-
return [0, 2, 4].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16));
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
192
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
193
|
-
}
|