@adeildo/pi-look 5.1.0 → 5.2.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 CHANGED
@@ -1,64 +1,76 @@
1
- # @adeildo/pi-look
1
+ <h1 align="center">@adeildo/pi-look</h1>
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@adeildo/pi-look)](https://www.npmjs.com/package/@adeildo/pi-look)
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/@adeildo/pi-look"><img src="https://img.shields.io/npm/v/@adeildo/pi-look" alt="npm"></a>
5
+ <a href="https://pi.dev"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffelipeadeildo%2Fpi-harness%2Fmain%2Fpackage.json&query=%24.devDependencies%5B%22%40earendil-works%2Fpi-coding-agent%22%5D&label=pi%20SDK&color=6E56CF" alt="pi SDK"></a>
6
+ </p>
4
7
 
5
- [Pi](https://pi.dev)'s start screen, editor, footer and working line, redrawn around the numbers that matter while the agent works. It paints only with the colours of the theme you run, and the screen holds still while the numbers change.
8
+ <p align="center">
9
+ <strong>Pi's screen, drawn around the numbers that matter while the agent works.</strong><br>
10
+ The start card, the editor framed with the branch and the model, a box around every command, and a footer with what the session costs.
11
+ </p>
6
12
 
7
- ```bash
8
- pi install npm:@adeildo/pi-look
9
- ```
13
+ <p align="center"><code>pi install npm:@adeildo/pi-look</code></p>
10
14
 
11
- It also comes in [`@adeildo/pi-harness`](../harness), with the rest of the pieces.
15
+ <!-- docs:look/preview -->
12
16
 
13
17
  <p align="center">
14
- <img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/look/assets/preview.png" alt="The start card with the model, the folder, the branch and the keys, and below it the framed editor with the branch and folder in its top border and the model and context in its bottom border." width="860">
18
+ <img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/look/assets/preview.png" alt="Pi with the look: the start card, then the strip with the stopwatch and the last call, the framed editor with the branch, the model and the context, and below it the cost, the tokens, the cache and the average speeds." width="860">
19
+ <br>
20
+ <em>The start card, then a prompt halfway through its answer.</em>
15
21
  </p>
16
22
 
17
- ## What is on the screen
23
+ <!-- /docs -->
18
24
 
19
- **The start card** says what the session starts with: the model and its effort, the folder and the state of the tree, the machine, how many tools, skills, prompts and extensions loaded, and the keys worth knowing, read from your keybindings.
25
+ ## The screen
20
26
 
21
- **The strip above the editor** is calm on purpose. Nothing in it moves while the model streams except the stopwatch.
27
+ A finished answer takes a box of its own, so you can see what the model wants to run before it runs. The mark of the tool and its name sit on the top line, the command under them, and the reason it was allowed under that, when another model answered it. A line cuts the command off from what it printed, and the bottom line says how it went: a check, a cross, and how long it took. A command still running says `running` with the time so far. A tool from an MCP server shows its server and tool instead of `mcp__server__tool`.
22
28
 
23
- | Piece | Means |
24
- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `0:12 3 calls` | How long the agent has worked on this prompt, and how many calls to the model it made |
26
- | `last call 1.7s wait 900ms thought ↓906 tok/s` | The last call that finished: how long the model took to start, how long it thought before writing, and how fast it wrote. Final numbers |
29
+ <!-- docs:look/call -->
27
30
 
28
- **The frame** around the editor carries the branch with its distance and changes, the folder, the permission mode, the model with an effort meter, and the context. The border takes pi's own effort colour, so it warms up as the model thinks harder and turns to the bash colour in shell mode.
31
+ <p align="center">
32
+ <img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/look/assets/call.png" alt="Three commands, each in its own box: a bash command with the reason it was allowed and the time it took, a file read, and a command still running." width="860">
33
+ <br>
34
+ <em>One command that ran, one a rule let through, and one still going.</em>
35
+ </p>
29
36
 
30
- **The spinner** names the state instead of saying "Working": `waiting` for the first token, `thinking`, `writing`, `drafting bash` while the model writes the call, `running bash` while it runs.
37
+ <!-- /docs -->
31
38
 
32
- **The footer** is the session: the cost, tokens sent and received, the cache hit of the last prompt, the average speeds, and last what other packages report, because it changes the most.
39
+ The strip above the editor is calm on purpose. Nothing moves while the model streams except the stopwatch.
33
40
 
34
- ## It holds still
41
+ | Piece | Means |
42
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
43
+ | `0:12 3 calls` | How long the agent has worked on this prompt, and how many calls to the model it made |
44
+ | `last call 1.7s wait 900ms thought ↓906 tok/s` | The last call: how long the model took to start, how long it thought then wrote, and how fast it writes |
35
45
 
36
- Numbers change on every frame and the screen must not move with them.
46
+ The border around the editor carries the branch with its distance and changes, the folder, the permission mode, the model with an effort meter, and the context. It takes pi's own effort colour, so it warms up as the model thinks harder and turns to the shell colour in bash mode.
37
47
 
38
- - Every number sits in a cell of fixed width, so ` 17` and `2.8k` take the same room.
39
- - A value not known yet shows as `–` in its cell and does not appear later in the middle of the line.
40
- - The working time is a stopwatch, `0:59` then `1:00`, instead of `59s` then `1m 0s`.
41
- - An estimate is drawn quieter and does not gain a `~`.
42
- - The strip and the footer keep their line from the start, so the editor does not jump when the first answer arrives.
48
+ The spinner names the state instead of saying "Working": `waiting` for the first token, `thinking`, `writing`, `drafting bash` while the model writes the call, `running bash` while it runs.
49
+
50
+ The footer is the session: the cost, the tokens sent and received, the cache of the last prompt, the average speeds, and last what other packages report, because that changes the most.
43
51
 
44
- Every finished answer leaves a small entry in the session with its time to first token and writing time, because pi records usage without a duration. That keeps the averages right after a resume.
52
+ ## It holds still
45
53
 
46
- ## Your theme, not ours
54
+ Numbers change on every frame, and the screen must not move with them.
47
55
 
48
- The look never names a colour. Every piece names a **theme token**, so it follows whatever theme pi runs: `system`, `dark`, `light`, a JSON of yours, or the desktop theme below. Change the theme and every piece moves with it. The [reference](docs/reference.md#colours) maps each piece to its token.
56
+ - Every number sits in a cell of fixed width, so ` 17` and `2.8k` take the same room.
57
+ - A value nobody knows yet shows as `–` and does not appear later in the middle of the line.
58
+ - The working time counts as a stopwatch, `0:59` then `1:00`.
59
+ - An estimate is drawn quieter, so you know it is one.
60
+ - The strip and the footer hold their line from the start, so the editor does not jump when the first answer arrives.
49
61
 
50
- ### The desktop theme
62
+ Pi records usage without a duration, so every finished answer leaves one small entry in the session with its time to the first token and its writing time. That is what keeps the averages right after a resume.
51
63
 
52
- If you run [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), matugen already made a palette from your wallpaper. The look turns it into the pi theme `desktop`, writes it to `~/.pi/agent/themes/desktop.json` and rewrites it when the palette changes. Pi reloads the active theme from that folder by itself, so a new wallpaper recolours a running pi. `Switch to it now`, in the Desktop theme section of the settings (`Alt+S`), writes the theme and makes it pi's.
64
+ ## Your theme
53
65
 
54
- ## Settings
66
+ The look never names a colour. Every piece names a theme token, so it follows whatever theme pi runs, and it moves with it when you change themes. The [reference](docs/reference.md#colours) maps each piece to its token.
55
67
 
56
- `Alt+S` opens them, on the Look tab. You can reorder and move every piece of the strip, the frame and the footer, pick the frame style, the icons and the separator, and switch off what you do not want. The [reference](docs/reference.md#settings) lists every key and every segment.
68
+ If you run [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), matugen already made a palette from your wallpaper. The look turns it into the pi theme `desktop` and rewrites that file when the palette changes, so a new wallpaper recolours a running pi. `Switch to it now`, in the settings, does that at once.
57
69
 
58
- ## Credit
70
+ ## Settings
59
71
 
60
- The framed editor and the idea of writing into its borders come from [pi-open-tui](https://github.com/OldSuns/pi-open-tui), which this replaces. The segments with priorities, and the status line in the editor border, come from [oh-my-pi](https://github.com/can1357/oh-my-pi).
72
+ `Alt+S` opens them on the Look tab. You can reorder every piece of the strip, the frame and the footer, pick the frame style, the icons and the separator, and switch off what you do not want. The [reference](docs/reference.md#settings) lists every key and every segment.
61
73
 
62
- ## License
74
+ The framed editor, and the idea of writing into its borders, come from [pi-open-tui](https://github.com/OldSuns/pi-open-tui), which this replaces. The segments with priorities come from [oh-my-pi](https://github.com/can1357/oh-my-pi).
63
75
 
64
- [MIT](LICENSE)
76
+ Part of [pi-harness](https://github.com/felipeadeildo/pi-harness), which brings every piece in one install.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeildo/pi-look",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
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.1.0"
41
+ "@adeildo/pi-kit": "5.2.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@earendil-works/pi-ai": "*",
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ import { applyCursor, LookEditor } from "./ui/editor.ts";
42
42
  import { FooterComponent, StripComponent } from "./ui/footer.ts";
43
43
  import { HeaderComponent, type LoadedCounts } from "./ui/header.ts";
44
44
  import type { Screen, SlotName } from "./ui/screen.ts";
45
+ import { registerToolFrames } from "./ui/tool-frame.ts";
45
46
  import { type Activity, describe, draftedTool } from "./ui/working.ts";
46
47
 
47
48
  export { SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
@@ -87,6 +88,7 @@ export const look = defineFeature({
87
88
  };
88
89
 
89
90
  const screen = createScreen(scope, live, reader, telemetry);
91
+ registerToolFrames(scope);
90
92
  const repaint = () => live.repaint?.();
91
93
 
92
94
  for (const entry of LOOK_SETTINGS) entry.listen(scope, repaint);
@@ -0,0 +1,105 @@
1
+ // The mark and the name a tool call is drawn with. The mark follows the icon set in the look's
2
+ // settings, like every other glyph on the screen.
3
+ import type { IconSet } from "./glyphs.ts";
4
+
5
+ type ToolKind = "shell" | "read" | "search" | "edit" | "web" | "agent" | "mcp" | "other";
6
+
7
+ const ICONS: Record<IconSet, Record<ToolKind, string>> = {
8
+ nerd: {
9
+ shell: "\u{f120}", // nf-fa-terminal
10
+ read: "\u{f15c}", // nf-fa-file_lines
11
+ search: "\u{f002}", // nf-fa-search
12
+ edit: "\u{f044}", // nf-fa-pencil
13
+ web: "\u{f0ac}", // nf-fa-globe
14
+ agent: "\u{f06a9}", // nf-md-robot
15
+ mcp: "\u{f1e6}", // nf-fa-plug
16
+ other: "\u{f013}", // nf-fa-gear
17
+ },
18
+ unicode: {
19
+ shell: "\u276f",
20
+ read: "\u25a4",
21
+ search: "\u2315",
22
+ edit: "\u270e",
23
+ web: "\u25cd",
24
+ agent: "\u25c6",
25
+ mcp: "\u226b",
26
+ other: "\u25cb",
27
+ },
28
+ ascii: {
29
+ shell: "$",
30
+ read: "=",
31
+ search: "?",
32
+ edit: "+",
33
+ web: "@",
34
+ agent: "*",
35
+ mcp: ">",
36
+ other: "-",
37
+ },
38
+ };
39
+
40
+ export type CallPhase = "writing" | "waiting" | "running" | "done" | "failed";
41
+
42
+ const MARKS: Record<IconSet, Record<CallPhase, string>> = {
43
+ nerd: {
44
+ writing: "\u{f044}", // nf-fa-pencil
45
+ waiting: "\u{f252}", // nf-fa-hourglass_half
46
+ running: "\u{f017}", // nf-fa-clock_o
47
+ done: "\u{f00c}", // nf-fa-check
48
+ failed: "\u{f00d}", // nf-fa-times
49
+ },
50
+ unicode: {
51
+ writing: "\u270e",
52
+ waiting: "\u29d6",
53
+ running: "\u25f7",
54
+ done: "\u2713",
55
+ failed: "\u2717",
56
+ },
57
+ ascii: { writing: "~", waiting: ":", running: "...", done: "ok", failed: "!" },
58
+ };
59
+
60
+ export function stateMark(phase: CallPhase, set: IconSet): string {
61
+ return MARKS[set][phase];
62
+ }
63
+
64
+ export function toolIcon(toolName: string, set: IconSet): string {
65
+ return ICONS[set][toolKind(toolName)];
66
+ }
67
+
68
+ /** The name as it reads on the frame: an MCP tool says its server rather than `mcp__server__tool`. */
69
+ export function toolLabel(toolName: string): string {
70
+ const mcp = toolName.match(/^mcp__([^_]+)__(.+)$/u);
71
+ return mcp === null ? toolName : `${mcp[1]}:${mcp[2]}`;
72
+ }
73
+
74
+ function toolKind(toolName: string): ToolKind {
75
+ if (toolName.startsWith("mcp__")) return "mcp";
76
+ switch (toolName) {
77
+ case "bash":
78
+ case "powershell":
79
+ case "shell":
80
+ return "shell";
81
+ case "read":
82
+ case "ls":
83
+ case "find":
84
+ return "read";
85
+ case "grep":
86
+ case "glob":
87
+ case "tool_search":
88
+ return "search";
89
+ case "edit":
90
+ case "write":
91
+ case "apply_patch":
92
+ return "edit";
93
+ case "web_search":
94
+ case "fetch":
95
+ case "webfetch":
96
+ case "web_fetch":
97
+ return "web";
98
+ case "task":
99
+ case "agent":
100
+ case "subagent":
101
+ return "agent";
102
+ default:
103
+ return "other";
104
+ }
105
+ }
@@ -0,0 +1,392 @@
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
+ stripTerminalSequences,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ } from "@earendil-works/pi-tui";
19
+
20
+ import { type IconSet, resolveIcons } from "../render/glyphs.ts";
21
+ import { stateMark, toolIcon, toolLabel } from "../render/tools.ts";
22
+ import { icons } from "../settings.ts";
23
+
24
+ /** What the call is doing, which is what the bottom rule says while it has no result yet. */
25
+ type Phase = "writing" | "waiting" | "running" | "done" | "failed";
26
+
27
+ const PHASE_COLORS = {
28
+ writing: "muted",
29
+ waiting: "accent",
30
+ running: "accent",
31
+ done: "success",
32
+ failed: "error",
33
+ } as const;
34
+ const BODY_PAD = 4;
35
+
36
+ export function registerToolFrames(scope: FeatureScope): void {
37
+ const redraws = new Map<string, () => void>();
38
+ scope.events.on(FRAMED, (data) => {
39
+ if (isObject(data)) data.framed = true;
40
+ });
41
+ scope.events.on(RULING_CHANGED, (data) => {
42
+ if (isObject(data) && typeof data.toolCallId === "string") redraws.get(data.toolCallId)?.();
43
+ });
44
+
45
+ scope.registerToolRenderer((toolName, next) =>
46
+ framed(scope, next(), {
47
+ name: toolName,
48
+ set: () => resolveIcons(icons.get(scope)).set,
49
+ redraws,
50
+ }),
51
+ );
52
+ }
53
+
54
+ interface FrameContext {
55
+ name: string;
56
+ set: () => IconSet;
57
+ redraws: Map<string, () => void>;
58
+ }
59
+
60
+ function framed(
61
+ scope: FeatureScope,
62
+ base: ToolRenderers | undefined,
63
+ frame: FrameContext,
64
+ ): ToolRenderers | undefined {
65
+ // Pi drops the whole chain when a resolver answers nothing, so a tool it cannot frame keeps
66
+ // whatever the next resolver drew.
67
+ const call = base?.renderCall;
68
+ const result = base?.renderResult;
69
+ if (base === undefined || call === undefined || result === undefined) return base;
70
+
71
+ // A renderer of pi's own mutates the component it returned last time, which sits inside the
72
+ // frame, so it gets that one back instead of the frame.
73
+ const inner = new WeakMap<Component, Component>();
74
+ const passed = (last: Component | undefined): Component | undefined =>
75
+ last === undefined ? undefined : (inner.get(last) ?? last);
76
+
77
+ return {
78
+ ...base,
79
+ // The frame is the shell, so pi does not wrap it in a box of its own.
80
+ renderShell: "self",
81
+ renderCall(args, theme, context) {
82
+ frame.redraws.set(context.toolCallId, context.invalidate);
83
+ const clock = clockOf(context);
84
+ const now = Date.now();
85
+ clock.writingAt ??= now;
86
+ if (context.argsComplete) clock.writtenAt ??= now;
87
+ if (context.executionStarted) clock.startedAt ??= now;
88
+ const body = call(args, theme, { ...context, lastComponent: passed(context.lastComponent) });
89
+
90
+ const box = new CallFrame({
91
+ theme,
92
+ icon: toolIcon(frame.name, frame.set()),
93
+ label: toolLabel(frame.name),
94
+ body,
95
+ ruling: rulingOf(scope.events, context.toolCallId),
96
+ expanded: context.expanded,
97
+ phase: context.executionStarted ? undefined : context.argsComplete ? "waiting" : "writing",
98
+ mark: stateMark,
99
+ set: frame.set,
100
+ elapsed: elapsedOf(clock, context),
101
+ });
102
+ inner.set(box, body);
103
+ return box;
104
+ },
105
+ renderResult(shown, options, theme, context) {
106
+ const clock = clockOf(context);
107
+ // A blocked call prints nothing but the reason the line above already gives.
108
+ const blocked = rulingOf(scope.events, context.toolCallId)?.blocked === true;
109
+ // Pi draws the pictures of a result right below the frame, so the frame counts them.
110
+ const images = shown.content.filter((block) => block.type === "image").length;
111
+ if (!options.isPartial) clock.endedAt ??= Date.now();
112
+ const body = result(shown, options, theme, {
113
+ ...context,
114
+ lastComponent: passed(context.lastComponent),
115
+ });
116
+ const phase: Phase = options.isPartial ? "running" : context.isError ? "failed" : "done";
117
+ const box = new ResultFrame({
118
+ theme,
119
+ body,
120
+ quiet: blocked,
121
+ phase,
122
+ mark: stateMark(phase, frame.set()),
123
+ elapsed: ranFor(clock, phase === "running"),
124
+ wrote: wroteFor(clock),
125
+ images: images === 0 ? undefined : `${images} image${images === 1 ? "" : "s"}`,
126
+ });
127
+ inner.set(box, body);
128
+ return box;
129
+ },
130
+ };
131
+ }
132
+
133
+ interface FrameParts {
134
+ theme: Theme;
135
+ body: Component;
136
+ }
137
+
138
+ /** The top rule, the call inside it, and the phase while the call has nothing to show yet. */
139
+ class CallFrame implements Component {
140
+ #drawn?: Drawn;
141
+
142
+ constructor(private readonly parts: FrameParts & CallParts) {}
143
+
144
+ invalidate(): void {
145
+ this.#drawn = undefined;
146
+ this.parts.body.invalidate();
147
+ }
148
+
149
+ render(width: number): string[] {
150
+ const { theme, icon, label, ruling, expanded, phase } = this.parts;
151
+ const body = this.parts.body.render(room(width));
152
+ const drawn = { width, body, rest: [ruling, expanded, phase, this.parts.elapsed] };
153
+ if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
154
+
155
+ const title = `${theme.fg("toolTitle", icon)} ${theme.fg("toolTitle", theme.bold(label))}`;
156
+ const lines = [rule("top", title, width, theme), ...inside(withoutBlanks(body), width, theme)];
157
+ // The decision is about the call, so it stays with the call.
158
+ if (ruling !== undefined)
159
+ lines.push(...inside([rulingText(ruling, theme, expanded)], width, theme));
160
+ // The call has nothing else to show yet, so the frame closes with what it is doing.
161
+ if (phase !== undefined) {
162
+ const mark = this.parts.mark(phase, this.parts.set());
163
+ const times = [this.parts.elapsed];
164
+ lines.push(
165
+ rule("bottom", phaseLabel(theme, mark, phase, times), width, theme, PHASE_COLORS[phase]),
166
+ );
167
+ }
168
+ this.#drawn = { ...drawn, lines };
169
+ return lines;
170
+ }
171
+ }
172
+
173
+ interface CallParts {
174
+ icon: string;
175
+ label: string;
176
+ ruling?: CallRuling;
177
+ expanded: boolean;
178
+ phase?: Phase;
179
+ mark: (phase: Phase, set: IconSet) => string;
180
+ set: () => IconSet;
181
+ elapsed?: string;
182
+ }
183
+
184
+ /** The result, behind a cut, and the bottom rule with how it went and how long it took. */
185
+ class ResultFrame implements Component {
186
+ #drawn?: Drawn;
187
+
188
+ constructor(private readonly parts: FrameParts & ResultParts) {}
189
+
190
+ invalidate(): void {
191
+ this.#drawn = undefined;
192
+ this.parts.body.invalidate();
193
+ }
194
+
195
+ render(width: number): string[] {
196
+ const { theme, phase, mark, wrote, quiet, images } = this.parts;
197
+ const time = this.parts.elapsed;
198
+ const body = this.parts.body.render(room(width));
199
+ const drawn = { width, body, rest: [phase, mark, time, wrote, images, quiet] };
200
+ if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
201
+
202
+ const printed = quiet === true ? [] : withoutBlanks(dropTime(body));
203
+ const lines = [
204
+ // The cut opens what the call printed, and a call with nothing to print has no cut.
205
+ ...(printed.length === 0 ? [] : [cut(width, theme), ...inside(printed, width, theme)]),
206
+ rule(
207
+ "bottom",
208
+ phaseLabel(theme, mark, phase, [
209
+ time,
210
+ wrote === undefined ? undefined : `wrote ${wrote}`,
211
+ images === undefined ? undefined : `with ${images}`,
212
+ ]),
213
+ width,
214
+ theme,
215
+ PHASE_COLORS[phase],
216
+ ),
217
+ ];
218
+ this.#drawn = { ...drawn, lines };
219
+ return lines;
220
+ }
221
+ }
222
+
223
+ interface ResultParts {
224
+ phase: Phase;
225
+ mark: string;
226
+ elapsed?: string;
227
+ wrote?: string;
228
+ /** How many pictures follow the frame, which pi draws outside it. */
229
+ images?: string;
230
+ /** Nothing worth printing under the line, like a call the gate blocked. */
231
+ quiet?: boolean;
232
+ }
233
+
234
+ /** One rule of the frame: two corners, and what sits on the left. */
235
+ function rule(
236
+ edge: "top" | "bottom",
237
+ left: string,
238
+ width: number,
239
+ theme: Theme,
240
+ color: "accent" | "success" | "error" | "muted" | "border" = "border",
241
+ ): string {
242
+ const [start, end] = edge === "top" ? ["\u256d", "\u256e"] : ["\u2570", "\u256f"];
243
+ const paint = (text: string) => theme.fg(color, text);
244
+ const cell = left === "" ? "" : ` ${left} `;
245
+ const fill = Math.max(1, width - BODY_PAD - visibleWidth(cell));
246
+ const line =
247
+ paint(`${start}\u2500`) + cell + paint("\u2500".repeat(fill)) + paint(`\u2500${end}`);
248
+ return truncateToWidth(line, width, "");
249
+ }
250
+
251
+ /** The line that ends the call and starts what it printed. */
252
+ function cut(width: number, theme: Theme): string {
253
+ const paint = (text: string) => theme.fg("border", text);
254
+ const fill = Math.max(1, width - BODY_PAD);
255
+ const line = `${paint("\u251c")}${paint("\u2500".repeat(fill + 2))}${paint("\u2524")}`;
256
+ return truncateToWidth(line, width, "");
257
+ }
258
+
259
+ /** The bottom rule: what the call is doing, how long it ran, and how long the model took to write it. */
260
+ function phaseLabel(
261
+ theme: Theme,
262
+ mark: string,
263
+ phase: Phase,
264
+ times: readonly (string | undefined)[],
265
+ ): string {
266
+ const cells = times
267
+ .filter((time) => time !== undefined)
268
+ .map((time) => theme.fg("dim", time ?? ""));
269
+ return [`${theme.fg(PHASE_COLORS[phase], mark)} ${theme.fg("muted", phase)}`, ...cells].join(
270
+ " ",
271
+ );
272
+ }
273
+
274
+ function inside(lines: readonly string[], width: number, theme: Theme): string[] {
275
+ const paint = (text: string) => theme.fg("border", text);
276
+ const inner = room(width);
277
+ return trim(lines).map((line) => {
278
+ // Measuring is cheap, and cutting a line that already fits is not.
279
+ const text = visibleWidth(line) > inner ? truncateToWidth(line, inner, "\u2026", true) : line;
280
+ const pad = " ".repeat(Math.max(0, inner - visibleWidth(text)));
281
+ return `${paint("\u2502")} ${text}${pad} ${paint("\u2502")}`;
282
+ });
283
+ }
284
+
285
+ interface Drawn {
286
+ width: number;
287
+ body: readonly string[];
288
+ /** What the frame drew around the body: the ruling, the phase, the times. */
289
+ rest: readonly unknown[];
290
+ lines: string[];
291
+ }
292
+
293
+ /** Whether the frame was already drawn for this body and this state, which keeps typing cheap. */
294
+ function unchanged(drawn: Drawn | undefined, next: Omit<Drawn, "lines">): drawn is Drawn {
295
+ if (drawn === undefined || drawn.width !== next.width) return false;
296
+ if (drawn.body.length !== next.body.length || drawn.rest.length !== next.rest.length)
297
+ return false;
298
+ if (drawn.rest.some((part, index) => part !== next.rest[index])) return false;
299
+ return drawn.body.every((line, index) => line === next.body[index]);
300
+ }
301
+
302
+ function room(width: number): number {
303
+ return Math.max(1, width - BODY_PAD);
304
+ }
305
+
306
+ /** Pi prints the time of a call itself, and the frame says it on the bottom rule instead. */
307
+ function dropTime(lines: readonly string[]): string[] {
308
+ const at = lines.findLastIndex((line) => !isBlank(line));
309
+ if (at === -1) return [...lines];
310
+ if (!/^(Took|Elapsed) [\d.]/u.test(stripTerminalSequences(lines[at] ?? "").trim()))
311
+ return [...lines];
312
+ return lines.slice(0, at);
313
+ }
314
+
315
+ interface Clock {
316
+ /** When the model started writing the call, and when it finished. */
317
+ writingAt?: number;
318
+ writtenAt?: number;
319
+ /** When the call started running, and when it answered. */
320
+ startedAt?: number;
321
+ endedAt?: number;
322
+ }
323
+
324
+ /** Pi shares one state object between the two halves of a call. */
325
+ function clockOf(context: { state: unknown }): Clock {
326
+ return context.state as Clock;
327
+ }
328
+
329
+ /** How long the model took to write the call. */
330
+ function wroteFor(clock: Clock): string | undefined {
331
+ if (clock.writingAt === undefined || clock.writtenAt === undefined) return undefined;
332
+ return elapsed(clock.writtenAt - clock.writingAt);
333
+ }
334
+
335
+ function ranFor(clock: Clock, running: boolean): string | undefined {
336
+ if (clock.startedAt === undefined) return undefined;
337
+ const end = clock.endedAt ?? (running ? Date.now() : undefined);
338
+ return end === undefined ? undefined : elapsed(end - clock.startedAt);
339
+ }
340
+
341
+ /** How long the call has spent waiting to run, which only the call half of the frame asks for. */
342
+ function elapsedOf(clock: Clock, context: { argsComplete: boolean }): string | undefined {
343
+ if (clock.writingAt === undefined) return undefined;
344
+ const from = context.argsComplete ? (clock.writtenAt ?? clock.writingAt) : clock.writingAt;
345
+ return elapsed(Date.now() - from);
346
+ }
347
+
348
+ function elapsed(ms: number): string {
349
+ const seconds = ms / 1000;
350
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
351
+ const total = Math.floor(seconds);
352
+ if (total < 3600) return `${Math.floor(total / 60)}m ${total % 60}s`;
353
+ return `${Math.floor(total / 3600)}h ${Math.floor((total % 3600) / 60)}m`;
354
+ }
355
+
356
+ /** Pi's renderers pad their part with blank lines, which the frame does not need. */
357
+ function trim(lines: readonly string[]): string[] {
358
+ let [from, to] = [0, lines.length];
359
+ while (from < to && isBlank(lines[from])) from++;
360
+ while (to > from && isBlank(lines[to - 1])) to--;
361
+ return lines.slice(from, to);
362
+ }
363
+
364
+ /** A renderer that separates its parts with a blank line leaves a gap the frame does not need. */
365
+ function withoutBlanks(lines: readonly string[]): string[] {
366
+ return lines.filter((line) => !isBlank(line));
367
+ }
368
+
369
+ function isBlank(line: string | undefined): boolean {
370
+ // A line of nothing but colour codes reads as blank, and pi's renderers end their parts with one.
371
+ return stripTerminalSequences(line ?? "").trim() === "";
372
+ }
373
+
374
+ export function rulingText(ruling: CallRuling, theme: Theme, expanded: boolean): string {
375
+ const color = PHASE_COLORS[stateOf(ruling.tone)];
376
+ let line = `${theme.fg(color, "\u25c8")} ${theme.fg(color, ruling.head)}`;
377
+ if (ruling.note) line += ` ${theme.fg("accent", "\u203a")} ${ruling.note}`;
378
+ if (ruling.why) line += ` ${theme.fg("muted", ruling.why)}`;
379
+ if (expanded && ruling.detail) line += ` ${theme.fg("dim", `(${ruling.detail})`)}`;
380
+ return line;
381
+ }
382
+
383
+ function stateOf(tone: RulingTone): Phase {
384
+ switch (tone) {
385
+ case "pending":
386
+ return "running";
387
+ case "success":
388
+ return "done";
389
+ default:
390
+ return "failed";
391
+ }
392
+ }