@adeildo/pi-look 4.1.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -121
- package/package.json +2 -2
- package/src/data/git.ts +0 -6
- package/src/data/snapshot.ts +0 -13
- package/src/data/telemetry.ts +116 -63
- package/src/data/totals.ts +0 -14
- package/src/desktop/palette.ts +0 -14
- package/src/desktop/sync.ts +1 -8
- package/src/index.ts +42 -61
- package/src/render/fit.ts +0 -10
- package/src/render/format.ts +0 -17
- package/src/render/frame.ts +0 -12
- package/src/render/glyphs.ts +0 -9
- package/src/render/paint.ts +0 -16
- package/src/render/segments.ts +27 -35
- package/src/settings.ts +54 -22
- package/src/ui/editor.ts +2 -15
- package/src/ui/footer.ts +0 -9
- package/src/ui/header.ts +0 -8
- package/src/ui/screen.ts +0 -5
- package/src/ui/working.ts +9 -57
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@adeildo/pi-look)
|
|
4
4
|
|
|
5
|
-
[Pi](https://pi.dev)'s start screen, editor, footer and working line, redrawn around the
|
|
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.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
pi install npm:@adeildo/pi-look
|
|
@@ -16,143 +16,44 @@ It also comes in [`@adeildo/pi-harness`](../harness), with the rest of the piece
|
|
|
16
16
|
|
|
17
17
|
## What is on the screen
|
|
18
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.
|
|
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.
|
|
20
20
|
|
|
21
|
-
**The strip above the editor** is calm on purpose
|
|
21
|
+
**The strip above the editor** is calm on purpose. Nothing in it moves while the model streams except the stopwatch.
|
|
22
22
|
|
|
23
|
-
| Piece
|
|
24
|
-
|
|
|
25
|
-
| `0:12
|
|
26
|
-
| `last call 1.7s wait
|
|
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 |
|
|
27
27
|
|
|
28
|
-
The
|
|
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.
|
|
29
29
|
|
|
30
|
-
The
|
|
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.
|
|
31
31
|
|
|
32
|
-
**The
|
|
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.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
## It holds still
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Numbers change on every frame and the screen must not move with them.
|
|
37
37
|
|
|
38
|
-
|
|
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.
|
|
39
43
|
|
|
40
|
-
|
|
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.
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
## Your theme, not ours
|
|
43
47
|
|
|
44
|
-
|
|
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.
|
|
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.
|
|
66
49
|
|
|
67
50
|
### The desktop theme
|
|
68
51
|
|
|
69
|
-
If you run [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), matugen already made a palette from your wallpaper
|
|
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.
|
|
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.
|
|
95
53
|
|
|
96
54
|
## Settings
|
|
97
55
|
|
|
98
|
-
|
|
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`.
|
|
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.
|
|
156
57
|
|
|
157
58
|
## Credit
|
|
158
59
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-look",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.1.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": "
|
|
41
|
+
"@adeildo/pi-kit": "5.1.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@earendil-works/pi-ai": "*",
|
package/src/data/git.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
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
1
|
import { execFile } from "node:child_process";
|
|
5
2
|
|
|
6
3
|
export interface GitState {
|
|
7
4
|
ahead: number;
|
|
8
5
|
behind: number;
|
|
9
|
-
/** In the index, ready to commit. */
|
|
10
6
|
staged: number;
|
|
11
|
-
/** Changed in the tree and not staged. */
|
|
12
7
|
modified: number;
|
|
13
8
|
untracked: number;
|
|
14
9
|
conflicted: number;
|
|
@@ -45,7 +40,6 @@ export function parseStatus(output: string): GitState {
|
|
|
45
40
|
return state;
|
|
46
41
|
}
|
|
47
42
|
|
|
48
|
-
/** Undefined outside a repository, or when git is missing or slow. */
|
|
49
43
|
export function readGit(cwd: string): Promise<GitState | undefined> {
|
|
50
44
|
return new Promise((resolve) => {
|
|
51
45
|
execFile(
|
package/src/data/snapshot.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
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
1
|
import { hostname } from "node:os";
|
|
4
2
|
|
|
5
3
|
import { VERSION, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
@@ -18,7 +16,6 @@ import {
|
|
|
18
16
|
|
|
19
17
|
export interface ModelInfo {
|
|
20
18
|
name: string;
|
|
21
|
-
/** As the provider wants to be written, not the id pi routes by. */
|
|
22
19
|
provider: string;
|
|
23
20
|
reasoning: boolean;
|
|
24
21
|
}
|
|
@@ -34,7 +31,6 @@ export interface Snapshot {
|
|
|
34
31
|
cwd: string;
|
|
35
32
|
home: string | undefined;
|
|
36
33
|
branch: string | null;
|
|
37
|
-
/** Undefined outside a repository or before the first probe. */
|
|
38
34
|
git: GitState | undefined;
|
|
39
35
|
host: string | undefined;
|
|
40
36
|
sessionName: string | undefined;
|
|
@@ -44,16 +40,12 @@ export interface Snapshot {
|
|
|
44
40
|
context: ContextInfo | undefined;
|
|
45
41
|
totals: Totals;
|
|
46
42
|
cacheHit: number | undefined;
|
|
47
|
-
/** The last prompt was written to the cache and nothing was read: the first call, not a miss. */
|
|
48
43
|
cacheWarming: boolean;
|
|
49
|
-
/** The dollar figure is what the tokens would have cost, not what was billed. */
|
|
50
44
|
subscription: boolean;
|
|
51
45
|
request: RequestView | undefined;
|
|
52
|
-
/** The last request that finished: its numbers are final. */
|
|
53
46
|
last: RequestView | undefined;
|
|
54
47
|
run: RunView | undefined;
|
|
55
48
|
averages: Averages;
|
|
56
|
-
/** What other packages report through `setStatus`, by key, in key order. */
|
|
57
49
|
statuses: ReadonlyMap<string, string>;
|
|
58
50
|
}
|
|
59
51
|
|
|
@@ -98,10 +90,6 @@ interface Derived {
|
|
|
98
90
|
averages: Averages;
|
|
99
91
|
}
|
|
100
92
|
|
|
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
93
|
export class SnapshotReader {
|
|
106
94
|
#key: string | undefined;
|
|
107
95
|
#derived: Derived | undefined;
|
|
@@ -167,7 +155,6 @@ function contextOf(ctx: ExtensionContext): ContextInfo | undefined {
|
|
|
167
155
|
return { percent: usage.percent, tokens: usage.tokens, window: usage.contextWindow };
|
|
168
156
|
}
|
|
169
157
|
|
|
170
|
-
/** A status is one line: newlines and tabs from another package would break the frame. */
|
|
171
158
|
function sanitize(text: string): string {
|
|
172
159
|
return text.replace(/[\r\n\t]+/g, " ").trim();
|
|
173
160
|
}
|
package/src/data/telemetry.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
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
1
|
import {
|
|
9
2
|
emptyTotals,
|
|
10
3
|
perSecond,
|
|
@@ -14,42 +7,48 @@ import {
|
|
|
14
7
|
usageTotals,
|
|
15
8
|
} from "./totals.ts";
|
|
16
9
|
|
|
17
|
-
/** A decode speed over less than this is noise, so it waits. */
|
|
18
10
|
const MIN_SAMPLE_MS = 250;
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
11
|
+
/** Measured over 16,889 assistant messages of one machine: 2.88 characters per output token. */
|
|
12
|
+
const DEFAULT_CHARS_PER_TOKEN = 2.9;
|
|
13
|
+
|
|
14
|
+
/** What a delta carried, so the wait for the first thought can be told from the first word. */
|
|
15
|
+
export type DeltaKind = "thinking" | "writing";
|
|
21
16
|
|
|
22
17
|
export interface RequestView {
|
|
23
|
-
/** The answer is still arriving. */
|
|
24
18
|
streaming: boolean;
|
|
25
|
-
/** Waiting for the first token right now. */
|
|
26
19
|
waiting: boolean;
|
|
27
|
-
/** Since the request left, frozen when it ends. */
|
|
28
20
|
elapsedMs: number;
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
/** From the request leaving to the first token of any kind: what you waited for the model. */
|
|
22
|
+
waitMs: number | undefined;
|
|
23
|
+
/** The same wait while it is still running, so the number counts from the moment it left. */
|
|
24
|
+
waitingMs: number;
|
|
25
|
+
/** From the request leaving to the response headers: the provider's own latency. */
|
|
26
|
+
serverMs: number | undefined;
|
|
27
|
+
/** From the headers to the first token: the model reading the prompt. */
|
|
28
|
+
prefillMs: number | undefined;
|
|
29
|
+
/** From the first thought to the first word written. Unset when it wrote without thinking. */
|
|
30
|
+
thoughtMs: number | undefined;
|
|
31
31
|
decode: number | undefined;
|
|
32
|
-
/** Prompt tokens per second of waiting, once the prompt size is known. */
|
|
33
32
|
prefill: number | undefined;
|
|
34
|
-
/** What this request used so far. */
|
|
35
33
|
usage: Totals;
|
|
36
|
-
/** The output count is an estimate until the provider reports it. */
|
|
37
34
|
estimated: boolean;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
export interface RunView {
|
|
41
38
|
running: boolean;
|
|
42
39
|
elapsedMs: number;
|
|
43
|
-
/** Requests made in this run. */
|
|
44
40
|
requests: number;
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
interface Request {
|
|
48
44
|
startedAt: number;
|
|
45
|
+
/** When the request left, from pi's own `before_provider_request`. */
|
|
46
|
+
sentAt: number | undefined;
|
|
47
|
+
headersAt: number | undefined;
|
|
49
48
|
firstTokenAt: number | undefined;
|
|
49
|
+
firstWritingAt: number | undefined;
|
|
50
50
|
endedAt: number | undefined;
|
|
51
51
|
usage: Totals;
|
|
52
|
-
/** Characters seen, for a live rate before the provider says how many tokens they were. */
|
|
53
52
|
characters: number;
|
|
54
53
|
}
|
|
55
54
|
|
|
@@ -67,12 +66,20 @@ interface UsageLike {
|
|
|
67
66
|
cost: { total: number };
|
|
68
67
|
}
|
|
69
68
|
|
|
69
|
+
function span(from: number | undefined, to: number | undefined): number | undefined {
|
|
70
|
+
return from === undefined || to === undefined ? undefined : to - from;
|
|
71
|
+
}
|
|
72
|
+
|
|
70
73
|
export class Telemetry {
|
|
71
74
|
readonly #now: () => number;
|
|
72
75
|
#run: Run | undefined;
|
|
73
76
|
#request: Request | undefined;
|
|
74
|
-
/** The last request that finished, frozen, so a line can show numbers that no longer move. */
|
|
75
77
|
#last: RequestView | undefined;
|
|
78
|
+
/** The marks of the request in flight, which arrive before the pi message it belongs to. */
|
|
79
|
+
#sentAt: number | undefined;
|
|
80
|
+
#headersAt: number | undefined;
|
|
81
|
+
/** Characters and tokens of the answers that finished, for the live estimate to use a real ratio. */
|
|
82
|
+
#sampled = { characters: 0, output: 0 };
|
|
76
83
|
|
|
77
84
|
constructor(now: () => number = Date.now) {
|
|
78
85
|
this.#now = now;
|
|
@@ -86,35 +93,43 @@ export class Telemetry {
|
|
|
86
93
|
if (this.#run !== undefined && this.#run.endedAt === undefined) this.#run.endedAt = this.#now();
|
|
87
94
|
}
|
|
88
95
|
|
|
89
|
-
/**
|
|
96
|
+
/** Fires before every call to the model, so it is the start of the wait. */
|
|
97
|
+
providerRequestSent(): void {
|
|
98
|
+
this.#sentAt = this.#now();
|
|
99
|
+
this.#headersAt = undefined;
|
|
100
|
+
this.#attach();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Fires when the response arrives, before its stream is read. */
|
|
104
|
+
providerResponseArrived(): void {
|
|
105
|
+
this.#headersAt = this.#now();
|
|
106
|
+
this.#attach();
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
requestStarted(): void {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
usage: emptyTotals(),
|
|
96
|
-
characters: 0,
|
|
97
|
-
};
|
|
110
|
+
// A turn begins, and nothing has left for it yet.
|
|
111
|
+
this.#sentAt = undefined;
|
|
112
|
+
this.#headersAt = undefined;
|
|
113
|
+
this.#begin();
|
|
98
114
|
if (this.#run === undefined || this.#run.endedAt !== undefined) this.runStarted();
|
|
99
115
|
if (this.#run !== undefined) this.#run.requests++;
|
|
100
116
|
}
|
|
101
117
|
|
|
102
|
-
|
|
118
|
+
// Later requests of a turn: the model answered, a tool ran, and the next call is already on its way.
|
|
103
119
|
answerStarted(): void {
|
|
104
|
-
if (this.#request === undefined || this.#request.endedAt !== undefined) this
|
|
120
|
+
if (this.#request === undefined || this.#request.endedAt !== undefined) this.#begin();
|
|
105
121
|
}
|
|
106
122
|
|
|
107
|
-
|
|
108
|
-
answerGrew(delta: string, usage: UsageLike): void {
|
|
123
|
+
answerGrew(delta: string, usage: UsageLike, kind: DeltaKind): void {
|
|
109
124
|
const request = this.#request;
|
|
110
125
|
if (request === undefined || request.endedAt !== undefined) return;
|
|
111
126
|
request.usage = usageTotals(usage);
|
|
112
127
|
if (delta.length === 0) return;
|
|
113
128
|
request.characters += delta.length;
|
|
114
129
|
request.firstTokenAt ??= this.#now();
|
|
130
|
+
if (kind === "writing") request.firstWritingAt ??= this.#now();
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
/** The answer is complete. Returns what the session keeps, or nothing when there was no answer. */
|
|
118
133
|
answerEnded(usage: UsageLike): RequestRecord | undefined {
|
|
119
134
|
const request = this.#request;
|
|
120
135
|
if (request === undefined || request.endedAt !== undefined) return undefined;
|
|
@@ -124,51 +139,31 @@ export class Telemetry {
|
|
|
124
139
|
request.usage = usageTotals(usage);
|
|
125
140
|
if (request.usage.output === 0 && request.firstTokenAt === undefined) return undefined;
|
|
126
141
|
|
|
142
|
+
if (request.characters > 0 && request.usage.output > 0) {
|
|
143
|
+
this.#sampled.characters += request.characters;
|
|
144
|
+
this.#sampled.output += request.usage.output;
|
|
145
|
+
}
|
|
146
|
+
|
|
127
147
|
const firstTokenAt = request.firstTokenAt ?? now;
|
|
128
|
-
|
|
148
|
+
const from = request.sentAt ?? request.startedAt;
|
|
149
|
+
this.#last = this.#view(request);
|
|
129
150
|
return {
|
|
130
151
|
output: request.usage.output,
|
|
131
152
|
prompt: promptOf(request.usage),
|
|
132
|
-
firstTokenMs: firstTokenAt -
|
|
153
|
+
firstTokenMs: firstTokenAt - from,
|
|
133
154
|
generationMs: now - firstTokenAt,
|
|
134
155
|
};
|
|
135
156
|
}
|
|
136
157
|
|
|
137
|
-
/** The request running now, or the last one. */
|
|
138
158
|
request(): RequestView | undefined {
|
|
139
159
|
const request = this.#request;
|
|
140
|
-
|
|
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
|
-
};
|
|
160
|
+
return request === undefined ? undefined : this.#view(request);
|
|
164
161
|
}
|
|
165
162
|
|
|
166
|
-
/** The last request that finished. Undefined until one has. */
|
|
167
163
|
last(): RequestView | undefined {
|
|
168
164
|
return this.#last;
|
|
169
165
|
}
|
|
170
166
|
|
|
171
|
-
/** The run going now, or the last one. */
|
|
172
167
|
run(): RunView | undefined {
|
|
173
168
|
const run = this.#run;
|
|
174
169
|
if (run === undefined) return undefined;
|
|
@@ -184,5 +179,63 @@ export class Telemetry {
|
|
|
184
179
|
this.#run = undefined;
|
|
185
180
|
this.#request = undefined;
|
|
186
181
|
this.#last = undefined;
|
|
182
|
+
this.#sentAt = undefined;
|
|
183
|
+
this.#headersAt = undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#begin(): void {
|
|
187
|
+
this.#request = {
|
|
188
|
+
startedAt: this.#now(),
|
|
189
|
+
sentAt: this.#sentAt,
|
|
190
|
+
headersAt: this.#headersAt,
|
|
191
|
+
firstTokenAt: undefined,
|
|
192
|
+
firstWritingAt: undefined,
|
|
193
|
+
endedAt: undefined,
|
|
194
|
+
usage: emptyTotals(),
|
|
195
|
+
characters: 0,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The marks belong to a request that may not exist yet: pi sends before it opens the message. */
|
|
200
|
+
#attach(): void {
|
|
201
|
+
const request = this.#request;
|
|
202
|
+
if (request === undefined || request.endedAt !== undefined) return;
|
|
203
|
+
request.sentAt = this.#sentAt;
|
|
204
|
+
request.headersAt = this.#headersAt;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#view(request: Request): RequestView {
|
|
208
|
+
const now = this.#now();
|
|
209
|
+
const streaming = request.endedAt === undefined;
|
|
210
|
+
const from = request.sentAt ?? request.startedAt;
|
|
211
|
+
const waitMs = span(from, request.firstTokenAt);
|
|
212
|
+
const estimated = streaming && request.usage.output === 0;
|
|
213
|
+
const output = estimated
|
|
214
|
+
? Math.round(request.characters / this.#charsPerToken())
|
|
215
|
+
: request.usage.output;
|
|
216
|
+
const writingMs =
|
|
217
|
+
request.firstTokenAt === undefined ? 0 : (request.endedAt ?? now) - request.firstTokenAt;
|
|
218
|
+
const thoughtMs = span(request.firstTokenAt, request.firstWritingAt);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
streaming,
|
|
222
|
+
waiting: streaming && request.firstTokenAt === undefined,
|
|
223
|
+
elapsedMs: (request.endedAt ?? now) - request.startedAt,
|
|
224
|
+
waitMs,
|
|
225
|
+
waitingMs: (request.endedAt ?? now) - from,
|
|
226
|
+
serverMs: span(request.sentAt, request.headersAt),
|
|
227
|
+
prefillMs: span(request.headersAt ?? request.sentAt, request.firstTokenAt),
|
|
228
|
+
thoughtMs: thoughtMs !== undefined && thoughtMs > 0 ? thoughtMs : undefined,
|
|
229
|
+
decode: writingMs < MIN_SAMPLE_MS && streaming ? undefined : perSecond(output, writingMs),
|
|
230
|
+
prefill: waitMs === undefined ? undefined : perSecond(promptOf(request.usage), waitMs),
|
|
231
|
+
usage: { ...request.usage, output },
|
|
232
|
+
estimated,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The measured mix of this session, or the one measured over a machine's worth of answers. */
|
|
237
|
+
#charsPerToken(): number {
|
|
238
|
+
const { characters, output } = this.#sampled;
|
|
239
|
+
return output > 0 ? characters / output : DEFAULT_CHARS_PER_TOKEN;
|
|
187
240
|
}
|
|
188
241
|
}
|
package/src/data/totals.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// What a session spent and how fast it went, added up from its own entries. Pi keeps the same totals
|
|
2
|
-
// for its footer but does not export the helper, so this is the one place that sums them.
|
|
3
1
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
2
|
|
|
5
3
|
export interface Totals {
|
|
@@ -18,18 +16,10 @@ interface UsageLike {
|
|
|
18
16
|
cost: { total: number };
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
/**
|
|
22
|
-
* What one request to the model left behind. Pi records usage without a duration, so this entry is
|
|
23
|
-
* where the session speeds come from after a resume.
|
|
24
|
-
*/
|
|
25
19
|
export interface RequestRecord {
|
|
26
|
-
/** Tokens the model wrote. */
|
|
27
20
|
output: number;
|
|
28
|
-
/** Tokens sent: fresh input plus what was read from and written to the cache. */
|
|
29
21
|
prompt: number;
|
|
30
|
-
/** From the request leaving to the first piece of the answer. */
|
|
31
22
|
firstTokenMs: number;
|
|
32
|
-
/** From the first piece of the answer to the last. */
|
|
33
23
|
generationMs: number;
|
|
34
24
|
}
|
|
35
25
|
|
|
@@ -38,9 +28,7 @@ export const REQUEST_ENTRY = "pi-look:request";
|
|
|
38
28
|
const LEGACY_ENTRY = "pi-statusline:turn";
|
|
39
29
|
|
|
40
30
|
export interface Averages {
|
|
41
|
-
/** Output tokens per second of writing, across the branch. */
|
|
42
31
|
decode: number | undefined;
|
|
43
|
-
/** Prompt tokens per second of waiting for the first token, across the branch. */
|
|
44
32
|
prefill: number | undefined;
|
|
45
33
|
}
|
|
46
34
|
|
|
@@ -62,7 +50,6 @@ export function usageTotals(usage: UsageLike): Totals {
|
|
|
62
50
|
return totals;
|
|
63
51
|
}
|
|
64
52
|
|
|
65
|
-
/** The whole branch, not just the messages after the last compaction. */
|
|
66
53
|
export function totalsOf(entries: readonly SessionEntry[]): Totals {
|
|
67
54
|
const totals = emptyTotals();
|
|
68
55
|
|
|
@@ -79,7 +66,6 @@ export function totalsOf(entries: readonly SessionEntry[]): Totals {
|
|
|
79
66
|
return totals;
|
|
80
67
|
}
|
|
81
68
|
|
|
82
|
-
/** How much of the last prompt came from the cache, the way pi's own footer reports it. */
|
|
83
69
|
export function cacheHitPercent(entries: readonly SessionEntry[]): number | undefined {
|
|
84
70
|
for (let index = entries.length - 1; index >= 0; index--) {
|
|
85
71
|
const entry = entries[index];
|