@adeildo/pi-look 5.0.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,161 +1,76 @@
1
- # @adeildo/pi-look
2
-
3
- [![npm](https://img.shields.io/npm/v/@adeildo/pi-look)](https://www.npmjs.com/package/@adeildo/pi-look)
4
-
5
- [Pi](https://pi.dev)'s start screen, editor, footer and working line, redrawn around the data that matters while the agent works, and painted only with the colours of the theme that is on.
6
-
7
- ```bash
8
- pi install npm:@adeildo/pi-look
9
- ```
10
-
11
- It also comes in [`@adeildo/pi-harness`](../harness), with the rest of the pieces.
1
+ <h1 align="center">@adeildo/pi-look</h1>
12
2
 
13
3
  <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">
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>
15
6
  </p>
16
7
 
17
- ## What is on the screen
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>
18
12
 
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. The π is coloured along the theme's effort ramp.
13
+ <p align="center"><code>pi install npm:@adeildo/pi-look</code></p>
20
14
 
21
- **The strip above the editor** is calm on purpose: nothing in it moves while the model streams except the stopwatch.
15
+ <!-- docs:look/preview -->
22
16
 
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, never a partial count |
17
+ <p align="center">
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>
21
+ </p>
27
22
 
28
- The wait runs from the moment the request leaves, which pi reports on `before_provider_request`, to the first token of any kind. `thought` is the part of that wait spent thinking before the first word, and only appears when the model thought. The pieces that split it further, `server` (the request leaving to the response headers, which is the provider's own latency) and `prefill` (the headers to the first token, the model reading the prompt), go in any slot. So do the live pieces, which move with every token: `speed`, `wait`, `request` and `costRate`.
23
+ <!-- /docs -->
29
24
 
30
- The arrows are from where you sit: `↑ in` is what goes to the model, `↓ out` is what comes back. `What is on screen`, in the Slots section of the settings (`Alt+S`), lists every piece with its current value and what it means.
25
+ ## The screen
31
26
 
32
- **The frame** has four slots. By default: the branch with its distance and changes on the top left, after the spinner; the folder and the machine on the top right; the permission mode, the model and the effort meter on the bottom left; the context on the bottom right. The border is drawn in pi's own effort colour, so it warms up as the model thinks harder, and turns to the bash colour in shell mode.
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`.
33
28
 
34
- **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. The thinking and the call are already in the transcript, so the spinner never repeats them.
29
+ <!-- docs:look/call -->
35
30
 
36
- **The footer** is the session: the cost (`sub` when the figure is what a subscription would have paid), tokens sent and received, where sent counts the cached part too, the cache hit of the last prompt, the average speeds of the whole session, and last, what other packages report, because it changes the most.
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>
37
36
 
38
- ### It holds still
37
+ <!-- /docs -->
39
38
 
40
- Numbers change on every frame, and the screen must not move with them. Every number sits in a cell of fixed width (` 17` and `2.8k` take the same room), a value that is not known yet shows as `–` in its cell instead of appearing later in the middle of the line, the working time is a stopwatch (`0:59`, `1:00`) instead of `59s` then `1m 0s`, and an estimate is drawn quieter instead of gaining a `~`. The spinner's message comes after the branch and only takes the room the slots leave. The strip and the footer keep their line from the start, so the editor does not jump when the first answer arrives.
39
+ The strip above the editor is calm on purpose. Nothing moves while the model streams except the stopwatch.
41
40
 
42
- 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 is what keeps the averages right after a resume.
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 |
43
45
 
44
- ## Colours
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.
45
47
 
46
- 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.
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.
47
49
 
48
- | What | Token |
49
- | --------------------------------------------- | ----------------------------------------------------------- |
50
- | Folder | `accent` |
51
- | Branch | `syntaxFunction` |
52
- | Ahead, staged / behind, modified / conflicted | `success` / `warning` / `error` |
53
- | Machine, cache | `syntaxType` |
54
- | Session name | `mdHeading` |
55
- | Provider, model icon | `syntaxKeyword` |
56
- | Model | `text`, bold |
57
- | Sent `↑` | `mdLink` |
58
- | Received `↓`, speeds, done | `success` |
59
- | Time to first token, money | `syntaxNumber`, then `warning` past $1 and `error` past $10 |
60
- | Context | `success`, `warning` past 70%, `error` past 90% |
61
- | Effort meter, the π | `thinkingMinimal` … `thinkingMax`, one per level |
62
- | Labels, units, separators | `dim` |
63
- | Editor frame | pi's effort border colour |
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.
64
51
 
65
- Syntax tokens are borrowed on purpose: a theme spends its hues there, so they give the line variety that stays in the theme's family. The table lives in `src/render/paint.ts` as `ROLE_TOKENS`, and a test fails if any entry is ever a colour instead of a token.
52
+ ## It holds still
66
53
 
67
- ### The desktop theme
54
+ Numbers change on every frame, and the screen must not move with them.
68
55
 
69
- If you run [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), matugen already made a palette from your wallpaper: the Material roles for dark and light, and sixteen terminal colours, in `~/.cache/DankMaterialShell/dms-colors.json`. The look turns that file into the pi theme `desktop`, writes it to `~/.pi/agent/themes/desktop.json`, and rewrites it whenever the file changes. Pi reloads the active theme from that folder by itself, so a new wallpaper recolours a running pi.
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.
70
61
 
71
- Pi's own `system` theme builds its colours from the terminal's palette. If your terminal already follows matugen, `system` gets close without this, with fewer colours to work from: the sixteen terminal colours instead of the Material roles.
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.
72
63
 
73
- `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
74
65
 
75
- The mapping, from the Material roles and the terminal colours:
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.
76
67
 
77
- | Pi | Desktop |
78
- | ---------------------------------- | --------------------------------------------------------------------------------------------------- |
79
- | `accent`, links, keywords, bullets | `primary` |
80
- | `text`, `muted`, `dim` | `on_surface`, `on_surface_variant`, `outline` |
81
- | borders | `outline`, `primary`, `outline_variant` |
82
- | selection, messages, pending tools | the `surface_container` steps |
83
- | `success`, `warning`, `error` | terminal green, terminal yellow, `error` |
84
- | tool success and error backgrounds | a surface mixed with green or with `error` |
85
- | headings, functions, custom labels | `tertiary` |
86
- | code, types | `secondary`, terminal cyan |
87
- | effort ramp, off to max | `outline_variant`, `outline`, `secondary`, `primary`, `tertiary`, terminal bright red, terminal red |
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.
88
69
 
89
- Material has no success, warning or diff roles, so the terminal colours of that hue stand in. The top of the effort ramp uses the terminal reds on purpose: they keep their meaning whatever the wallpaper, so the frame still reads hot at `xhigh` and `max`.
70
+ ## Settings
90
71
 
91
- Another palette source is a new file next to `src/desktop/palette.ts`: a parser into Material roles and terminal colours, and the same mapping does the rest.
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.
92
73
 
93
- ## Settings
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).
94
75
 
95
- They live in the file every piece shares, `~/.pi/agent/extensions/pi-harness/settings.json`, under `look.`. The ones marked _project_ are also read from a trusted project.
96
-
97
- | Key | Default | Does |
98
- | ------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
99
- | `strip` | `["elapsed", "last"]` | The line above the editor. _project_ |
100
- | `frame.topLeft` | `["branch", "session"]` | After the spinner. _project_ |
101
- | `frame.topRight` | `["path", "host"]` | _project_ |
102
- | `frame.bottomLeft` | `["status:pi-ask-permission:mode", "model", "status:pi-providers:account", "effort"]` | _project_ |
103
- | `frame.bottomRight` | `["context"]` | _project_ |
104
- | `below` | `["cost", "tokens", "cache", "average", "statuses"]` | The line below the editor. _project_ |
105
- | `labels` | `true` | A short word before each number: `out`, `in`, `ttft`, `wait`, `thought`, `server`, `time`, `ctx`, `cache`. Off is denser, with only the glyphs. _project_ |
106
- | `frame.style` | `"rounded"` | `rounded`, `square`, `heavy`, `line` (pi's two rules, written into), or `off`, which leaves pi's editor alone and moves the frame slots to the footer. _project_ |
107
- | `frame.cursor` | `"bar"` | `block`, `bar` or `underline`. The last two use the terminal's own cursor |
108
- | `header` | `"card"` | `card`, `compact` (two lines) or `off` (pi's own). _project_ |
109
- | `icons` | `"auto"` | `nerd`, `unicode` or `ascii`. Auto uses Nerd Font glyphs locally and plain Unicode over SSH, where the font lives on the other machine |
110
- | `separator` | `"space"` | `dot`, `bar`, `slash` or `space`. _project_ |
111
- | `pathLength` | `40` | Longest the folder may be before it loses folders from the left. _project_ |
112
- | `gaugeCells` | `8` | Cells in the context gauge. `0` hides it. _project_ |
113
- | `peek` | `true` | The spinner names the state. Off, it says what pi says |
114
- | `desktop.theme` | `true` | Keep the desktop theme written. Nothing happens without the palette file |
115
- | `desktop.source` | `"~/.cache/DankMaterialShell/dms-colors.json"` | Where the palette is |
116
-
117
- ### Segments
118
-
119
- Any segment goes in any slot, in the order the slot lists them.
120
-
121
- | Id | Shows |
122
- | -------------- | ----------------------------------------------------------------------------------------------------- |
123
- | `path` | The working folder, with home as `~` |
124
- | `branch` | The branch, ahead `↑` and behind `↓`, staged `+`, modified `~`, untracked `?`, conflicts `!`, stashes |
125
- | `host` | The machine, so a pasted line says where it came from |
126
- | `session` | The session name, once it has one |
127
- | `version` | The pi version |
128
- | `clock` | The time |
129
- | `model` | `provider/model` |
130
- | `provider` | The provider on its own |
131
- | `effort` | The effort meter and level. Hidden for models that do not reason |
132
- | `context` | Context used, the gauge, tokens over the window |
133
- | `speed` | This answer's speeds, received `↓` and sent `↑` |
134
- | `wait` | Time to the first token |
135
- | `server` | The last call's time from the request leaving to the response headers |
136
- | `elapsed` | The agent's working time, as a stopwatch, and its calls |
137
- | `last` | The last finished call: its wait, its thought time and its writing speed |
138
- | `request` | This answer's tokens, with the cache |
139
- | `costRate` | This answer's cost per million tokens |
140
- | `cost` | The session's cost |
141
- | `tokens` | The session's tokens, with the cache |
142
- | `cache` | The cache hit of the last prompt |
143
- | `average` | The session's average speeds, both ways |
144
- | `statuses` | What other packages report through `setStatus`, minus the ones placed on their own |
145
- | `status:<key>` | One package's status, by the key it passes to `setStatus` |
146
-
147
- A `status:<key>` segment is how a package gets a place of its own. [`@adeildo/pi-ask-permission`](../ask-permission) reports its mode as `pi-ask-permission:mode`, and the mode says what happens to what you type, so by default it sits in the frame next to the model instead of in the footer with everything else.
148
-
149
- ## How it fits
150
-
151
- When a line runs out of room, the least important pieces take their short form first (the folder becomes its last part, the model loses its provider, the effort meter loses its label), and then leave, one at a time. The two sides of a border compete for the same width, so an unimportant piece on the right leaves before an important one on the left. A line never goes empty: the last piece stays and is cut.
152
-
153
- The frame is the editor pi already has. Typing, history, autocomplete and every app key stay pi's; the class only changes what the lines look like and shifts mouse clicks by the width of the frame. The spinner is pi's, embedded through pi's own hook, and the working line only speaks through `setWorkingMessage`.
154
-
155
- ## Credit
156
-
157
- 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).
158
-
159
- ## License
160
-
161
- [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.0.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.0.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
+ }