@adeildo/pi-look 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 adeildo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,163 @@
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.
12
+
13
+ <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">
15
+ </p>
16
+
17
+ ## What is on the screen
18
+
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.
20
+
21
+ **The strip above the editor** is calm on purpose: nothing in it moves while the model streams except the stopwatch.
22
+
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 · ↓906 tok/s` | The last call that finished: how long it waited for the first token, and how fast it wrote. Final numbers, never a partial count |
27
+
28
+ The live pieces, which move with every token, go in any slot too: `speed`, `wait`, `request` and `costRate`.
29
+
30
+ The arrows are from where you sit: `↑ in` is what goes to the model, `↓ out` is what comes back. `/look explain` lists every piece on screen with its current value and what it means.
31
+
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.
33
+
34
+ **The spinner** says what is happening instead of "Working": the newest line of the thinking, the tool call being written, or the command running with its first argument (`bash · git status`, `edit · src/index.ts`).
35
+
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.
37
+
38
+ ### It holds still
39
+
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, which grows with every token of thinking, 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.
41
+
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.
43
+
44
+ ## Colours
45
+
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.
47
+
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 |
64
+
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.
66
+
67
+ ### The desktop theme
68
+
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.
70
+
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.
72
+
73
+ ```
74
+ /look explain what every piece on screen is, with its current value
75
+ /look theme write the desktop theme and switch to it
76
+ ```
77
+
78
+ The mapping, from the Material roles and the terminal colours:
79
+
80
+ | Pi | Desktop |
81
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------- |
82
+ | `accent`, links, keywords, bullets | `primary` |
83
+ | `text`, `muted`, `dim` | `on_surface`, `on_surface_variant`, `outline` |
84
+ | borders | `outline`, `primary`, `outline_variant` |
85
+ | selection, messages, pending tools | the `surface_container` steps |
86
+ | `success`, `warning`, `error` | terminal green, terminal yellow, `error` |
87
+ | tool success and error backgrounds | a surface mixed with green or with `error` |
88
+ | headings, functions, custom labels | `tertiary` |
89
+ | code, types | `secondary`, terminal cyan |
90
+ | effort ramp, off to max | `outline_variant`, `outline`, `secondary`, `primary`, `tertiary`, terminal bright red, terminal red |
91
+
92
+ 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`.
93
+
94
+ 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.
95
+
96
+ ## Settings
97
+
98
+ 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.
99
+
100
+ | Key | Default | Does |
101
+ | ------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | `strip` | `["elapsed", "last"]` | The line above the editor. _project_ |
103
+ | `frame.topLeft` | `["branch", "session"]` | After the spinner. _project_ |
104
+ | `frame.topRight` | `["path", "host"]` | _project_ |
105
+ | `frame.bottomLeft` | `["status:pi-ask-permission:mode", "model", "effort"]` | _project_ |
106
+ | `frame.bottomRight` | `["context"]` | _project_ |
107
+ | `below` | `["cost", "tokens", "cache", "average", "statuses"]` | The line below the editor. _project_ |
108
+ | `labels` | `true` | A short word before each number: `out`, `in`, `ttft`, `time`, `ctx`, `cache`. Off is denser, with only the glyphs. _project_ |
109
+ | `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_ |
110
+ | `frame.cursor` | `"bar"` | `block`, `bar` or `underline`. The last two use the terminal's own cursor |
111
+ | `header` | `"card"` | `card`, `compact` (two lines) or `off` (pi's own). _project_ |
112
+ | `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 |
113
+ | `separator` | `"dot"` | `dot`, `bar`, `slash` or `space`. _project_ |
114
+ | `pathLength` | `40` | Longest the folder may be before it loses folders from the left. _project_ |
115
+ | `gaugeCells` | `8` | Cells in the context gauge. `0` hides it. _project_ |
116
+ | `peek` | `true` | The spinner says what runs. Off, it says what pi says |
117
+ | `desktop.theme` | `true` | Keep the desktop theme written. Nothing happens without the palette file |
118
+ | `desktop.source` | `"~/.cache/DankMaterialShell/dms-colors.json"` | Where the palette is |
119
+
120
+ ### Segments
121
+
122
+ Any segment goes in any slot, in the order the slot lists them.
123
+
124
+ | Id | Shows |
125
+ | -------------- | ----------------------------------------------------------------------------------------------------- |
126
+ | `path` | The working folder, with home as `~` |
127
+ | `branch` | The branch, ahead `↑` and behind `↓`, staged `+`, modified `~`, untracked `?`, conflicts `!`, stashes |
128
+ | `host` | The machine, so a pasted line says where it came from |
129
+ | `session` | The session name, once it has one |
130
+ | `version` | The pi version |
131
+ | `clock` | The time |
132
+ | `model` | `provider/model` |
133
+ | `provider` | The provider on its own |
134
+ | `effort` | The effort meter and level. Hidden for models that do not reason |
135
+ | `context` | Context used, the gauge, tokens over the window |
136
+ | `speed` | This answer's speeds, received `↓` and sent `↑` |
137
+ | `wait` | Time to the first token |
138
+ | `elapsed` | The agent's working time, as a stopwatch, and its calls |
139
+ | `last` | The last finished call: wait for the first token and writing speed |
140
+ | `request` | This answer's tokens, with the cache |
141
+ | `costRate` | This answer's cost per million tokens |
142
+ | `cost` | The session's cost |
143
+ | `tokens` | The session's tokens, with the cache |
144
+ | `cache` | The cache hit of the last prompt |
145
+ | `average` | The session's average speeds, both ways |
146
+ | `statuses` | What other packages report through `setStatus`, minus the ones placed on their own |
147
+ | `status:<key>` | One package's status, by the key it passes to `setStatus` |
148
+
149
+ 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.
150
+
151
+ ## How it fits
152
+
153
+ 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.
154
+
155
+ 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`.
156
+
157
+ ## Credit
158
+
159
+ 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).
160
+
161
+ ## License
162
+
163
+ [MIT](LICENSE)
Binary file
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@adeildo/pi-look",
3
+ "version": "4.0.0",
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
+ "keywords": [
6
+ "pi",
7
+ "pi-coding-agent",
8
+ "pi-extension",
9
+ "pi-package",
10
+ "statusline",
11
+ "theme",
12
+ "tui"
13
+ ],
14
+ "homepage": "https://github.com/felipeadeildo/pi-harness/tree/main/packages/look#readme",
15
+ "bugs": "https://github.com/felipeadeildo/pi-harness/issues",
16
+ "license": "MIT",
17
+ "author": {
18
+ "name": "adeildo",
19
+ "email": "contato@felipeadeildo.com",
20
+ "url": "https://github.com/felipeadeildo"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/felipeadeildo/pi-harness.git",
25
+ "directory": "packages/look"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "assets/preview.png",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "type": "module",
34
+ "exports": {
35
+ ".": "./src/index.ts"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "@adeildo/pi-kit": "4.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-ai": "*",
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "@earendil-works/pi-tui": "*"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./src/index.ts"
51
+ ],
52
+ "image": "https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/look/assets/preview.png"
53
+ }
54
+ }
@@ -0,0 +1,58 @@
1
+ // The working tree, in one call: `git status --porcelain=v2 --branch --show-stash` says how far the
2
+ // branch is from its upstream and what changed, file by file. The parse is pure so it can be tested
3
+ // against captured output.
4
+ import { execFile } from "node:child_process";
5
+
6
+ export interface GitState {
7
+ ahead: number;
8
+ behind: number;
9
+ /** In the index, ready to commit. */
10
+ staged: number;
11
+ /** Changed in the tree and not staged. */
12
+ modified: number;
13
+ untracked: number;
14
+ conflicted: number;
15
+ stashed: number;
16
+ }
17
+
18
+ export function emptyGit(): GitState {
19
+ return { ahead: 0, behind: 0, staged: 0, modified: 0, untracked: 0, conflicted: 0, stashed: 0 };
20
+ }
21
+
22
+ export function parseStatus(output: string): GitState {
23
+ const state = emptyGit();
24
+
25
+ for (const line of output.split("\n")) {
26
+ if (line.startsWith("# branch.ab ")) {
27
+ const [, , ahead, behind] = line.split(" ");
28
+ state.ahead = Math.abs(Number(ahead ?? "0")) || 0;
29
+ state.behind = Math.abs(Number(behind ?? "0")) || 0;
30
+ } else if (line.startsWith("# stash ")) {
31
+ state.stashed = Number(line.slice("# stash ".length)) || 0;
32
+ } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
33
+ // `XY` is the index column, then the tree column, with `.` for unchanged.
34
+ const index = line[2];
35
+ const tree = line[3];
36
+ if (index !== undefined && index !== ".") state.staged++;
37
+ if (tree !== undefined && tree !== ".") state.modified++;
38
+ } else if (line.startsWith("u ")) {
39
+ state.conflicted++;
40
+ } else if (line.startsWith("? ")) {
41
+ state.untracked++;
42
+ }
43
+ }
44
+
45
+ return state;
46
+ }
47
+
48
+ /** Undefined outside a repository, or when git is missing or slow. */
49
+ export function readGit(cwd: string): Promise<GitState | undefined> {
50
+ return new Promise((resolve) => {
51
+ execFile(
52
+ "git",
53
+ ["-C", cwd, "status", "--porcelain=v2", "--branch", "--show-stash"],
54
+ { timeout: 2000 },
55
+ (error, stdout) => resolve(error === null ? parseStatus(stdout) : undefined),
56
+ );
57
+ });
58
+ }
@@ -0,0 +1,173 @@
1
+ // Everything the look can show, read once per frame. Segments are pure functions of this, so the
2
+ // whole screen can be drawn and tested without a terminal or a session.
3
+ import { hostname } from "node:os";
4
+
5
+ import { VERSION, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
6
+
7
+ import type { GitState } from "./git.ts";
8
+ import type { RequestView, RunView } from "./telemetry.ts";
9
+ import {
10
+ type Averages,
11
+ averagesOf,
12
+ cacheHitPercent,
13
+ cacheWarming,
14
+ emptyTotals,
15
+ type Totals,
16
+ totalsOf,
17
+ } from "./totals.ts";
18
+
19
+ export interface ModelInfo {
20
+ name: string;
21
+ /** As the provider wants to be written, not the id pi routes by. */
22
+ provider: string;
23
+ reasoning: boolean;
24
+ }
25
+
26
+ export interface ContextInfo {
27
+ percent: number;
28
+ tokens: number;
29
+ window: number;
30
+ }
31
+
32
+ export interface Snapshot {
33
+ now: number;
34
+ cwd: string;
35
+ home: string | undefined;
36
+ branch: string | null;
37
+ /** Undefined outside a repository or before the first probe. */
38
+ git: GitState | undefined;
39
+ host: string | undefined;
40
+ sessionName: string | undefined;
41
+ version: string;
42
+ model: ModelInfo | undefined;
43
+ thinking: string | undefined;
44
+ context: ContextInfo | undefined;
45
+ totals: Totals;
46
+ cacheHit: number | undefined;
47
+ /** The last prompt was written to the cache and nothing was read: the first call, not a miss. */
48
+ cacheWarming: boolean;
49
+ /** The dollar figure is what the tokens would have cost, not what was billed. */
50
+ subscription: boolean;
51
+ request: RequestView | undefined;
52
+ /** The last request that finished: its numbers are final. */
53
+ last: RequestView | undefined;
54
+ run: RunView | undefined;
55
+ averages: Averages;
56
+ /** What other packages report through `setStatus`, by key, in key order. */
57
+ statuses: ReadonlyMap<string, string>;
58
+ }
59
+
60
+ export function emptySnapshot(): Snapshot {
61
+ return {
62
+ now: 0,
63
+ cwd: "",
64
+ home: undefined,
65
+ branch: null,
66
+ git: undefined,
67
+ host: undefined,
68
+ sessionName: undefined,
69
+ version: VERSION,
70
+ model: undefined,
71
+ thinking: undefined,
72
+ context: undefined,
73
+ totals: emptyTotals(),
74
+ cacheHit: undefined,
75
+ cacheWarming: false,
76
+ subscription: false,
77
+ request: undefined,
78
+ last: undefined,
79
+ run: undefined,
80
+ averages: { decode: undefined, prefill: undefined },
81
+ statuses: new Map(),
82
+ };
83
+ }
84
+
85
+ export interface LiveInputs {
86
+ branch: string | null;
87
+ git: GitState | undefined;
88
+ request: RequestView | undefined;
89
+ last: RequestView | undefined;
90
+ run: RunView | undefined;
91
+ statuses: ReadonlyMap<string, string>;
92
+ }
93
+
94
+ interface Derived {
95
+ totals: Totals;
96
+ cacheHit: number | undefined;
97
+ cacheWarming: boolean;
98
+ averages: Averages;
99
+ }
100
+
101
+ /**
102
+ * Reads the session once per frame. The sums walk the whole branch, so they are kept until the leaf
103
+ * moves.
104
+ */
105
+ export class SnapshotReader {
106
+ #key: string | undefined;
107
+ #derived: Derived | undefined;
108
+ readonly #host = hostname().split(".")[0];
109
+
110
+ read(ctx: ExtensionContext, live: LiveInputs): Snapshot {
111
+ const model = ctx.model;
112
+ const derived = this.#derive(ctx);
113
+
114
+ return {
115
+ now: Date.now(),
116
+ cwd: ctx.cwd,
117
+ home: process.env.HOME ?? process.env.USERPROFILE,
118
+ branch: live.branch,
119
+ git: live.git,
120
+ host: this.#host,
121
+ sessionName: ctx.sessionManager.getSessionName(),
122
+ version: VERSION,
123
+ model:
124
+ model === undefined
125
+ ? undefined
126
+ : {
127
+ name: model.name ?? model.id,
128
+ provider: ctx.modelRegistry.getProviderDisplayName(model.provider),
129
+ reasoning: model.reasoning,
130
+ },
131
+ thinking: ctx.thinkingLevel,
132
+ context: contextOf(ctx),
133
+ ...derived,
134
+ subscription: model === undefined ? false : ctx.modelRegistry.isUsingOAuth(model),
135
+ request: live.request,
136
+ last: live.last,
137
+ run: live.run,
138
+ statuses: new Map(
139
+ [...live.statuses.entries()]
140
+ .toSorted(([a], [b]) => a.localeCompare(b))
141
+ .map(([key, text]): [string, string] => [key, sanitize(text)])
142
+ .filter(([, text]) => text !== ""),
143
+ ),
144
+ };
145
+ }
146
+
147
+ #derive(ctx: ExtensionContext): Derived {
148
+ // Every entry is appended at the leaf, so the leaf moving is the only way the sums change.
149
+ const key = ctx.sessionManager.getLeafId() ?? "";
150
+ if (this.#derived !== undefined && key === this.#key) return this.#derived;
151
+
152
+ const entries: readonly SessionEntry[] = ctx.sessionManager.getBranch();
153
+ this.#key = key;
154
+ this.#derived = {
155
+ totals: totalsOf(entries),
156
+ cacheHit: cacheHitPercent(entries),
157
+ cacheWarming: cacheWarming(entries),
158
+ averages: averagesOf(entries),
159
+ };
160
+ return this.#derived;
161
+ }
162
+ }
163
+
164
+ function contextOf(ctx: ExtensionContext): ContextInfo | undefined {
165
+ const usage = ctx.getContextUsage();
166
+ if (usage === undefined || usage.percent === null || usage.tokens === null) return undefined;
167
+ return { percent: usage.percent, tokens: usage.tokens, window: usage.contextWindow };
168
+ }
169
+
170
+ /** A status is one line: newlines and tabs from another package would break the frame. */
171
+ function sanitize(text: string): string {
172
+ return text.replace(/[\r\n\t]+/g, " ").trim();
173
+ }
@@ -0,0 +1,188 @@
1
+ // The clock behind the speeds. A run is everything between the prompt and the agent settling; a
2
+ // request is one call to the model inside it. Both are measured from the message events, because pi
3
+ // records usage without a duration.
4
+ //
5
+ // Two speeds per request, one for each direction:
6
+ // ↑ prefill: prompt tokens over the wait for the first token, how fast what we sent was taken in
7
+ // ↓ decode: output tokens over the time spent writing, how fast the answer comes back
8
+ import {
9
+ emptyTotals,
10
+ perSecond,
11
+ promptOf,
12
+ type RequestRecord,
13
+ type Totals,
14
+ usageTotals,
15
+ } from "./totals.ts";
16
+
17
+ /** A decode speed over less than this is noise, so it waits. */
18
+ const MIN_SAMPLE_MS = 250;
19
+ /** Close enough for a live number: the provider's real count replaces it at the end. */
20
+ const CHARS_PER_TOKEN = 4;
21
+
22
+ export interface RequestView {
23
+ /** The answer is still arriving. */
24
+ streaming: boolean;
25
+ /** Waiting for the first token right now. */
26
+ waiting: boolean;
27
+ /** Since the request left, frozen when it ends. */
28
+ elapsedMs: number;
29
+ firstTokenMs: number | undefined;
30
+ /** Output tokens per second, live while streaming. */
31
+ decode: number | undefined;
32
+ /** Prompt tokens per second of waiting, once the prompt size is known. */
33
+ prefill: number | undefined;
34
+ /** What this request used so far. */
35
+ usage: Totals;
36
+ /** The output count is an estimate until the provider reports it. */
37
+ estimated: boolean;
38
+ }
39
+
40
+ export interface RunView {
41
+ running: boolean;
42
+ elapsedMs: number;
43
+ /** Requests made in this run. */
44
+ requests: number;
45
+ }
46
+
47
+ interface Request {
48
+ startedAt: number;
49
+ firstTokenAt: number | undefined;
50
+ endedAt: number | undefined;
51
+ usage: Totals;
52
+ /** Characters seen, for a live rate before the provider says how many tokens they were. */
53
+ characters: number;
54
+ }
55
+
56
+ interface Run {
57
+ startedAt: number;
58
+ endedAt: number | undefined;
59
+ requests: number;
60
+ }
61
+
62
+ interface UsageLike {
63
+ input: number;
64
+ output: number;
65
+ cacheRead: number;
66
+ cacheWrite: number;
67
+ cost: { total: number };
68
+ }
69
+
70
+ export class Telemetry {
71
+ readonly #now: () => number;
72
+ #run: Run | undefined;
73
+ #request: Request | undefined;
74
+ /** The last request that finished, frozen, so a line can show numbers that no longer move. */
75
+ #last: RequestView | undefined;
76
+
77
+ constructor(now: () => number = Date.now) {
78
+ this.#now = now;
79
+ }
80
+
81
+ runStarted(): void {
82
+ this.#run = { startedAt: this.#now(), endedAt: undefined, requests: 0 };
83
+ }
84
+
85
+ runEnded(): void {
86
+ if (this.#run !== undefined && this.#run.endedAt === undefined) this.#run.endedAt = this.#now();
87
+ }
88
+
89
+ /** A turn starting is the request leaving. */
90
+ requestStarted(): void {
91
+ this.#request = {
92
+ startedAt: this.#now(),
93
+ firstTokenAt: undefined,
94
+ endedAt: undefined,
95
+ usage: emptyTotals(),
96
+ characters: 0,
97
+ };
98
+ if (this.#run === undefined || this.#run.endedAt !== undefined) this.runStarted();
99
+ if (this.#run !== undefined) this.#run.requests++;
100
+ }
101
+
102
+ /** The assistant message opening. Starts a request when no turn announced one. */
103
+ answerStarted(): void {
104
+ if (this.#request === undefined || this.#request.endedAt !== undefined) this.requestStarted();
105
+ }
106
+
107
+ /** A piece of the answer: text, thinking or a tool call being written. */
108
+ answerGrew(delta: string, usage: UsageLike): void {
109
+ const request = this.#request;
110
+ if (request === undefined || request.endedAt !== undefined) return;
111
+ request.usage = usageTotals(usage);
112
+ if (delta.length === 0) return;
113
+ request.characters += delta.length;
114
+ request.firstTokenAt ??= this.#now();
115
+ }
116
+
117
+ /** The answer is complete. Returns what the session keeps, or nothing when there was no answer. */
118
+ answerEnded(usage: UsageLike): RequestRecord | undefined {
119
+ const request = this.#request;
120
+ if (request === undefined || request.endedAt !== undefined) return undefined;
121
+
122
+ const now = this.#now();
123
+ request.endedAt = now;
124
+ request.usage = usageTotals(usage);
125
+ if (request.usage.output === 0 && request.firstTokenAt === undefined) return undefined;
126
+
127
+ const firstTokenAt = request.firstTokenAt ?? now;
128
+ this.#last = this.request();
129
+ return {
130
+ output: request.usage.output,
131
+ prompt: promptOf(request.usage),
132
+ firstTokenMs: firstTokenAt - request.startedAt,
133
+ generationMs: now - firstTokenAt,
134
+ };
135
+ }
136
+
137
+ /** The request running now, or the last one. */
138
+ request(): RequestView | undefined {
139
+ const request = this.#request;
140
+ if (request === undefined) return undefined;
141
+
142
+ const now = this.#now();
143
+ const streaming = request.endedAt === undefined;
144
+ const firstTokenMs =
145
+ request.firstTokenAt === undefined ? undefined : request.firstTokenAt - request.startedAt;
146
+ const estimated = streaming && request.usage.output === 0;
147
+ const output = estimated
148
+ ? Math.round(request.characters / CHARS_PER_TOKEN)
149
+ : request.usage.output;
150
+ const writingMs =
151
+ request.firstTokenAt === undefined ? 0 : (request.endedAt ?? now) - request.firstTokenAt;
152
+
153
+ return {
154
+ streaming,
155
+ waiting: streaming && request.firstTokenAt === undefined,
156
+ elapsedMs: (request.endedAt ?? now) - request.startedAt,
157
+ firstTokenMs,
158
+ decode: writingMs < MIN_SAMPLE_MS && streaming ? undefined : perSecond(output, writingMs),
159
+ prefill:
160
+ firstTokenMs === undefined ? undefined : perSecond(promptOf(request.usage), firstTokenMs),
161
+ usage: { ...request.usage, output },
162
+ estimated,
163
+ };
164
+ }
165
+
166
+ /** The last request that finished. Undefined until one has. */
167
+ last(): RequestView | undefined {
168
+ return this.#last;
169
+ }
170
+
171
+ /** The run going now, or the last one. */
172
+ run(): RunView | undefined {
173
+ const run = this.#run;
174
+ if (run === undefined) return undefined;
175
+ const running = run.endedAt === undefined;
176
+ return {
177
+ running,
178
+ elapsedMs: (run.endedAt ?? this.#now()) - run.startedAt,
179
+ requests: run.requests,
180
+ };
181
+ }
182
+
183
+ reset(): void {
184
+ this.#run = undefined;
185
+ this.#request = undefined;
186
+ this.#last = undefined;
187
+ }
188
+ }