@adeildo/pi-harness 4.0.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.
@@ -0,0 +1,448 @@
1
+ // The look: pi's start screen, editor, footer and working line, redrawn around the data I want in
2
+ // front of me, in the colours of whatever theme is on. One feature, because the pieces share one clock
3
+ // and one git probe; each piece has its own switch in the settings.
4
+ import { createApp, defineFeature, type FeatureScope } from "@adeildo/pi-kit";
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ ReadonlyFooterDataProvider,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import type { TUI } from "@earendil-works/pi-tui";
11
+
12
+ import { type GitState, readGit } from "./data/git.ts";
13
+ import { emptySnapshot, type Snapshot, SnapshotReader } from "./data/snapshot.ts";
14
+ import { Telemetry } from "./data/telemetry.ts";
15
+ import { REQUEST_ENTRY, type RequestRecord } from "./data/totals.ts";
16
+ import { DESKTOP_THEME } from "./desktop/palette.ts";
17
+ import { syncDesktopTheme, watchDesktopPalette } from "./desktop/sync.ts";
18
+ import { resolveIcons } from "./render/glyphs.ts";
19
+ import { PLAIN, themePaint } from "./render/paint.ts";
20
+ import { claimedStatuses, renderSegments, SEGMENTS } from "./render/segments.ts";
21
+ import {
22
+ below,
23
+ bottomLeft,
24
+ bottomRight,
25
+ cursor,
26
+ desktop,
27
+ desktopSource,
28
+ frame,
29
+ gaugeCells,
30
+ header,
31
+ icons,
32
+ labels,
33
+ LOOK_SETTINGS,
34
+ pathLength,
35
+ peek,
36
+ separator,
37
+ SEPARATORS,
38
+ strip,
39
+ topLeft,
40
+ topRight,
41
+ } from "./settings.ts";
42
+ import { applyCursor, LookEditor } from "./ui/editor.ts";
43
+ import { FooterComponent, StripComponent } from "./ui/footer.ts";
44
+ import { HeaderComponent, type LoadedCounts } from "./ui/header.ts";
45
+ import type { Screen, SlotName } from "./ui/screen.ts";
46
+ import { type Activity, callingTool, describe, thinkingTail, toolDetail } from "./ui/working.ts";
47
+
48
+ export { SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
49
+ export { ROLE_TOKENS } from "./render/paint.ts";
50
+ export { desktopTheme, parseDesktopColors } from "./desktop/palette.ts";
51
+
52
+ const STRIP_WIDGET = "pi-look:strip";
53
+ const TICK_MS = 500;
54
+ /** The thinking tail changes on every token; the spinner line does not need to. */
55
+ const PEEK_MS = 120;
56
+
57
+ interface Live {
58
+ ctx: ExtensionContext | undefined;
59
+ footerData: ReadonlyFooterDataProvider | undefined;
60
+ tui: TUI | undefined;
61
+ repaint: (() => void) | undefined;
62
+ git: GitState | undefined;
63
+ probing: boolean;
64
+ ticker: ReturnType<typeof setInterval> | undefined;
65
+ stopWatching: (() => void) | undefined;
66
+ /** The frame's snapshot, shared by every component drawn in the same frame. */
67
+ frame: Snapshot | undefined;
68
+ activity: string | undefined;
69
+ peekAt: number;
70
+ }
71
+
72
+ export const look = defineFeature({
73
+ id: "look",
74
+ description:
75
+ "The start screen, a framed editor, the footer and a working line that says what runs",
76
+ settings: LOOK_SETTINGS,
77
+ setup(scope) {
78
+ const telemetry = new Telemetry();
79
+ const reader = new SnapshotReader();
80
+ const live: Live = {
81
+ ctx: undefined,
82
+ footerData: undefined,
83
+ tui: undefined,
84
+ repaint: undefined,
85
+ git: undefined,
86
+ probing: false,
87
+ ticker: undefined,
88
+ stopWatching: undefined,
89
+ frame: undefined,
90
+ activity: undefined,
91
+ peekAt: 0,
92
+ };
93
+
94
+ const screen = createScreen(scope, live, reader, telemetry);
95
+ const repaint = () => live.repaint?.();
96
+
97
+ for (const entry of LOOK_SETTINGS) entry.listen(scope, repaint);
98
+ frame.listen(scope, () => live.ctx && installEditor(live.ctx, screen, live, scope));
99
+ header.listen(scope, () => live.ctx && installHeader(live.ctx, screen, scope));
100
+ cursor.listen(scope, (style) => live.tui && applyCursor(live.tui, style));
101
+ peek.listen(scope, (on) => !on && live.ctx?.ui.setWorkingMessage());
102
+
103
+ scope.onSessionStart(async (ctx) => {
104
+ live.ctx = ctx;
105
+ telemetry.reset();
106
+ void probeGit(live);
107
+ if (ctx.mode !== "tui") return;
108
+
109
+ ctx.ui.setFooter(
110
+ (tui, _theme, data) =>
111
+ new FooterComponent(tui, data, screen, {
112
+ attach: (footerData, paint) => {
113
+ live.footerData = footerData;
114
+ live.repaint = paint;
115
+ },
116
+ detach: () => {
117
+ live.footerData = undefined;
118
+ live.repaint = undefined;
119
+ },
120
+ }),
121
+ );
122
+ ctx.ui.setWidget(STRIP_WIDGET, () => new StripComponent(screen), {
123
+ placement: "aboveEditor",
124
+ });
125
+ installEditor(ctx, screen, live, scope);
126
+ installHeader(ctx, screen, scope);
127
+ if (desktop.get(scope)) await startDesktopSync(ctx, scope, live);
128
+ });
129
+
130
+ scope.onShutdown(() => {
131
+ stopTicking(live);
132
+ live.stopWatching?.();
133
+ live.stopWatching = undefined;
134
+ const ctx = live.ctx;
135
+ if (ctx?.mode === "tui") {
136
+ ctx.ui.setFooter(undefined);
137
+ ctx.ui.setWidget(STRIP_WIDGET, undefined);
138
+ ctx.ui.setHeader(undefined);
139
+ ctx.ui.setEditorComponent(undefined);
140
+ ctx.ui.setWorkingMessage();
141
+ }
142
+ if (live.tui !== undefined) applyCursor(live.tui, "block");
143
+ live.ctx = undefined;
144
+ live.tui = undefined;
145
+ });
146
+
147
+ scope.on("agent_start", () => {
148
+ telemetry.runStarted();
149
+ setActivity(live, scope, { kind: "waiting" });
150
+ startTicking(live);
151
+ });
152
+
153
+ scope.on("turn_start", () => {
154
+ telemetry.requestStarted();
155
+ setActivity(live, scope, { kind: "waiting" });
156
+ });
157
+
158
+ scope.on("message_start", (event) => {
159
+ if (event.message.role === "assistant") telemetry.answerStarted();
160
+ });
161
+
162
+ scope.on("message_update", (event) => {
163
+ const message = event.message;
164
+ if (message.role !== "assistant") return;
165
+ const update = event.assistantMessageEvent;
166
+ const delta = "delta" in update ? update.delta : "";
167
+ telemetry.answerGrew(delta, message.usage);
168
+
169
+ if (update.type === "thinking_delta") {
170
+ const now = Date.now();
171
+ if (now - live.peekAt < PEEK_MS) return;
172
+ live.peekAt = now;
173
+ setActivity(live, scope, { kind: "thinking", tail: thinkingTail(message.content) });
174
+ } else if (update.type === "text_delta" || update.type === "text_start") {
175
+ setActivity(live, scope, { kind: "writing" });
176
+ } else if (update.type === "toolcall_start" || update.type === "toolcall_delta") {
177
+ const tool = callingTool(message.content);
178
+ if (tool !== undefined) setActivity(live, scope, { kind: "calling", tool });
179
+ }
180
+ });
181
+
182
+ scope.on("message_end", (event) => {
183
+ if (event.message.role !== "assistant") return;
184
+ const record = telemetry.answerEnded(event.message.usage);
185
+ // Kept in the session, so the average speeds survive a resume, and the usage ledger gets a
186
+ // duration, which pi does not record.
187
+ if (record !== undefined) scope.appendEntry<RequestRecord>(REQUEST_ENTRY, record);
188
+ repaint();
189
+ });
190
+
191
+ scope.on("tool_execution_start", (event) => {
192
+ setActivity(live, scope, {
193
+ kind: "running",
194
+ tool: event.toolName,
195
+ detail: toolDetail(event.args),
196
+ });
197
+ });
198
+
199
+ // A command may have moved the branch or touched the tree.
200
+ scope.on("tool_execution_end", () => void probeGit(live));
201
+
202
+ scope.on("agent_end", () => {
203
+ telemetry.runEnded();
204
+ stopTicking(live);
205
+ live.activity = undefined;
206
+ live.ctx?.ui.setWorkingMessage();
207
+ repaint();
208
+ });
209
+
210
+ scope.on("agent_settled", () => void probeGit(live));
211
+ scope.on("model_select", repaint);
212
+ scope.on("thinking_level_select", repaint);
213
+ scope.on("session_compact", repaint);
214
+ scope.on("session_tree", repaint);
215
+
216
+ scope.registerCommand("look", {
217
+ description:
218
+ "Look: `explain` says what every piece on screen is, `theme` switches to the desktop theme",
219
+ getArgumentCompletions: (prefix) =>
220
+ ["explain", "theme"]
221
+ .filter((name) => name.startsWith(prefix.trim()))
222
+ .map((name) => ({ value: name, label: name })),
223
+ handler: async (args, ctx) => {
224
+ const action = args.trim();
225
+ if (action === "" || action === "explain") {
226
+ ctx.ui.notify(explain(screen), "info");
227
+ return;
228
+ }
229
+ if (action !== "theme") {
230
+ ctx.ui.notify("usage: /look explain | /look theme", "info");
231
+ return;
232
+ }
233
+ const result = await syncDesktopTheme(desktopSource.get(scope));
234
+ if (result.kind === "missing" || result.kind === "invalid") {
235
+ const why = result.kind === "missing" ? "no palette file" : result.reason;
236
+ ctx.ui.notify(`look: cannot build the desktop theme: ${why}`, "warning");
237
+ return;
238
+ }
239
+ const switched = ctx.ui.setTheme(DESKTOP_THEME);
240
+ ctx.ui.notify(
241
+ switched.success
242
+ ? `look: now on the ${DESKTOP_THEME} theme, from ${desktopSource.get(scope)}`
243
+ : `look: wrote ${result.path}, but pi did not load it: ${switched.error ?? "unknown"}`,
244
+ switched.success ? "info" : "warning",
245
+ );
246
+ },
247
+ });
248
+ },
249
+ });
250
+
251
+ const SLOT_TITLES: Record<SlotName, string> = {
252
+ strip: "Above the editor, this answer",
253
+ topLeft: "Frame, top left",
254
+ topRight: "Frame, top right",
255
+ bottomLeft: "Frame, bottom left",
256
+ bottomRight: "Frame, bottom right",
257
+ below: "Below the editor, the session",
258
+ };
259
+
260
+ /** Every piece on screen, where it is, what it shows now and what it means. */
261
+ function explain(screen: Screen): string {
262
+ const snapshot = screen.snapshot();
263
+ const paint = screen.paint();
264
+ const lines: string[] = [paint.bold("What is on screen")];
265
+ for (const name of Object.keys(SLOT_TITLES) as SlotName[]) {
266
+ const ids = screen.slot(name);
267
+ if (ids.length === 0) continue;
268
+ lines.push("", paint.role("brand", SLOT_TITLES[name]));
269
+ for (const id of ids) {
270
+ const [piece] = renderSegments([id], {
271
+ snapshot,
272
+ glyphs: screen.glyphs(),
273
+ paint,
274
+ options: screen.options(),
275
+ });
276
+ const meaning = id.startsWith("status:")
277
+ ? `what ${id.slice("status:".length)} reports`
278
+ : (SEGMENTS[id as keyof typeof SEGMENTS]?.describe ?? "");
279
+ const shown = piece === undefined ? paint.dim("(nothing yet)") : piece.text;
280
+ lines.push(` ${shown}`, ` ${paint.dim(`${id}: ${meaning}`)}`);
281
+ }
282
+ }
283
+ lines.push(
284
+ "",
285
+ paint.dim("Arrows are from where you sit: ↑ in is sent to the model, ↓ out comes back."),
286
+ paint.dim(
287
+ "Slots and labels live under look. in ~/.pi/agent/extensions/pi-harness/settings.json.",
288
+ ),
289
+ );
290
+ return lines.join("\n");
291
+ }
292
+
293
+ function createScreen(
294
+ scope: FeatureScope,
295
+ live: Live,
296
+ reader: SnapshotReader,
297
+ telemetry: Telemetry,
298
+ ): Screen {
299
+ const slots: Record<SlotName, typeof strip> = {
300
+ strip,
301
+ topLeft,
302
+ topRight,
303
+ bottomLeft,
304
+ bottomRight,
305
+ below,
306
+ };
307
+
308
+ return {
309
+ snapshot() {
310
+ const ctx = live.ctx;
311
+ if (ctx === undefined) return emptySnapshot();
312
+ // Every component of one frame renders in the same tick, so they share one read.
313
+ if (live.frame !== undefined) return live.frame;
314
+ const snapshot = reader.read(ctx, {
315
+ branch: live.footerData?.getGitBranch() ?? null,
316
+ git: live.git,
317
+ request: telemetry.request(),
318
+ last: telemetry.last(),
319
+ run: telemetry.run(),
320
+ statuses: live.footerData?.getExtensionStatuses() ?? new Map(),
321
+ });
322
+ live.frame = snapshot;
323
+ queueMicrotask(() => {
324
+ live.frame = undefined;
325
+ });
326
+ return snapshot;
327
+ },
328
+ glyphs: () => resolveIcons(icons.get(scope)),
329
+ paint: (border) => (live.ctx === undefined ? PLAIN : themePaint(live.ctx.ui.theme, border)),
330
+ separator: () => SEPARATORS[separator.get(scope)],
331
+ options: () => ({
332
+ pathLength: pathLength.get(scope),
333
+ gaugeCells: gaugeCells.get(scope),
334
+ labels: labels.get(scope),
335
+ claimed: claimedStatuses(Object.values(slots).map((entry) => entry.get(scope))),
336
+ }),
337
+ slot: (name) => slots[name].get(scope),
338
+ frameStyle: () => frame.get(scope),
339
+ cursor: () => cursor.get(scope),
340
+ };
341
+ }
342
+
343
+ function installEditor(
344
+ ctx: ExtensionContext,
345
+ screen: Screen,
346
+ live: Live,
347
+ scope: FeatureScope,
348
+ ): void {
349
+ if (ctx.mode !== "tui") return;
350
+ if (frame.get(scope) === "off") {
351
+ ctx.ui.setEditorComponent(undefined);
352
+ return;
353
+ }
354
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
355
+ live.tui = tui;
356
+ applyCursor(tui, cursor.get(scope));
357
+ return new LookEditor(tui, theme, keybindings, screen);
358
+ });
359
+ }
360
+
361
+ function installHeader(ctx: ExtensionContext, screen: Screen, scope: FeatureScope): void {
362
+ if (ctx.mode !== "tui") return;
363
+ if (header.get(scope) === "off") {
364
+ ctx.ui.setHeader(undefined);
365
+ return;
366
+ }
367
+ ctx.ui.setHeader(
368
+ () =>
369
+ new HeaderComponent(screen, {
370
+ style: () => header.get(scope),
371
+ counts: () => loadedCounts(scope),
372
+ }),
373
+ );
374
+ }
375
+
376
+ /** What loaded, from pi's own lists: active tools, skill and prompt commands, extension files. */
377
+ function loadedCounts(pi: ExtensionAPI): LoadedCounts {
378
+ const commands = pi.getCommands();
379
+ const extensions = new Set<string>();
380
+ for (const command of commands) {
381
+ if (command.source === "extension") extensions.add(command.sourceInfo.path);
382
+ }
383
+ for (const tool of pi.getAllTools()) {
384
+ const source = tool.sourceInfo.source;
385
+ if (source !== "builtin" && source !== "sdk") extensions.add(tool.sourceInfo.path);
386
+ }
387
+ return {
388
+ tools: pi.getActiveTools().length,
389
+ skills: commands.filter((command) => command.source === "skill").length,
390
+ prompts: commands.filter((command) => command.source === "prompt").length,
391
+ extensions: extensions.size,
392
+ };
393
+ }
394
+
395
+ async function startDesktopSync(ctx: ExtensionContext, scope: FeatureScope, live: Live) {
396
+ const source = desktopSource.get(scope);
397
+ const sync = async () => {
398
+ const result = await syncDesktopTheme(source);
399
+ if (result.kind === "invalid") scope.warn(`desktop theme: ${result.reason}`);
400
+ return result;
401
+ };
402
+
403
+ const first = await sync();
404
+ if (first.kind === "missing") return;
405
+ if (first.kind === "written" && ctx.ui.theme.name !== DESKTOP_THEME) {
406
+ ctx.ui.notify(`look: wrote the ${DESKTOP_THEME} theme. /look theme switches to it.`, "info");
407
+ }
408
+ live.stopWatching?.();
409
+ live.stopWatching = watchDesktopPalette(source, () => void sync());
410
+ }
411
+
412
+ function setActivity(live: Live, scope: FeatureScope, activity: Activity): void {
413
+ if (!peek.get(scope)) return;
414
+ const message = describe(activity);
415
+ if (message === live.activity) return;
416
+ live.activity = message;
417
+ live.ctx?.ui.setWorkingMessage(message);
418
+ }
419
+
420
+ /** Only one at a time, and never while a render is waiting on it. */
421
+ async function probeGit(live: Live): Promise<void> {
422
+ const ctx = live.ctx;
423
+ if (ctx === undefined || live.probing) return;
424
+ live.probing = true;
425
+ try {
426
+ live.git = await readGit(ctx.cwd);
427
+ live.repaint?.();
428
+ } finally {
429
+ live.probing = false;
430
+ }
431
+ }
432
+
433
+ /** The clocks have to move on their own while the model thinks and nothing else happens. */
434
+ function startTicking(live: Live): void {
435
+ stopTicking(live);
436
+ live.ticker = setInterval(() => live.repaint?.(), TICK_MS);
437
+ live.ticker.unref?.();
438
+ }
439
+
440
+ function stopTicking(live: Live): void {
441
+ if (live.ticker === undefined) return;
442
+ clearInterval(live.ticker);
443
+ live.ticker = undefined;
444
+ }
445
+
446
+ export default function piLook(pi: ExtensionAPI): void {
447
+ createApp(pi, { name: "pi-harness" }).use(look).build();
448
+ }
@@ -0,0 +1,89 @@
1
+ // Making pieces fit. Every piece has a priority and may have a shorter form. When a line is too wide,
2
+ // the least important pieces shorten first, then leave, one at a time, until it fits. Regions that
3
+ // share a line (the left and right of a border) compete for the same width, so an unimportant piece on
4
+ // the right leaves before an important one on the left.
5
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
+
7
+ export interface Piece {
8
+ text: string;
9
+ /** A shorter form, used before the piece is dropped. */
10
+ compact?: string | undefined;
11
+ /** Higher stays longer. */
12
+ priority: number;
13
+ }
14
+
15
+ export interface FitOptions {
16
+ separator: string;
17
+ /** Width a region costs on top of its pieces when it has any, like the padding around it. */
18
+ regionOverhead?: number;
19
+ ellipsis?: string;
20
+ }
21
+
22
+ interface Slot {
23
+ region: number;
24
+ order: number;
25
+ piece: Piece;
26
+ text: string;
27
+ dropped: boolean;
28
+ }
29
+
30
+ /** One string per region, each the kept pieces joined by the separator. */
31
+ export function fitRegions(
32
+ regions: readonly (readonly Piece[])[],
33
+ width: number,
34
+ options: FitOptions,
35
+ ): string[] {
36
+ const slots: Slot[] = regions.flatMap((pieces, region) =>
37
+ pieces.map((piece, order) => ({ region, order, piece, text: piece.text, dropped: false })),
38
+ );
39
+ const byPriority = slots.toSorted((a, b) => a.piece.priority - b.piece.priority);
40
+ const measure = (): number => measureRegions(regions.length, slots, options);
41
+
42
+ for (const slot of byPriority) {
43
+ if (measure() <= width) break;
44
+ if (slot.piece.compact !== undefined) slot.text = slot.piece.compact;
45
+ }
46
+
47
+ for (const slot of byPriority) {
48
+ if (measure() <= width) break;
49
+ if (slots.filter((candidate) => !candidate.dropped).length <= 1) break;
50
+ slot.dropped = true;
51
+ }
52
+
53
+ const joined = join(regions.length, slots, options.separator);
54
+ if (measure() <= width) return joined;
55
+
56
+ // One piece left and still too wide: cut it, keeping the overhead of its region.
57
+ const overhead = options.regionOverhead ?? 0;
58
+ return joined.map((text) =>
59
+ text === ""
60
+ ? ""
61
+ : truncateToWidth(text, Math.max(0, width - overhead), options.ellipsis ?? "…"),
62
+ );
63
+ }
64
+
65
+ /** A single run of pieces, as one string. */
66
+ export function fitLine(pieces: readonly Piece[], width: number, options: FitOptions): string {
67
+ return fitRegions([pieces], width, options)[0] ?? "";
68
+ }
69
+
70
+ function join(count: number, slots: readonly Slot[], separator: string): string[] {
71
+ const regions: string[][] = Array.from({ length: count }, () => []);
72
+ for (const slot of slots.toSorted((a, b) => a.order - b.order)) {
73
+ if (!slot.dropped) regions[slot.region]?.push(slot.text);
74
+ }
75
+ return regions.map((texts) => texts.join(separator));
76
+ }
77
+
78
+ function measureRegions(count: number, slots: readonly Slot[], options: FitOptions): number {
79
+ const separatorWidth = visibleWidth(options.separator);
80
+ let total = 0;
81
+ for (let region = 0; region < count; region++) {
82
+ const kept = slots.filter((slot) => slot.region === region && !slot.dropped);
83
+ if (kept.length === 0) continue;
84
+ total += options.regionOverhead ?? 0;
85
+ total += kept.reduce((sum, slot) => sum + visibleWidth(slot.text), 0);
86
+ total += separatorWidth * (kept.length - 1);
87
+ }
88
+ return total;
89
+ }
@@ -0,0 +1,136 @@
1
+ // Numbers as a person reads them at a glance. Pure, so every rule here has a test.
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+
4
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+
6
+ /**
7
+ * The widest each kind of number gets in normal use. A value is padded to its cell, so a number that
8
+ * ticks from `9.8` to `114` to `2.8k` does not push the rest of the line around.
9
+ */
10
+ export const CELL = {
11
+ count: 4,
12
+ rate: 4,
13
+ latency: 5,
14
+ /** `0:08` to `9:59`, then one more for `59:59`. */
15
+ clock: 4,
16
+ percent: 4,
17
+ money: 6,
18
+ } as const;
19
+
20
+ /** Right-aligned in a cell of `width` columns. Longer values keep their length. */
21
+ export function cell(text: string, width: number): string {
22
+ return " ".repeat(Math.max(0, width - visibleWidth(text))) + text;
23
+ }
24
+
25
+ /** What a cell shows before its value is known, so the cell is there from the start. */
26
+ export const UNKNOWN = "–";
27
+
28
+ /** `1.2k`, `43k`, `1.2M`, `22M`, the way pi writes token counts. */
29
+ export function count(value: number): string {
30
+ const n = Math.max(0, Math.round(value));
31
+ if (n < 1000) return String(n);
32
+ if (n < 10_000) return `${trim((n / 1000).toFixed(1))}k`;
33
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
34
+ if (n < 10_000_000) return `${trim((n / 1_000_000).toFixed(1))}M`;
35
+ return `${Math.round(n / 1_000_000)}M`;
36
+ }
37
+
38
+ /** A speed: `42`, `906`, `1.2k`, `48k` tokens per second. */
39
+ export function rate(perSecond: number): string {
40
+ if (perSecond < 10) return trim(perSecond.toFixed(1));
41
+ return count(perSecond);
42
+ }
43
+
44
+ /** `2m 17s`, `1h 4m`, for how long something has been running. */
45
+ export function duration(milliseconds: number): string {
46
+ const total = Math.max(0, Math.floor(milliseconds / 1000));
47
+ const secs = total % 60;
48
+ const minutes = Math.floor(total / 60) % 60;
49
+ const hours = Math.floor(total / 3600);
50
+ if (hours > 0) return `${hours}h ${minutes}m`;
51
+ if (minutes > 0) return `${minutes}m ${secs}s`;
52
+ return `${secs}s`;
53
+ }
54
+
55
+ /** `0:08`, `2:17`, `1:04:09`: a stopwatch, which keeps its shape as it counts. */
56
+ export function stopwatch(milliseconds: number): string {
57
+ const total = Math.max(0, Math.floor(milliseconds / 1000));
58
+ const secs = String(total % 60).padStart(2, "0");
59
+ const minutes = Math.floor(total / 60) % 60;
60
+ const hours = Math.floor(total / 3600);
61
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`;
62
+ }
63
+
64
+ /** `320ms`, `1.7s`, `12s`, for latencies, where the fraction matters under ten seconds. */
65
+ export function latency(milliseconds: number): string {
66
+ if (milliseconds < 1000) return `${Math.round(milliseconds)}ms`;
67
+ if (milliseconds < 10_000) return `${(milliseconds / 1000).toFixed(1)}s`;
68
+ return duration(milliseconds);
69
+ }
70
+
71
+ /** Three decimals while it is cents, two once it is dollars. */
72
+ export function money(amount: number): string {
73
+ return amount >= 10 ? `$${amount.toFixed(2)}` : `$${amount.toFixed(3)}`;
74
+ }
75
+
76
+ export function percent(value: number): string {
77
+ return value >= 10 || value === 0 ? `${Math.round(value)}%` : `${value.toFixed(1)}%`;
78
+ }
79
+
80
+ /** `~` for the home folder, and the tail kept when it is long, since the tail says where you are. */
81
+ export function shortenPath(cwd: string, home: string | undefined, maxWidth: number): string {
82
+ const full = home === undefined ? cwd : underHome(cwd, home);
83
+ if (maxWidth <= 0 || visibleWidth(full) <= maxWidth) return full;
84
+
85
+ const parts = full.split(sep);
86
+ let kept = "";
87
+ for (let index = parts.length - 1; index > 0; index--) {
88
+ const candidate = `${sep}${parts[index] ?? ""}${kept}`;
89
+ if (visibleWidth(`…${candidate}`) > maxWidth) break;
90
+ kept = candidate;
91
+ }
92
+ if (kept === "") return truncateTail(parts.at(-1) ?? full, maxWidth);
93
+ return `…${kept}`;
94
+ }
95
+
96
+ export function basename(cwd: string): string {
97
+ return cwd.split(/[\\/]/).findLast(Boolean) ?? cwd;
98
+ }
99
+
100
+ /** A gauge of `cells`, filled to `value` out of 100. */
101
+ export function gauge(value: number, cells: number): { filled: number; empty: number } {
102
+ const filled = Math.max(0, Math.min(cells, Math.round((value / 100) * cells)));
103
+ return { filled, empty: cells - filled };
104
+ }
105
+
106
+ /** Clip to the start, keeping the end: for the tail of a thought, which is the newest part. */
107
+ export function truncateTail(text: string, maxWidth: number): string {
108
+ if (maxWidth <= 0) return "";
109
+ if (visibleWidth(text) <= maxWidth) return text;
110
+ const chars = [...text];
111
+ let width = 1;
112
+ let start = chars.length;
113
+ while (start > 0) {
114
+ const next = visibleWidth(chars[start - 1] ?? "");
115
+ if (width + next > maxWidth) break;
116
+ width += next;
117
+ start--;
118
+ }
119
+ return `…${chars.slice(start).join("")}`;
120
+ }
121
+
122
+ export function fitWidth(text: string, width: number, ellipsis = "…"): string {
123
+ return truncateToWidth(text, Math.max(0, width), ellipsis);
124
+ }
125
+
126
+ function underHome(cwd: string, home: string): string {
127
+ const rest = relative(resolve(home), resolve(cwd));
128
+ const inside =
129
+ rest === "" || (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest));
130
+ if (!inside) return cwd;
131
+ return rest === "" ? "~" : `~${sep}${rest}`;
132
+ }
133
+
134
+ function trim(fixed: string): string {
135
+ return fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed;
136
+ }