@adeildo/pi-look 4.1.0 → 5.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.
- package/README.md +22 -121
- package/package.json +2 -2
- package/src/data/git.ts +0 -6
- package/src/data/snapshot.ts +0 -13
- package/src/data/telemetry.ts +116 -63
- package/src/data/totals.ts +0 -14
- package/src/desktop/palette.ts +0 -14
- package/src/desktop/sync.ts +1 -8
- package/src/index.ts +42 -61
- package/src/render/fit.ts +0 -10
- package/src/render/format.ts +0 -17
- package/src/render/frame.ts +0 -12
- package/src/render/glyphs.ts +0 -9
- package/src/render/paint.ts +0 -16
- package/src/render/segments.ts +27 -35
- package/src/settings.ts +54 -22
- package/src/ui/editor.ts +2 -15
- package/src/ui/footer.ts +0 -9
- package/src/ui/header.ts +0 -8
- package/src/ui/screen.ts +0 -5
- package/src/ui/working.ts +9 -57
package/src/render/segments.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// What each piece says. A segment is a pure function of the snapshot: it returns a piece, with an
|
|
2
|
-
// optional shorter form and a priority, or nothing when it has nothing to say. Where a segment goes is
|
|
3
|
-
// the layout's business, so any segment can sit in any slot.
|
|
4
1
|
import type { Snapshot } from "../data/snapshot.ts";
|
|
5
2
|
import { promptOf, tokensOf, type Totals } from "../data/totals.ts";
|
|
6
3
|
import type { Piece } from "./fit.ts";
|
|
@@ -35,6 +32,7 @@ export const SEGMENT_IDS = [
|
|
|
35
32
|
"context",
|
|
36
33
|
"speed",
|
|
37
34
|
"wait",
|
|
35
|
+
"server",
|
|
38
36
|
"elapsed",
|
|
39
37
|
"last",
|
|
40
38
|
"request",
|
|
@@ -47,12 +45,10 @@ export const SEGMENT_IDS = [
|
|
|
47
45
|
] as const;
|
|
48
46
|
|
|
49
47
|
export type BuiltinSegment = (typeof SEGMENT_IDS)[number];
|
|
50
|
-
/** One package's status on its own, by the key it passes to `setStatus`. */
|
|
51
48
|
export type StatusSegment = `status:${string}`;
|
|
52
49
|
export type SegmentId = BuiltinSegment | StatusSegment;
|
|
53
50
|
|
|
54
51
|
const STATUS_PREFIX = "status:";
|
|
55
|
-
/** A status placed on its own is something its package wants seen, like the permission mode. */
|
|
56
52
|
const STATUS_PRIORITY = 95;
|
|
57
53
|
|
|
58
54
|
export function isSegmentId(value: string): value is SegmentId {
|
|
@@ -62,7 +58,6 @@ export function isSegmentId(value: string): value is SegmentId {
|
|
|
62
58
|
);
|
|
63
59
|
}
|
|
64
60
|
|
|
65
|
-
/** The status keys a layout places on their own, which the `statuses` segment then leaves out. */
|
|
66
61
|
export function claimedStatuses(slots: readonly (readonly SegmentId[])[]): Set<string> {
|
|
67
62
|
const claimed = new Set<string>();
|
|
68
63
|
for (const ids of slots) {
|
|
@@ -73,13 +68,9 @@ export function claimedStatuses(slots: readonly (readonly SegmentId[])[]): Set<s
|
|
|
73
68
|
}
|
|
74
69
|
|
|
75
70
|
export interface SegmentOptions {
|
|
76
|
-
/** Longest the folder may be before it is shortened from the left. 0 keeps all of it. */
|
|
77
71
|
pathLength: number;
|
|
78
|
-
/** Cells in the context gauge. 0 draws no gauge. */
|
|
79
72
|
gaugeCells: number;
|
|
80
|
-
/** Status keys placed on their own somewhere in the layout. */
|
|
81
73
|
claimed: ReadonlySet<string>;
|
|
82
|
-
/** A short word before each number, saying what it is. */
|
|
83
74
|
labels: boolean;
|
|
84
75
|
}
|
|
85
76
|
|
|
@@ -93,9 +84,7 @@ export interface SegmentInput {
|
|
|
93
84
|
type Rendered = Omit<Piece, "priority"> | undefined;
|
|
94
85
|
|
|
95
86
|
interface Segment {
|
|
96
|
-
/** Higher stays longer when the line runs out of room. */
|
|
97
87
|
priority: number;
|
|
98
|
-
/** One line for the docs and the settings screen. */
|
|
99
88
|
describe: string;
|
|
100
89
|
render(input: SegmentInput): Rendered;
|
|
101
90
|
}
|
|
@@ -201,7 +190,6 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
|
|
|
201
190
|
},
|
|
202
191
|
},
|
|
203
192
|
effort: {
|
|
204
|
-
// The frame is already painted in the effort colour, so the meter can go before the context.
|
|
205
193
|
priority: 80,
|
|
206
194
|
describe: "how hard the model thinks, as a meter that warms with the level",
|
|
207
195
|
render: ({ snapshot, glyphs, paint }) => {
|
|
@@ -242,7 +230,6 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
|
|
|
242
230
|
render: ({ snapshot, glyphs, paint, options }) => {
|
|
243
231
|
const request = snapshot.request;
|
|
244
232
|
if (request === undefined) return undefined;
|
|
245
|
-
// An estimate is drawn quieter until the provider says the real count; a `~` would change the width.
|
|
246
233
|
const out = speedCell(request.decode);
|
|
247
234
|
const value = request.estimated ? paint.muted(out) : paint.bold(paint.role("speed", out));
|
|
248
235
|
const down = `${flow("received", glyphs, paint, options)}${value}`;
|
|
@@ -254,11 +241,12 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
|
|
|
254
241
|
},
|
|
255
242
|
wait: {
|
|
256
243
|
priority: 55,
|
|
257
|
-
describe:
|
|
244
|
+
describe:
|
|
245
|
+
"time to first token (ttft): from the request leaving to the model writing its first token",
|
|
258
246
|
render: ({ snapshot, glyphs, paint, options }) => {
|
|
259
247
|
const request = snapshot.request;
|
|
260
248
|
if (request === undefined) return undefined;
|
|
261
|
-
const ms = request.waiting ? request.
|
|
249
|
+
const ms = request.waiting ? request.waitingMs : request.waitMs;
|
|
262
250
|
const icon = glyphs.wait === "" ? "" : `${paint.role("wait", glyphs.wait)} `;
|
|
263
251
|
const value = cell(ms === undefined ? UNKNOWN : latency(ms), CELL.latency);
|
|
264
252
|
return {
|
|
@@ -275,38 +263,53 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
|
|
|
275
263
|
if (run === undefined) return undefined;
|
|
276
264
|
const role: Role = run.running ? "running" : "done";
|
|
277
265
|
const icon = mark(run.running ? glyphs.running : glyphs.done, paint, role);
|
|
278
|
-
// A stopwatch keeps its shape as it counts, so the line does not move while it ticks.
|
|
279
266
|
const time = paint.role(role, stopwatch(run.elapsedMs));
|
|
280
267
|
const calls =
|
|
281
268
|
run.requests <= 1
|
|
282
269
|
? ""
|
|
283
|
-
: paint.dim(options.labels ? `
|
|
270
|
+
: paint.dim(options.labels ? ` ${run.requests} calls` : ` ×${run.requests}`);
|
|
284
271
|
return { text: `${icon}${time}${calls}`, compact: `${icon}${time}` };
|
|
285
272
|
},
|
|
286
273
|
},
|
|
287
274
|
last: {
|
|
288
275
|
priority: 55,
|
|
289
276
|
describe:
|
|
290
|
-
"the last call
|
|
277
|
+
"the last call that finished: how long the model took to start, how long it thought, and how fast it wrote",
|
|
291
278
|
render: ({ snapshot, glyphs, paint, options }) => {
|
|
292
279
|
const last = snapshot.last;
|
|
293
280
|
if (last === undefined) return undefined;
|
|
294
281
|
const parts: string[] = [];
|
|
295
|
-
if (last.
|
|
296
|
-
const wait = paint.role("wait", latency(last.
|
|
282
|
+
if (last.waitMs !== undefined) {
|
|
283
|
+
const wait = paint.role("wait", latency(last.waitMs));
|
|
297
284
|
parts.push(
|
|
298
285
|
options.labels
|
|
299
286
|
? `${wait}${paint.dim(" wait")}`
|
|
300
287
|
: `${mark(glyphs.wait, paint, "wait")}${wait}`,
|
|
301
288
|
);
|
|
302
289
|
}
|
|
290
|
+
if (last.thoughtMs !== undefined) {
|
|
291
|
+
const thought = paint.role("wait", latency(last.thoughtMs));
|
|
292
|
+
parts.push(
|
|
293
|
+
options.labels ? `${thought}${paint.dim(" thought")}` : `${thought}${paint.dim(" th")}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
303
296
|
if (last.decode !== undefined) {
|
|
304
297
|
const speed = `${paint.role("received", glyphs.received)}${paint.role("speed", rate(last.decode))}`;
|
|
305
298
|
parts.push(`${speed}${paint.dim(" tok/s")}`);
|
|
306
299
|
}
|
|
307
300
|
if (parts.length === 0) return undefined;
|
|
308
301
|
const label = options.labels ? paint.dim("last call ") : "";
|
|
309
|
-
return { text: `${label}${parts.join(
|
|
302
|
+
return { text: `${label}${parts.join(" ")}`, compact: parts[0] };
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
server: {
|
|
306
|
+
priority: 53,
|
|
307
|
+
describe:
|
|
308
|
+
"how long the provider took to answer the request, before the model wrote anything: send to headers",
|
|
309
|
+
render: ({ snapshot, paint }) => {
|
|
310
|
+
const ms = snapshot.last?.serverMs;
|
|
311
|
+
if (ms === undefined) return undefined;
|
|
312
|
+
return { text: `${paint.role("wait", latency(ms))}${paint.dim(" server")}` };
|
|
310
313
|
},
|
|
311
314
|
},
|
|
312
315
|
request: {
|
|
@@ -318,7 +321,6 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
|
|
|
318
321
|
const sent = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
319
322
|
const up = `${flow("sent", glyphs, paint, options)}${tokenCell(sent)}`;
|
|
320
323
|
const down = `${flow("received", glyphs, paint, options)}${tokenCell(usage.output)}`;
|
|
321
|
-
// Both cache cells are always there, so the breakdown never appears in the middle of the line.
|
|
322
324
|
const cache = cacheParts(usage, glyphs, paint, options, true);
|
|
323
325
|
return {
|
|
324
326
|
text: `${up} ${paint.dim("(")}${cache}${paint.dim(")")} ${down}`,
|
|
@@ -424,14 +426,13 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
|
|
|
424
426
|
if (texts.length === 0) return undefined;
|
|
425
427
|
const icon = glyphs.plug === "" ? "" : `${paint.dim(glyphs.plug)} `;
|
|
426
428
|
return {
|
|
427
|
-
text: `${icon}${texts.join(
|
|
429
|
+
text: `${icon}${texts.join(" ")}`,
|
|
428
430
|
compact: `${icon}${texts[0] ?? ""}`,
|
|
429
431
|
};
|
|
430
432
|
},
|
|
431
433
|
},
|
|
432
434
|
};
|
|
433
435
|
|
|
434
|
-
/** The pieces for one slot, in the order the slot lists them. */
|
|
435
436
|
export function renderSegments(ids: readonly SegmentId[], input: SegmentInput): Piece[] {
|
|
436
437
|
return ids.flatMap((id): Piece[] => {
|
|
437
438
|
if (id.startsWith(STATUS_PREFIX)) {
|
|
@@ -452,10 +453,6 @@ function counted(value: number, glyph: string, role: Role, paint: Paint): string
|
|
|
452
453
|
return value > 0 ? paint.role(role, `${glyph}${value}`) : "";
|
|
453
454
|
}
|
|
454
455
|
|
|
455
|
-
/**
|
|
456
|
-
* Read from the cache, and written to it. With labels: `cache read 80k · wrote 1.3k`. `fixed` keeps both
|
|
457
|
-
* cells with a dash for zero, for a line that must not change shape.
|
|
458
|
-
*/
|
|
459
456
|
function cacheParts(
|
|
460
457
|
usage: Totals,
|
|
461
458
|
glyphs: Glyphs,
|
|
@@ -471,7 +468,7 @@ function cacheParts(
|
|
|
471
468
|
read === "" ? "" : `${paint.dim("read ")}${paint.role("cache", read)}`,
|
|
472
469
|
write === "" ? "" : `${paint.dim("wrote ")}${paint.role("cache", write)}`,
|
|
473
470
|
].filter((part) => part !== "");
|
|
474
|
-
return parts.length === 0 ? "" : `${paint.dim("cache ")}${parts.join(
|
|
471
|
+
return parts.length === 0 ? "" : `${paint.dim("cache ")}${parts.join(" ")}`;
|
|
475
472
|
}
|
|
476
473
|
return [
|
|
477
474
|
read === "" ? "" : `${paint.role("cache", glyphs.cacheRead)}${read}`,
|
|
@@ -481,10 +478,6 @@ function cacheParts(
|
|
|
481
478
|
.join(" ");
|
|
482
479
|
}
|
|
483
480
|
|
|
484
|
-
/**
|
|
485
|
-
* The direction of a flow of tokens, from where you sit: ↑ in, what is sent to the model, and ↓ out,
|
|
486
|
-
* what comes back. With labels the word follows the arrow.
|
|
487
|
-
*/
|
|
488
481
|
function flow(
|
|
489
482
|
role: "sent" | "received",
|
|
490
483
|
glyphs: Glyphs,
|
|
@@ -496,7 +489,6 @@ function flow(
|
|
|
496
489
|
return paint.role(role, options.labels ? `${arrow}${name} ` : arrow);
|
|
497
490
|
}
|
|
498
491
|
|
|
499
|
-
/** A dim word in front of a value, when labels are on. `skip` when the glyph already is the word. */
|
|
500
492
|
function word(options: SegmentOptions, text: string, paint: Paint, skip = false): string {
|
|
501
493
|
return options.labels && !skip && text !== "" ? paint.dim(`${text} `) : "";
|
|
502
494
|
}
|
package/src/settings.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// Every setting of the look, under `look.` in the shared settings file. The slots are plain lists of
|
|
2
|
-
// segment ids, so moving a piece from the frame to the footer is editing one array.
|
|
3
1
|
import {
|
|
4
2
|
boolean,
|
|
3
|
+
type Control,
|
|
5
4
|
type Decoder,
|
|
6
5
|
integer,
|
|
7
6
|
literal,
|
|
@@ -13,9 +12,24 @@ import {
|
|
|
13
12
|
|
|
14
13
|
import type { FrameStyle } from "./render/frame.ts";
|
|
15
14
|
import type { IconMode } from "./render/glyphs.ts";
|
|
16
|
-
import { isSegmentId, type SegmentId } from "./render/segments.ts";
|
|
15
|
+
import { isSegmentId, SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
|
|
17
16
|
|
|
18
|
-
const
|
|
17
|
+
const PERMISSION_STATUS: SegmentId = "status:pi-ask-permission:mode";
|
|
18
|
+
const ACCOUNT_STATUS: SegmentId = "status:pi-providers:account";
|
|
19
|
+
|
|
20
|
+
const STATUS_LABELS: Partial<Record<SegmentId, string>> = {
|
|
21
|
+
[PERMISSION_STATUS]: "permission mode",
|
|
22
|
+
[ACCOUNT_STATUS]: "account",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const SECTIONS = [
|
|
26
|
+
"Editor",
|
|
27
|
+
"Slots",
|
|
28
|
+
"Footer",
|
|
29
|
+
"Start screen",
|
|
30
|
+
"Working line",
|
|
31
|
+
"Desktop theme",
|
|
32
|
+
];
|
|
19
33
|
|
|
20
34
|
export type HeaderStyle = "card" | "compact" | "off";
|
|
21
35
|
export type SeparatorStyle = "dot" | "bar" | "slash" | "space";
|
|
@@ -28,7 +42,6 @@ export const SEPARATORS: Record<SeparatorStyle, string> = {
|
|
|
28
42
|
space: " ",
|
|
29
43
|
};
|
|
30
44
|
|
|
31
|
-
/** A list of known segment ids. Unknown ones are dropped with a warning, not the whole list. */
|
|
32
45
|
function segments(fallback: readonly SegmentId[]): Decoder<SegmentId[]> {
|
|
33
46
|
return {
|
|
34
47
|
decode(input, path) {
|
|
@@ -50,13 +63,33 @@ function segments(fallback: readonly SegmentId[]): Decoder<SegmentId[]> {
|
|
|
50
63
|
};
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
// Any other package's status can have a slot of its own, so the ones already placed are offered too.
|
|
67
|
+
function slotControl(fallback: readonly SegmentId[]): Control {
|
|
68
|
+
const statuses = [
|
|
69
|
+
PERMISSION_STATUS,
|
|
70
|
+
ACCOUNT_STATUS,
|
|
71
|
+
...fallback.filter((id) => id.startsWith("status:")),
|
|
72
|
+
];
|
|
73
|
+
return {
|
|
74
|
+
type: "list",
|
|
75
|
+
options: [
|
|
76
|
+
...SEGMENT_IDS.map((id) => ({ value: id, description: SEGMENTS[id].describe })),
|
|
77
|
+
...[...new Set(statuses)].map((id) => ({
|
|
78
|
+
value: id,
|
|
79
|
+
label: STATUS_LABELS[id] ?? id.slice("status:".length),
|
|
80
|
+
description: `what ${id.slice("status:".length)} reports`,
|
|
81
|
+
})),
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
53
86
|
function slot(id: string, label: string, description: string, fallback: readonly SegmentId[]) {
|
|
54
87
|
return setting<SegmentId[]>({
|
|
55
88
|
id: `look.${id}`,
|
|
56
89
|
default: [...fallback],
|
|
57
90
|
decoder: segments(fallback),
|
|
58
91
|
project: true,
|
|
59
|
-
ui: {
|
|
92
|
+
ui: { section: "Slots", label, description, control: slotControl(fallback) },
|
|
60
93
|
});
|
|
61
94
|
}
|
|
62
95
|
|
|
@@ -74,14 +107,13 @@ export const topRight = slot("frame.topRight", "Frame, top right", "Where you ar
|
|
|
74
107
|
"path",
|
|
75
108
|
"host",
|
|
76
109
|
]);
|
|
77
|
-
|
|
78
|
-
export const PERMISSION_MODE: SegmentId = "status:pi-ask-permission:mode";
|
|
110
|
+
export const PERMISSION_MODE: SegmentId = PERMISSION_STATUS;
|
|
79
111
|
|
|
80
112
|
export const bottomLeft = slot(
|
|
81
113
|
"frame.bottomLeft",
|
|
82
114
|
"Frame, bottom left",
|
|
83
115
|
"How what you type is handled, and who does the work.",
|
|
84
|
-
[PERMISSION_MODE, "model", "effort"],
|
|
116
|
+
[PERMISSION_MODE, "model", ACCOUNT_STATUS, "effort"],
|
|
85
117
|
);
|
|
86
118
|
export const bottomRight = slot("frame.bottomRight", "Frame, bottom right", "How full it is.", [
|
|
87
119
|
"context",
|
|
@@ -99,7 +131,7 @@ export const frame = setting<FrameStyle | "off">({
|
|
|
99
131
|
decoder: literal("rounded", "square", "heavy", "line", "off"),
|
|
100
132
|
project: true,
|
|
101
133
|
ui: {
|
|
102
|
-
|
|
134
|
+
section: "Editor",
|
|
103
135
|
label: "Frame",
|
|
104
136
|
description:
|
|
105
137
|
"The box around the editor. `line` keeps pi's two rules, `off` leaves the editor alone and moves the frame slots to the footer.",
|
|
@@ -111,7 +143,7 @@ export const cursor = setting<CursorStyle>({
|
|
|
111
143
|
default: "bar",
|
|
112
144
|
decoder: literal("block", "bar", "underline"),
|
|
113
145
|
ui: {
|
|
114
|
-
|
|
146
|
+
section: "Editor",
|
|
115
147
|
label: "Cursor",
|
|
116
148
|
description: "Bar and underline use the terminal's own cursor, so it blinks the way it does.",
|
|
117
149
|
},
|
|
@@ -123,7 +155,7 @@ export const header = setting<HeaderStyle>({
|
|
|
123
155
|
decoder: literal("card", "compact", "off"),
|
|
124
156
|
project: true,
|
|
125
157
|
ui: {
|
|
126
|
-
|
|
158
|
+
section: "Start screen",
|
|
127
159
|
label: "Start screen",
|
|
128
160
|
description: "The card pi opens with. Off keeps pi's own.",
|
|
129
161
|
},
|
|
@@ -134,7 +166,7 @@ export const icons = setting<IconMode>({
|
|
|
134
166
|
default: "auto",
|
|
135
167
|
decoder: literal("auto", "nerd", "unicode", "ascii"),
|
|
136
168
|
ui: {
|
|
137
|
-
|
|
169
|
+
section: "Footer",
|
|
138
170
|
label: "Icons",
|
|
139
171
|
description: "Auto uses Nerd Font glyphs locally, plain Unicode over SSH.",
|
|
140
172
|
},
|
|
@@ -142,10 +174,10 @@ export const icons = setting<IconMode>({
|
|
|
142
174
|
|
|
143
175
|
export const separator = setting<SeparatorStyle>({
|
|
144
176
|
id: "look.separator",
|
|
145
|
-
default: "
|
|
177
|
+
default: "space",
|
|
146
178
|
decoder: literal("dot", "bar", "slash", "space"),
|
|
147
179
|
project: true,
|
|
148
|
-
ui: {
|
|
180
|
+
ui: { section: "Footer", label: "Separator", description: "Between the pieces of a slot." },
|
|
149
181
|
});
|
|
150
182
|
|
|
151
183
|
export const pathLength = setting({
|
|
@@ -154,7 +186,7 @@ export const pathLength = setting({
|
|
|
154
186
|
decoder: integer(0, 500),
|
|
155
187
|
project: true,
|
|
156
188
|
ui: {
|
|
157
|
-
|
|
189
|
+
section: "Footer",
|
|
158
190
|
label: "Path length",
|
|
159
191
|
description: "Longest the folder may be before it is shortened from the left. 0 keeps it all.",
|
|
160
192
|
},
|
|
@@ -165,7 +197,7 @@ export const gaugeCells = setting({
|
|
|
165
197
|
default: 8,
|
|
166
198
|
decoder: integer(0, 40),
|
|
167
199
|
project: true,
|
|
168
|
-
ui: {
|
|
200
|
+
ui: { section: "Footer", label: "Gauge", description: "Cells in the context gauge. 0 hides it." },
|
|
169
201
|
});
|
|
170
202
|
|
|
171
203
|
export const labels = setting({
|
|
@@ -174,7 +206,7 @@ export const labels = setting({
|
|
|
174
206
|
decoder: boolean,
|
|
175
207
|
project: true,
|
|
176
208
|
ui: {
|
|
177
|
-
|
|
209
|
+
section: "Footer",
|
|
178
210
|
label: "Labels",
|
|
179
211
|
description:
|
|
180
212
|
"A short word before each number (out, in, ttft, took, ctx, cache). Off is denser.",
|
|
@@ -186,10 +218,10 @@ export const peek = setting({
|
|
|
186
218
|
default: true,
|
|
187
219
|
decoder: boolean,
|
|
188
220
|
ui: {
|
|
189
|
-
|
|
221
|
+
section: "Working line",
|
|
190
222
|
label: "Working line",
|
|
191
223
|
description:
|
|
192
|
-
"The spinner
|
|
224
|
+
"The spinner names the state: waiting, thinking, writing, drafting a tool call, running it.",
|
|
193
225
|
},
|
|
194
226
|
});
|
|
195
227
|
|
|
@@ -198,7 +230,7 @@ export const desktop = setting({
|
|
|
198
230
|
default: true,
|
|
199
231
|
decoder: boolean,
|
|
200
232
|
ui: {
|
|
201
|
-
|
|
233
|
+
section: "Desktop theme",
|
|
202
234
|
label: "Desktop theme",
|
|
203
235
|
description:
|
|
204
236
|
"Write the matugen palette from DankMaterialShell as the pi theme `desktop`, and keep it in sync.",
|
|
@@ -210,7 +242,7 @@ export const desktopSource = setting({
|
|
|
210
242
|
default: "~/.cache/DankMaterialShell/dms-colors.json",
|
|
211
243
|
decoder: string,
|
|
212
244
|
ui: {
|
|
213
|
-
|
|
245
|
+
section: "Desktop theme",
|
|
214
246
|
label: "Desktop palette",
|
|
215
247
|
description: "The DankMaterialShell colour file the desktop theme is made from.",
|
|
216
248
|
},
|
package/src/ui/editor.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// The editor, framed. Pi's editor draws a rule above and below the text; this one draws a box, writes
|
|
2
|
-
// four slots into its borders, and puts the working spinner in the top-left corner. Typing, history, autocomplete and every app key stay pi's: the class only
|
|
3
|
-
// changes what the lines look like, and shifts mouse clicks by the width of the frame.
|
|
4
1
|
import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
5
2
|
import {
|
|
6
3
|
type EditorTheme,
|
|
@@ -18,14 +15,12 @@ import { type Screen, type SlotName, slotPieces } from "./screen.ts";
|
|
|
18
15
|
|
|
19
16
|
type WorkingIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
|
|
20
17
|
|
|
21
|
-
/** Pi calls the border hooks inside its render; these
|
|
18
|
+
/** Pi calls the border hooks inside its render; these mark where this class draws its own lines. */
|
|
22
19
|
const TOP = "\u0000look:top\u0000";
|
|
23
20
|
const BOTTOM = "\u0000look:bottom\u0000";
|
|
24
21
|
// oxlint-disable-next-line no-control-regex -- pi's software cursor is an escape sequence
|
|
25
22
|
const INVERTED_CELL = /\x1b\[7m([\s\S]*?)\x1b\[0m/;
|
|
26
|
-
/** Below this the frame is noise, so pi's plain editor draws instead. */
|
|
27
23
|
const MIN_WIDTH = 24;
|
|
28
|
-
/** The spinner always gets this much of the top border, enough for "⠋ Thinking". */
|
|
29
24
|
const LEAD_MIN = 10;
|
|
30
25
|
|
|
31
26
|
const CURSOR_SEQUENCES: Record<CursorStyle, string> = {
|
|
@@ -40,7 +35,6 @@ export class LookEditor extends CustomEditor {
|
|
|
40
35
|
#framing = false;
|
|
41
36
|
#hiddenAbove = 0;
|
|
42
37
|
#hiddenBelow = 0;
|
|
43
|
-
/** Columns the frame takes on each side, for mouse clicks. */
|
|
44
38
|
#inset = 0;
|
|
45
39
|
|
|
46
40
|
constructor(
|
|
@@ -155,7 +149,6 @@ export class LookEditor extends CustomEditor {
|
|
|
155
149
|
const text = cursor === "block" ? line : withoutSoftwareCursor(line);
|
|
156
150
|
result.push(`${left}${fill(text, inner)}${right}`);
|
|
157
151
|
} else {
|
|
158
|
-
// The autocomplete list, lined up with the text so clicks land where they look.
|
|
159
152
|
result.push(`${" ".repeat(this.#inset)}${line}`);
|
|
160
153
|
}
|
|
161
154
|
}
|
|
@@ -163,7 +156,6 @@ export class LookEditor extends CustomEditor {
|
|
|
163
156
|
return result.map((line) => truncateToWidth(line, width, ""));
|
|
164
157
|
}
|
|
165
158
|
|
|
166
|
-
/** The spinner and its message, in the room the slots left. Just the spinner when it is tight. */
|
|
167
159
|
#lead(budget: number): string | undefined {
|
|
168
160
|
const indicator = this.#indicator;
|
|
169
161
|
if (indicator === undefined) return undefined;
|
|
@@ -173,21 +165,16 @@ export class LookEditor extends CustomEditor {
|
|
|
173
165
|
}
|
|
174
166
|
}
|
|
175
167
|
|
|
176
|
-
/**
|
|
177
|
-
* Pi draws its cursor as an inverted cell. With the terminal's own cursor showing, that would be two
|
|
178
|
-
* cursors, so the inversion goes and the character stays.
|
|
179
|
-
*/
|
|
168
|
+
/** With the terminal's cursor showing, pi's inverted cell would be a second cursor. */
|
|
180
169
|
function withoutSoftwareCursor(line: string): string {
|
|
181
170
|
return line.replace(INVERTED_CELL, "$1");
|
|
182
171
|
}
|
|
183
172
|
|
|
184
|
-
/** Pads a content line to the inner width, so the right rail stays straight. */
|
|
185
173
|
function fill(line: string, width: number): string {
|
|
186
174
|
const clipped = truncateToWidth(line, width, "");
|
|
187
175
|
return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
|
|
188
176
|
}
|
|
189
177
|
|
|
190
|
-
/** Sets the terminal cursor shape. Pi draws its own block cursor, so the others need the hardware one. */
|
|
191
178
|
export function applyCursor(tui: TUI, style: CursorStyle): void {
|
|
192
179
|
if (style !== "block") tui.setShowHardwareCursor(true);
|
|
193
180
|
tui.terminal.write(CURSOR_SEQUENCES[style]);
|
package/src/ui/footer.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// The lines around the editor that are not the frame. The strip above it is the answer being written;
|
|
2
|
-
// the footer below it is the session. With the frame off, the footer also carries the four frame
|
|
3
|
-
// slots, so turning the box off never hides data.
|
|
4
1
|
import type { ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
|
|
5
2
|
import { type Component, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
3
|
|
|
@@ -8,16 +5,13 @@ import { fitLine, fitRegions, type Piece } from "../render/fit.ts";
|
|
|
8
5
|
import type { Paint } from "../render/paint.ts";
|
|
9
6
|
import { type Screen, type SlotName, slotPieces } from "./screen.ts";
|
|
10
7
|
|
|
11
|
-
/** At least this much between a left and a right group sharing a line. */
|
|
12
8
|
const GAP = 3;
|
|
13
9
|
|
|
14
|
-
/** Where the text starts, so the strip and the footer line up with the text inside the frame. */
|
|
15
10
|
export function indentOf(screen: Screen): number {
|
|
16
11
|
const style = screen.frameStyle();
|
|
17
12
|
return style === "off" || style === "line" ? 1 : 2;
|
|
18
13
|
}
|
|
19
14
|
|
|
20
|
-
/** The strip above the editor. Blank until the first answer, but its line is always there. */
|
|
21
15
|
export class StripComponent implements Component {
|
|
22
16
|
readonly #screen: Screen;
|
|
23
17
|
|
|
@@ -36,7 +30,6 @@ export class StripComponent implements Component {
|
|
|
36
30
|
}
|
|
37
31
|
|
|
38
32
|
export interface FooterHooks {
|
|
39
|
-
/** The footer data is where the branch and other packages' statuses come from. */
|
|
40
33
|
attach(data: ReadonlyFooterDataProvider, repaint: () => void): void;
|
|
41
34
|
detach(): void;
|
|
42
35
|
}
|
|
@@ -67,7 +60,6 @@ export class FooterComponent implements Component {
|
|
|
67
60
|
lines.push(renderSlot(screen, "below", room));
|
|
68
61
|
|
|
69
62
|
const kept = lines.filter((line) => line !== "").map((line) => `${pad}${line}`);
|
|
70
|
-
// At least one line, for the same reason as the strip: the editor must not jump when it fills.
|
|
71
63
|
return kept.length === 0 ? [""] : kept;
|
|
72
64
|
}
|
|
73
65
|
|
|
@@ -86,7 +78,6 @@ function renderSlot(screen: Screen, name: SlotName, width: number): string {
|
|
|
86
78
|
return fitLine(pieces, width, fitOptions(screen, paint));
|
|
87
79
|
}
|
|
88
80
|
|
|
89
|
-
/** A left group and a right group on one line, pushed apart. */
|
|
90
81
|
function spread(screen: Screen, leftSlot: SlotName, rightSlot: SlotName, width: number): string {
|
|
91
82
|
if (width <= 0) return "";
|
|
92
83
|
const paint = screen.paint();
|
package/src/ui/header.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// The start screen. A card with the π drawn in blocks, coloured along the theme's effort ramp, next to
|
|
2
|
-
// what the session starts with: the model, the folder, the machine, what loaded, and the keys worth
|
|
3
|
-
// knowing. Narrow terminals get two plain lines instead.
|
|
4
1
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
5
2
|
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
3
|
|
|
@@ -10,7 +7,6 @@ import { renderSegments, type SegmentId } from "../render/segments.ts";
|
|
|
10
7
|
import type { HeaderStyle } from "../settings.ts";
|
|
11
8
|
import type { Screen } from "./screen.ts";
|
|
12
9
|
|
|
13
|
-
/** The π, one character per pixel, drawn two pixels per row with half blocks. */
|
|
14
10
|
const PIXELS = [
|
|
15
11
|
"..............",
|
|
16
12
|
".############.",
|
|
@@ -24,10 +20,8 @@ const PIXELS = [
|
|
|
24
20
|
".##.......####",
|
|
25
21
|
];
|
|
26
22
|
const LOGO = halfBlocks(PIXELS);
|
|
27
|
-
/** One effort level per row, from cool at the top to hot at the bottom. */
|
|
28
23
|
const LOGO_RAMP = EFFORT_LEVELS.slice(1);
|
|
29
24
|
const LOGO_COLUMN = 20;
|
|
30
|
-
/** Under this the right column is too cramped to share with the logo. */
|
|
31
25
|
const MIN_RIGHT = 34;
|
|
32
26
|
const MIN_CARD = 48;
|
|
33
27
|
|
|
@@ -150,7 +144,6 @@ export class HeaderComponent implements Component {
|
|
|
150
144
|
return parts.filter((part) => part !== "").join(paint.dim(this.#screen.separator()));
|
|
151
145
|
}
|
|
152
146
|
|
|
153
|
-
/** Three rows of two hints each, or one column when narrow. */
|
|
154
147
|
#hints(paint: Paint, width: number): string[] {
|
|
155
148
|
const hints = HINTS();
|
|
156
149
|
const keyWidth = Math.max(...hints.map((hint) => visibleWidth(hint.key)));
|
|
@@ -171,7 +164,6 @@ export class HeaderComponent implements Component {
|
|
|
171
164
|
}
|
|
172
165
|
}
|
|
173
166
|
|
|
174
|
-
/** Read on every render, so a remapped key shows the new binding. */
|
|
175
167
|
function HINTS(): Hint[] {
|
|
176
168
|
return [
|
|
177
169
|
{ key: keyText("app.model.select") || "/model", does: "model" },
|
package/src/ui/screen.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// What the components read. The feature implements it over the live session and the settings; tests
|
|
2
|
-
// implement it over a fixed snapshot. Everything is read at render time, so a setting or theme change
|
|
3
|
-
// shows on the next frame with nothing to invalidate.
|
|
4
1
|
import type { Snapshot } from "../data/snapshot.ts";
|
|
5
2
|
import type { Piece } from "../render/fit.ts";
|
|
6
3
|
import type { FrameStyle } from "../render/frame.ts";
|
|
@@ -14,7 +11,6 @@ export type SlotName = "strip" | "topLeft" | "topRight" | "bottomLeft" | "bottom
|
|
|
14
11
|
export interface Screen {
|
|
15
12
|
snapshot(): Snapshot;
|
|
16
13
|
glyphs(): Glyphs;
|
|
17
|
-
/** Paints with the active theme. `frame` overrides the border colour, which the editor owns. */
|
|
18
14
|
paint(frame?: (text: string) => string): Paint;
|
|
19
15
|
separator(): string;
|
|
20
16
|
options(): SegmentOptions;
|
|
@@ -23,7 +19,6 @@ export interface Screen {
|
|
|
23
19
|
cursor(): CursorStyle;
|
|
24
20
|
}
|
|
25
21
|
|
|
26
|
-
/** The rendered pieces of one slot. The separator is painted here, once, for everyone. */
|
|
27
22
|
export function slotPieces(
|
|
28
23
|
screen: Screen,
|
|
29
24
|
name: SlotName,
|
package/src/ui/working.ts
CHANGED
|
@@ -1,73 +1,25 @@
|
|
|
1
|
-
// What the spinner says. Pi's says "Working" for the whole run; this one says what is happening: the
|
|
2
|
-
// newest line of the model's thinking, the tool call it is writing, the command running. It only
|
|
3
|
-
// speaks through pi's public `setWorkingMessage`, so pi still owns the spinner and where it sits.
|
|
4
|
-
|
|
5
1
|
export type Activity =
|
|
6
2
|
| { kind: "waiting" }
|
|
7
|
-
| { kind: "thinking"
|
|
3
|
+
| { kind: "thinking" }
|
|
8
4
|
| { kind: "writing" }
|
|
9
|
-
| { kind: "
|
|
10
|
-
| { kind: "running"; tool: string
|
|
5
|
+
| { kind: "drafting"; tool: string }
|
|
6
|
+
| { kind: "running"; tool: string };
|
|
11
7
|
|
|
12
|
-
/** One line, plain text: pi paints the message itself. */
|
|
13
8
|
export function describe(activity: Activity): string {
|
|
14
9
|
switch (activity.kind) {
|
|
15
10
|
case "waiting":
|
|
16
|
-
return "Waiting";
|
|
17
11
|
case "thinking":
|
|
18
|
-
return activity.tail === "" ? "Thinking" : `Thinking · ${activity.tail}`;
|
|
19
12
|
case "writing":
|
|
20
|
-
return
|
|
21
|
-
case "
|
|
22
|
-
return `
|
|
13
|
+
return activity.kind;
|
|
14
|
+
case "drafting":
|
|
15
|
+
return `drafting ${activity.tool.toLowerCase()}`;
|
|
23
16
|
case "running":
|
|
24
|
-
return
|
|
17
|
+
return `running ${activity.tool.toLowerCase()}`;
|
|
25
18
|
}
|
|
26
19
|
}
|
|
27
20
|
|
|
28
|
-
|
|
29
|
-
type?: unknown;
|
|
30
|
-
thinking?: unknown;
|
|
31
|
-
name?: unknown;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** The newest line of the thinking so far. Model output, so control characters go. */
|
|
35
|
-
export function thinkingTail(content: unknown): string {
|
|
36
|
-
if (!Array.isArray(content)) return "";
|
|
37
|
-
for (let index = content.length - 1; index >= 0; index--) {
|
|
38
|
-
const part = content[index] as ContentPart | undefined;
|
|
39
|
-
if (part?.type !== "thinking" || typeof part.thinking !== "string") continue;
|
|
40
|
-
const lines = part.thinking.split("\n").map(clean).filter(Boolean);
|
|
41
|
-
return lines.at(-1) ?? "";
|
|
42
|
-
}
|
|
43
|
-
return "";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The name of the tool call being written, when the model is writing one. */
|
|
47
|
-
export function callingTool(content: unknown): string | undefined {
|
|
21
|
+
export function draftedTool(content: unknown): string | undefined {
|
|
48
22
|
if (!Array.isArray(content)) return undefined;
|
|
49
|
-
const part = content.at(-1) as
|
|
23
|
+
const part = content.at(-1) as { type?: unknown; name?: unknown } | undefined;
|
|
50
24
|
return part?.type === "toolCall" && typeof part.name === "string" ? part.name : undefined;
|
|
51
25
|
}
|
|
52
|
-
|
|
53
|
-
const DETAIL_KEYS = ["command", "path", "file_path", "pattern", "query", "url", "prompt"];
|
|
54
|
-
|
|
55
|
-
/** The one argument that says what a tool is doing: the command, the file, the pattern. */
|
|
56
|
-
export function toolDetail(args: unknown): string {
|
|
57
|
-
if (typeof args !== "object" || args === null) return "";
|
|
58
|
-
const record = args as Record<string, unknown>;
|
|
59
|
-
for (const key of DETAIL_KEYS) {
|
|
60
|
-
const value = record[key];
|
|
61
|
-
if (typeof value === "string" && value.trim() !== "") {
|
|
62
|
-
return clean(value.split("\n").find((line) => line.trim() !== "") ?? "");
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return "";
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// oxlint-disable-next-line no-control-regex -- escape sequences and control characters are the target
|
|
69
|
-
const CONTROL = /\x1b\[[0-?]*[ -/]*[@-~]|[\x00-\x1f\x7f]/g;
|
|
70
|
-
|
|
71
|
-
function clean(text: string): string {
|
|
72
|
-
return text.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
|
73
|
-
}
|