@adeildo/pi-harness 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/docs/look.md +163 -0
- package/docs/providers.md +35 -0
- package/package.json +54 -0
- package/src/look/data/git.ts +58 -0
- package/src/look/data/snapshot.ts +173 -0
- package/src/look/data/telemetry.ts +188 -0
- package/src/look/data/totals.ts +149 -0
- package/src/look/desktop/palette.ts +207 -0
- package/src/look/desktop/sync.ts +86 -0
- package/src/look/index.ts +448 -0
- package/src/look/render/fit.ts +89 -0
- package/src/look/render/format.ts +136 -0
- package/src/look/render/frame.ts +118 -0
- package/src/look/render/glyphs.ts +155 -0
- package/src/look/render/paint.ts +137 -0
- package/src/look/render/segments.ts +514 -0
- package/src/look/settings.ts +237 -0
- package/src/look/ui/editor.ts +194 -0
- package/src/look/ui/footer.ts +103 -0
- package/src/look/ui/header.ts +212 -0
- package/src/look/ui/screen.ts +39 -0
- package/src/look/ui/working.ts +73 -0
- package/src/providers/index.ts +10 -0
- package/src/providers/subscription/billing.ts +27 -0
- package/src/providers/subscription/feature.ts +43 -0
- package/src/providers/subscription/payload.ts +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Felipe 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,24 @@
|
|
|
1
|
+
# @adeildo/pi-harness
|
|
2
|
+
|
|
3
|
+
The extensions I run [Pi](https://pi.dev) with, as one package. Each feature is its own extension in the package manifest, so `pi config` turns one off without touching the rest.
|
|
4
|
+
|
|
5
|
+
| Feature | What it does |
|
|
6
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| [Look](docs/look.md) | The start card, a framed editor, the answer strip and the footer, painted only with the theme's colours |
|
|
8
|
+
| [Providers](docs/providers.md) | Bills Anthropic OAuth requests to the Claude Pro or Max plan instead of extra usage |
|
|
9
|
+
|
|
10
|
+
The permission dialog is its own package, [`@adeildo/pi-ask-permission`](../ask-permission), because it makes sense without the rest.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pi install npm:@adeildo/pi-harness
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Settings
|
|
19
|
+
|
|
20
|
+
Every feature reads `~/.pi/agent/extensions/pi-harness/settings.json`, and `.pi/extensions/pi-harness/settings.json` in a trusted project for the settings marked _project_. Each feature's page lists its own.
|
|
21
|
+
|
|
22
|
+
## License
|
|
23
|
+
|
|
24
|
+
[MIT](LICENSE)
|
package/docs/look.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Look
|
|
2
|
+
|
|
3
|
+
[Pi](https://pi.dev)'s start screen, editor, footer and working line, redrawn around the data I want in front of me, and painted only with the colours of the theme that is on.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
╭─ π pi v0.99.1 ──────────────────────────────────────────────────────────────────╮
|
|
7
|
+
│ ▄▄▄▄▄▄▄▄▄▄▄▄ │ Anthropic/Claude Opus 5.5 · ▂▃▄▅▆▇ high │
|
|
8
|
+
│ ▀▀▀██▀▀▀▀██▀▀▀ │ ~/Projects/pi-harness · main ↑22 +14 ~2 ?2 │
|
|
9
|
+
│ ██ ██ │ ghost · 18 tools · 9 skills · 6 prompts · 10 ext │
|
|
10
|
+
│ ██ ██ │ ───────────────────────────────────────────────────── │
|
|
11
|
+
│ ▄█▀ ▀██▄▄ │ ctrl+l model shift+tab effort │
|
|
12
|
+
│ │ / commands ! shell │
|
|
13
|
+
│ ready when you are │ ctrl+g external editor /tree session history │
|
|
14
|
+
╰─────────────────────────────────────────────────────────────────────────────────╯
|
|
15
|
+
|
|
16
|
+
0:12 · 3 calls · last call 1.7s wait · ↓906 tok/s
|
|
17
|
+
╭─ ⠋ Thinking · check the frame first · main* ────── ~/Projects/pi-harness · ghost ─╮
|
|
18
|
+
│ what you type │
|
|
19
|
+
╰─ ⏵⏵ auto · anywhere · Anthropic/Claude Opus 5.5 · ▂▃▄▅ high ─── 49% ━━━━━━━━ 489k/1M ─╯
|
|
20
|
+
$0.497 sub · ↑368k in ↓1.7k out · 98% cached · avg ↓290 ↑31k tok/s · mem 12
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What is on the screen
|
|
24
|
+
|
|
25
|
+
**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.
|
|
26
|
+
|
|
27
|
+
**The strip above the editor** is calm on purpose: nothing in it moves while the model streams except the stopwatch.
|
|
28
|
+
|
|
29
|
+
| Piece | Means |
|
|
30
|
+
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| ` 0:12 · 3 calls` | How long the agent has worked on this prompt, and how many calls to the model it made |
|
|
32
|
+
| `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 |
|
|
33
|
+
|
|
34
|
+
The live pieces, which move with every token, are still there for whoever wants them: `speed`, `wait`, `request` and `costRate` go in any slot.
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
**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.
|
|
39
|
+
|
|
40
|
+
**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`).
|
|
41
|
+
|
|
42
|
+
**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.
|
|
43
|
+
|
|
44
|
+
### It holds still
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
## Colours
|
|
51
|
+
|
|
52
|
+
The look never names a colour. Every piece names a **theme token**, so it follows whatever theme pi runs: `dark`, `light`, a JSON of yours, or the desktop theme below. Change the theme and every piece moves with it.
|
|
53
|
+
|
|
54
|
+
| What | Token |
|
|
55
|
+
| --------------------------------------------- | ----------------------------------------------------------- |
|
|
56
|
+
| Folder | `accent` |
|
|
57
|
+
| Branch | `syntaxFunction` |
|
|
58
|
+
| Ahead, staged / behind, modified / conflicted | `success` / `warning` / `error` |
|
|
59
|
+
| Machine, cache | `syntaxType` |
|
|
60
|
+
| Session name | `mdHeading` |
|
|
61
|
+
| Provider, model icon | `syntaxKeyword` |
|
|
62
|
+
| Model | `text`, bold |
|
|
63
|
+
| Sent `↑` | `mdLink` |
|
|
64
|
+
| Received `↓`, speeds, done | `success` |
|
|
65
|
+
| Time to first token, money | `syntaxNumber`, then `warning` past $1 and `error` past $10 |
|
|
66
|
+
| Context | `success`, `warning` past 70%, `error` past 90% |
|
|
67
|
+
| Effort meter, the π | `thinkingMinimal` … `thinkingMax`, one per level |
|
|
68
|
+
| Labels, units, separators | `dim` |
|
|
69
|
+
| Editor frame | pi's effort border colour |
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
### The desktop theme
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
/look explain what every piece on screen is, with its current value
|
|
79
|
+
/look theme write the desktop theme and switch to it
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The mapping, from the Material roles and the terminal colours:
|
|
83
|
+
|
|
84
|
+
| Pi | Desktop |
|
|
85
|
+
| ---------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `accent`, links, keywords, bullets | `primary` |
|
|
87
|
+
| `text`, `muted`, `dim` | `on_surface`, `on_surface_variant`, `outline` |
|
|
88
|
+
| borders | `outline`, `primary`, `outline_variant` |
|
|
89
|
+
| selection, messages, pending tools | the `surface_container` steps |
|
|
90
|
+
| `success`, `warning`, `error` | terminal green, terminal yellow, `error` |
|
|
91
|
+
| tool success and error backgrounds | a surface mixed with green or with `error` |
|
|
92
|
+
| headings, functions, custom labels | `tertiary` |
|
|
93
|
+
| code, types | `secondary`, terminal cyan |
|
|
94
|
+
| effort ramp, off to max | `outline_variant`, `outline`, `secondary`, `primary`, `tertiary`, terminal bright red, terminal red |
|
|
95
|
+
|
|
96
|
+
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`.
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
## Settings
|
|
101
|
+
|
|
102
|
+
They live in the shared file, `~/.pi/agent/extensions/pi-harness/settings.json`, under `look.`. The ones marked _project_ are also read from a trusted project.
|
|
103
|
+
|
|
104
|
+
| Key | Default | Does |
|
|
105
|
+
| ------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
106
|
+
| `strip` | `["elapsed", "last"]` | The line above the editor. _project_ |
|
|
107
|
+
| `frame.topLeft` | `["branch", "session"]` | After the spinner. _project_ |
|
|
108
|
+
| `frame.topRight` | `["path", "host"]` | _project_ |
|
|
109
|
+
| `frame.bottomLeft` | `["status:pi-ask-permission:mode", "model", "effort"]` | _project_ |
|
|
110
|
+
| `frame.bottomRight` | `["context"]` | _project_ |
|
|
111
|
+
| `below` | `["cost", "tokens", "cache", "average", "statuses"]` | The line below the editor. _project_ |
|
|
112
|
+
| `labels` | `true` | A short word before each number: `out`, `in`, `ttft`, `time`, `ctx`, `cache`. Off is denser, with only the glyphs. _project_ |
|
|
113
|
+
| `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_ |
|
|
114
|
+
| `frame.cursor` | `"bar"` | `block`, `bar` or `underline`. The last two use the terminal's own cursor |
|
|
115
|
+
| `header` | `"card"` | `card`, `compact` (two lines) or `off` (pi's own). _project_ |
|
|
116
|
+
| `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 |
|
|
117
|
+
| `separator` | `"dot"` | `dot`, `bar`, `slash` or `space`. _project_ |
|
|
118
|
+
| `pathLength` | `40` | Longest the folder may be before it loses folders from the left. _project_ |
|
|
119
|
+
| `gaugeCells` | `8` | Cells in the context gauge. `0` hides it. _project_ |
|
|
120
|
+
| `peek` | `true` | The spinner says what runs. Off, it says what pi says |
|
|
121
|
+
| `desktop.theme` | `true` | Keep the desktop theme written. Nothing happens without the palette file |
|
|
122
|
+
| `desktop.source` | `"~/.cache/DankMaterialShell/dms-colors.json"` | Where the palette is |
|
|
123
|
+
|
|
124
|
+
### Segments
|
|
125
|
+
|
|
126
|
+
Any segment goes in any slot, in the order the slot lists them.
|
|
127
|
+
|
|
128
|
+
| Id | Shows |
|
|
129
|
+
| -------------- | ----------------------------------------------------------------------------------------------------- |
|
|
130
|
+
| `path` | The working folder, with home as `~` |
|
|
131
|
+
| `branch` | The branch, ahead `↑` and behind `↓`, staged `+`, modified `~`, untracked `?`, conflicts `!`, stashes |
|
|
132
|
+
| `host` | The machine, so a pasted line says where it came from |
|
|
133
|
+
| `session` | The session name, once it has one |
|
|
134
|
+
| `version` | The pi version |
|
|
135
|
+
| `clock` | The time |
|
|
136
|
+
| `model` | `provider/model` |
|
|
137
|
+
| `provider` | The provider on its own |
|
|
138
|
+
| `effort` | The effort meter and level. Hidden for models that do not reason |
|
|
139
|
+
| `context` | Context used, the gauge, tokens over the window |
|
|
140
|
+
| `speed` | This answer's speeds, received `↓` and sent `↑` |
|
|
141
|
+
| `wait` | Time to the first token |
|
|
142
|
+
| `elapsed` | The agent's working time, as a stopwatch, and its calls |
|
|
143
|
+
| `last` | The last finished call: wait for the first token and writing speed |
|
|
144
|
+
| `request` | This answer's tokens, with the cache |
|
|
145
|
+
| `costRate` | This answer's cost per million tokens |
|
|
146
|
+
| `cost` | The session's cost |
|
|
147
|
+
| `tokens` | The session's tokens, with the cache |
|
|
148
|
+
| `cache` | The cache hit of the last prompt |
|
|
149
|
+
| `average` | The session's average speeds, both ways |
|
|
150
|
+
| `statuses` | What other packages report through `setStatus`, minus the ones placed on their own |
|
|
151
|
+
| `status:<key>` | One package's status, by the key it passes to `setStatus` |
|
|
152
|
+
|
|
153
|
+
A `status:<key>` segment is how a package gets a place of its own. `pi-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.
|
|
154
|
+
|
|
155
|
+
## How it fits
|
|
156
|
+
|
|
157
|
+
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.
|
|
158
|
+
|
|
159
|
+
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`.
|
|
160
|
+
|
|
161
|
+
## Credit
|
|
162
|
+
|
|
163
|
+
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).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Providers
|
|
2
|
+
|
|
3
|
+
Provider features for [Pi](https://pi.dev). For now it has one: `subscription`.
|
|
4
|
+
|
|
5
|
+
## subscription
|
|
6
|
+
|
|
7
|
+
With a Claude Pro or Max subscription connected through `/login anthropic`, Pi's requests count as paid extra usage. With this, they come out of the plan, the way Claude Code's do.
|
|
8
|
+
|
|
9
|
+
Pi already sends Claude Code's identity with an OAuth token. Anthropic also checks two more things before billing a request to the plan.
|
|
10
|
+
|
|
11
|
+
1. The user agent. It has to read `claude-cli/<version> (external, cli)`.
|
|
12
|
+
2. The `system` blocks. The first one has to be a billing attribution line, and there can't be anything Claude Code wouldn't send. So pi's own prompt moves to the start of the first user message, wrapped in `<system-instructions>`. The model still reads it there and follows it.
|
|
13
|
+
|
|
14
|
+
This works on any provider that uses the Anthropic Messages API with an OAuth token, not only the one called `anthropic`. A second account registered under another name gets billed to its plan too. Requests with an API key go out untouched.
|
|
15
|
+
|
|
16
|
+
Anthropic won't serve newer models to Claude Code versions it considers too old. When a request comes back with `400 claude_code_version_too_old`, raise the version in `~/.pi/agent/extensions/pi-harness/settings.json`:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"subscription": { "claudeCodeVersion": "2.1.280" }
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then turn off Pi's extra usage warning in `~/.pi/agent/settings.json`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{ "warnings": { "anthropicExtraUsage": false } }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> [!WARNING]
|
|
31
|
+
> This makes Pi introduce itself to Anthropic as Claude Code. Using your subscription from anything other than Claude Code may break Anthropic's terms. Decide whether you're fine with that risk before you turn it on.
|
|
32
|
+
|
|
33
|
+
## Credit
|
|
34
|
+
|
|
35
|
+
The idea and the fingerprint come from [pi-claude-max](https://github.com/bradennss/pi-claude-max) by Braden Lamb, MIT. This is a rewrite. It reads the version from settings, carries pi's cache breakpoints over instead of dropping the one-hour TTL, moves every system block after the identity instead of only the second, and checks for an OAuth token instead of the provider name.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adeildo/pi-harness",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Felipe Adeildo's Pi harness: a start screen, framed editor and footer in your theme's colours, and Anthropic OAuth billed to the Claude plan. Each feature is its own extension, so pi config turns one off.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"anthropic",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"pi-package",
|
|
11
|
+
"statusline",
|
|
12
|
+
"subscription",
|
|
13
|
+
"theme"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Felipe Adeildo",
|
|
18
|
+
"email": "contato@felipeadeildo.com",
|
|
19
|
+
"url": "https://github.com/felipeadeildo"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/felipeadeildo/pi-harness.git",
|
|
24
|
+
"directory": "packages/harness"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"docs",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"type": "module",
|
|
33
|
+
"exports": {
|
|
34
|
+
"./look": "./src/look/index.ts",
|
|
35
|
+
"./providers": "./src/providers/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/look/index.ts",
|
|
51
|
+
"./src/providers/index.ts"
|
|
52
|
+
]
|
|
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
|
+
}
|