@opencode-cockpit/status 0.3.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +254 -0
  3. package/dist/core/ansi.js +145 -0
  4. package/dist/core/authoring.js +13 -0
  5. package/dist/core/builtins/index.js +10 -0
  6. package/dist/core/builtins/model.js +204 -0
  7. package/dist/core/builtins/place.js +66 -0
  8. package/dist/core/builtins/session.js +72 -0
  9. package/dist/core/builtins/settings.js +28 -0
  10. package/dist/core/builtins/system.js +58 -0
  11. package/dist/core/claude-code.js +79 -0
  12. package/dist/core/command.js +77 -0
  13. package/dist/core/config.js +187 -0
  14. package/dist/core/context.js +24 -0
  15. package/dist/core/custom.js +125 -0
  16. package/dist/core/format.js +156 -0
  17. package/dist/core/render.js +88 -0
  18. package/dist/core/segments.js +148 -0
  19. package/dist/core/types.js +1 -0
  20. package/dist/tui/components/statusline.js +135 -0
  21. package/dist/tui/index.js +110 -0
  22. package/dist/tui/state/snapshot.js +144 -0
  23. package/dist/tui/state/store.js +51 -0
  24. package/package.json +63 -0
  25. package/types/core/ansi.d.ts +8 -0
  26. package/types/core/authoring.d.ts +17 -0
  27. package/types/core/builtins/index.d.ts +6 -0
  28. package/types/core/builtins/model.d.ts +3 -0
  29. package/types/core/builtins/place.d.ts +3 -0
  30. package/types/core/builtins/session.d.ts +3 -0
  31. package/types/core/builtins/settings.d.ts +13 -0
  32. package/types/core/builtins/system.d.ts +3 -0
  33. package/types/core/claude-code.d.ts +61 -0
  34. package/types/core/command.d.ts +35 -0
  35. package/types/core/config.d.ts +141 -0
  36. package/types/core/context.d.ts +78 -0
  37. package/types/core/custom.d.ts +49 -0
  38. package/types/core/format.d.ts +56 -0
  39. package/types/core/render.d.ts +22 -0
  40. package/types/core/segments.d.ts +26 -0
  41. package/types/core/types.d.ts +52 -0
  42. package/types/tui/components/statusline.d.ts +26 -0
  43. package/types/tui/index.d.ts +10 -0
  44. package/types/tui/state/snapshot.d.ts +11 -0
  45. package/types/tui/state/store.d.ts +28 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codestz
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,254 @@
1
+ # @opencode-cockpit/status
2
+
3
+ A statusline for [OpenCode](https://opencode.ai) you can actually configure — declarative segments,
4
+ your own TypeScript, or the statusline script you already wrote for Claude Code.
5
+
6
+ ![The statusline under an OpenCode conversation: a context bar at 40%, the token total with its cache, input and output parts, the session diff, elapsed time and todo progress](https://raw.githubusercontent.com/Codestz/opencode-cockpit/main/media/statusline.png)
7
+
8
+ Part of [opencode-cockpit](https://github.com/Codestz/opencode-cockpit). Install it on its own, or
9
+ get it with every other bay through the `opencode-cockpit` bundle.
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ ```jsonc
16
+ // ~/.config/opencode/tui.json
17
+ { "plugin": ["@opencode-cockpit/status"] }
18
+ ```
19
+
20
+ That's enough. Without any configuration you get a line under the conversation carrying what
21
+ OpenCode does not already tell you.
22
+
23
+ ## What it shows by default, and why it's so little
24
+
25
+ OpenCode's own furniture already carries a lot: its footer has the path, the branch and the token
26
+ count; its sidebar has the context percentage and the spend; its prompt has the agent and the model.
27
+
28
+ A statusline that repeats those buys you a second copy of something already on screen — on one
29
+ window the context percentage can end up drawn five times. So the default line is what the host
30
+ leaves out:
31
+
32
+ | Segment | Says |
33
+ | --- | --- |
34
+ | `session.status` | working, or `retry 2 in 5s` — OpenCode shows a spinner, not why it stalled |
35
+ | `git.diff` | `+150 / -30` for this session |
36
+ | `todo` | `3/7 todo`, and nothing once the list is done |
37
+ | `session.time` | `12m04s` |
38
+ | `diagnostics` | only when an LSP or MCP server is unhealthy |
39
+
40
+ Everything else is one line of config away — including the things the host shows, if you want them
41
+ in both places.
42
+
43
+ ## Configuration
44
+
45
+ `~/.config/opencode-cockpit/config.json` for every project, `<project>/.cockpit.json` for one, and
46
+ the plugin entry itself beats both.
47
+
48
+ ```jsonc
49
+ {
50
+ "statusline": {
51
+ "surface": "bottom",
52
+ "separator": " │ ",
53
+ "segments": [
54
+ "git.diff",
55
+ { "type": "context", "style": "gradient", "width": 16 },
56
+ { "type": "cost", "color": "#e8b923" },
57
+ "diagnostics"
58
+ ]
59
+ }
60
+ }
61
+ ```
62
+
63
+ A segment is a built-in's name, or that name with settings. Unknown names are skipped rather than
64
+ fatal, so a config written against a newer version costs you a segment and not the line.
65
+
66
+ ### Surfaces
67
+
68
+ Two, each with a job.
69
+
70
+ | `surface` | Where | Good for |
71
+ | --- | --- | --- |
72
+ | `bottom` | full-width line under the conversation | everything, when no sidebar is open |
73
+ | `sidebar` | the sidebar, stacked vertically by default | the time dimension: trends, composition |
74
+
75
+ Use `lines` for more than one at once:
76
+
77
+ ```jsonc
78
+ {
79
+ "statusline": {
80
+ "lines": [
81
+ { "surface": "bottom", "segments": ["git.diff", "todo", "session.time"] },
82
+ { "surface": "sidebar", "segments": ["context", "cost"] }
83
+ ]
84
+ }
85
+ }
86
+ ```
87
+
88
+ Each line takes its own `separator`, `stack` (`horizontal` / `vertical`), `maxRows`, `icons` and
89
+ `paddingLeft` / `paddingRight` / `paddingTop` / `paddingBottom`. The padding defaults line each
90
+ surface up with OpenCode's own content.
91
+
92
+ ### When the terminal is narrow
93
+
94
+ Segments carry a priority, and a line too wide for its surface drops the lowest-priority ones until
95
+ it fits. How full the context is survives a 60-column window; the version string does not. Set
96
+ `priority` on any segment to change what goes first. A vertical line drops by `maxRows` instead.
97
+
98
+ ## Built-in segments
99
+
100
+ | Name | Shows | Settings |
101
+ | --- | --- | --- |
102
+ | `cwd` | folder, relative to the worktree | `maxWidth` |
103
+ | `git.branch` | current branch, dimmed on the default branch | |
104
+ | `session.diff` | `+150 / -30` — what **this session** changed, not the working tree | |
105
+ | `model` | `claude-opus-5` | `full` |
106
+ | `context` | how full the window is | `style`: `percent` \| `bar` \| `gradient` \| `split`, `width`, `warnAt`, `dangerAt` |
107
+ | `tokens` | `78.5k tok` | |
108
+ | `cost` | session spend | `currency`, `showZero` |
109
+ | `todo` | `3/7 todo` | `showComplete` |
110
+ | `session.status` | working, or a retry and its countdown | |
111
+ | `session.time` | elapsed | `coarse` |
112
+ | `diagnostics` | unhealthy LSP and MCP servers | |
113
+ | `version` | this bay's version | |
114
+ | `text` | literal text | `value` |
115
+ | `command` | the output of a shell command | `name`, `row` |
116
+
117
+ Every segment takes `prefix`, `suffix`, `priority`, `color` (a tone name or `#rrggbb`) and `icon`.
118
+
119
+ `session.diff` reports what OpenCode's own Files list shows: the files **this session** changed. A
120
+ file you edited by hand was never part of the session and will not appear.
121
+
122
+ For the **working tree**, pair a command with the `worktree` segment in `examples/bottom.ts` — a
123
+ built-in that shelled out would stop being a pure function of the snapshot, which is what makes
124
+ every one of them testable without a filesystem:
125
+
126
+ ```jsonc
127
+ {
128
+ "commands": { "tree": { "run": "git diff --shortstat", "intervalMs": 5000 } },
129
+ "segments": [
130
+ { "type": "session.diff", "prefix": "session " },
131
+ { "type": "worktree", "prefix": "tree " }
132
+ ]
133
+ }
134
+ ```
135
+
136
+ `session.diff` also answers to `git.diff`, its old and more misleading name.
137
+
138
+ **A segment with nothing to say says nothing.** `cost` hides itself where nobody declared prices
139
+ rather than reporting `$0.00`; `context` hides itself where nobody declared a window rather than
140
+ inventing a denominator; `diagnostics` is silent while everything is healthy. That rule matters
141
+ behind a proxy — see [Proxies](#proxies-litellm-and-friends).
142
+
143
+ ## Your own segments, in TypeScript
144
+
145
+ The declarative config covers the usual line and a shell command covers anything with a CLI. Neither
146
+ can read the session and decide, or remember what it saw a minute ago. A module can.
147
+
148
+ ```ts
149
+ // ~/.config/opencode-cockpit/statusline.ts
150
+ import type { CustomModule, StatusContext } from "@opencode-cockpit/status/segment"
151
+
152
+ export default {
153
+ segments: {
154
+ burn(ctx: StatusContext) {
155
+ const session = ctx.session
156
+ if (!session?.priced || session.cost <= 0) return undefined
157
+ const minutes = (ctx.now - (session.startedAt ?? ctx.now)) / 60_000
158
+ if (minutes < 1) return undefined
159
+ const rate = session.cost / minutes
160
+ return { text: `$${rate.toFixed(2)}/min`, tone: rate > 0.5 ? "warning" : "muted" }
161
+ },
162
+ },
163
+ } satisfies CustomModule
164
+ ```
165
+
166
+ ```jsonc
167
+ {
168
+ "statusline": {
169
+ "modules": ["~/.config/opencode-cockpit/statusline.ts"],
170
+ "segments": ["burn", "git.diff"]
171
+ }
172
+ }
173
+ ```
174
+
175
+ The name is then usable anywhere a built-in is, and reusing a built-in's name replaces it. A segment
176
+ returns a string, a `{ text, tone }`, or `{ runs: [...] }` for several styles in one segment — an
177
+ icon in one colour, a figure in another, a bar whose cells are coloured by what fills them.
178
+
179
+ Paths take `~`, an absolute path, or one relative to the project. A module in your config directory
180
+ works even though nothing is installed next to it: the authoring import is resolved against the
181
+ installed bay rather than against the module's own folder.
182
+
183
+ A module is handed the same snapshot the built-ins get and touches no OpenCode api, which makes a
184
+ custom segment exactly as testable as a built-in. It is loaded once and its segments are called on
185
+ every repaint, so it can keep history — which is how a sparkline or a rate is possible at all.
186
+
187
+ Returning `undefined` hides the segment. A segment that throws loses only its own place on the line.
188
+ A module that will not load raises a toast naming the file, rather than silently dropping segments.
189
+
190
+ **Worked examples** live in [`examples/`](./examples): `bottom.ts` is a complete line for a window
191
+ with no sidebar; `sidebar.ts` is a quiet column beside OpenCode's own Context block. Both are loaded
192
+ and asserted by the test suite, so neither can rot.
193
+
194
+ ## Your Claude Code statusline
195
+
196
+ A shell command, fed the same JSON on stdin that Claude Code's `statusLine` hook sends:
197
+
198
+ ```jsonc
199
+ {
200
+ "statusline": {
201
+ "commands": { "mine": { "run": "~/.claude/statusline.sh", "intervalMs": 2000 } },
202
+ "segments": [{ "type": "command", "name": "mine" }]
203
+ }
204
+ }
205
+ ```
206
+
207
+ An existing script works unchanged. The payload carries `session_id`, `cwd`, `workspace`, `model`,
208
+ `version`, `cost.*` and — when a window was actually declared — `context_window.*` and
209
+ `current_usage.*`. `rate_limits` is deliberately absent: it describes an Anthropic plan's quota,
210
+ which has no meaning behind a proxy.
211
+
212
+ Two differences from Claude Code, both improvements:
213
+
214
+ - **It is not on the draw path.** The command runs on its own interval and the line renders whatever
215
+ it last returned, so a slow script makes the value stale rather than making the interface stutter.
216
+ A failing run leaves the last good value in place.
217
+ - **Its colours survive.** The SGR escapes are parsed rather than stripped: 24-bit `38;2;r;g;b` and
218
+ the 256-colour cube become exact colours, and the basic sixteen become theme tones so a ported
219
+ script still follows the theme you run. Multi-row scripts keep their rows — pick one with `row`.
220
+
221
+ ## Proxies, LiteLLM and friends
222
+
223
+ Tokens always work: they come from the provider's response. Cost and the context percentage are
224
+ computed locally from your model catalogue, so behind a proxy they need declaring in OpenCode's own
225
+ config:
226
+
227
+ ```jsonc
228
+ {
229
+ "provider": {
230
+ "litellm": {
231
+ "npm": "@ai-sdk/openai-compatible",
232
+ "options": { "baseURL": "https://llm.corp/v1" },
233
+ "models": {
234
+ "claude-opus-5": {
235
+ "cost": { "input": 5, "output": 25, "cache_read": 0.5 },
236
+ "limit": { "context": 200000, "output": 64000 }
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ ```
243
+
244
+ Without them the `cost` and `context` segments stay silent instead of reporting `$0.00` and `0%`.
245
+ If your proxy knows the real spend — LiteLLM's `/spend` endpoints do — a `command` segment can read
246
+ it, which is better than any locally multiplied estimate.
247
+
248
+ ## Requirements
249
+
250
+ OpenCode 1.18+ and Bun 1.3.5+.
251
+
252
+ ## Licence
253
+
254
+ MIT
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Turning a script's coloured output into styled runs.
3
+ *
4
+ * Statusline scripts written for Claude Code colour themselves with SGR escapes — true-colour
5
+ * `\\e[38;2;R;G;Bm` in the good ones. Stripping those and drawing the text grey throws away most
6
+ * of what the author wrote; parsing them means a script someone already tuned looks the same here
7
+ * as it does there, without a line of it changing.
8
+ *
9
+ * Only SGR is understood. Cursor movement and the rest are dropped, because a statusline that
10
+ * moves the cursor is not a statusline.
11
+ */
12
+
13
+ const SGR = new RegExp(`${String.fromCharCode(27)}\\[([0-9;]*)m`, "g");
14
+
15
+ /** The 16 basic colours, mapped to tones so they follow the user's theme rather than fighting it. */
16
+ const BASIC_TONE = {
17
+ 30: "text",
18
+ 31: "error",
19
+ 32: "success",
20
+ 33: "warning",
21
+ 34: "info",
22
+ 35: "accent",
23
+ 36: "info",
24
+ 37: "text",
25
+ 90: "muted",
26
+ 91: "error",
27
+ 92: "success",
28
+ 93: "warning",
29
+ 94: "info",
30
+ 95: "accent",
31
+ 96: "info",
32
+ 97: "text"
33
+ };
34
+ const CLEAR = {};
35
+ function hex(r, g, b) {
36
+ return `#${[r, g, b].map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0")).join("")}`;
37
+ }
38
+
39
+ /**
40
+ * The xterm 256-colour cube, so `\\e[38;5;208m` is the orange the author meant rather than a
41
+ * guess. 0-15 are the basics, 16-231 a 6×6×6 cube, 232-255 a grey ramp.
42
+ */
43
+ const CUBE = [0, 95, 135, 175, 215, 255];
44
+ function from256(n) {
45
+ if (n < 16) return undefined; // a basic colour: let the tone mapping handle it
46
+ if (n < 232) {
47
+ const index = n - 16;
48
+ return hex(CUBE[Math.floor(index / 36) % 6], CUBE[Math.floor(index / 6) % 6], CUBE[index % 6]);
49
+ }
50
+ const grey = 8 + (n - 232) * 10;
51
+ return hex(grey, grey, grey);
52
+ }
53
+
54
+ /** Applies one escape's parameters to the running style. */
55
+ function apply(style, params) {
56
+ let next = {
57
+ ...style
58
+ };
59
+ for (let i = 0; i < params.length; i++) {
60
+ const code = params[i];
61
+ if (code === 0) {
62
+ next = {
63
+ ...CLEAR
64
+ };
65
+ } else if (code === 1) {
66
+ next.bold = true;
67
+ } else if (code === 2) {
68
+ next.dim = true;
69
+ } else if (code === 22) {
70
+ next.bold = false;
71
+ next.dim = false;
72
+ } else if (code === 39) {
73
+ next.color = undefined;
74
+ next.tone = undefined;
75
+ } else if (code === 49) {
76
+ next.bg = undefined;
77
+ } else if (code === 38 || code === 48) {
78
+ // Extended colour: 5;n for the 256 palette, 2;r;g;b for true colour.
79
+ const mode = params[i + 1];
80
+ if (mode === 5) {
81
+ const value = params[i + 2];
82
+ const colour = from256(value);
83
+ if (code === 38) {
84
+ next.color = colour;
85
+ next.tone = colour ? undefined : BASIC_TONE[value < 8 ? value + 30 : value + 82];
86
+ } else if (colour) {
87
+ next.bg = colour;
88
+ }
89
+ i += 2;
90
+ } else if (mode === 2) {
91
+ const colour = hex(params[i + 2], params[i + 3], params[i + 4]);
92
+ if (code === 38) {
93
+ next.color = colour;
94
+ next.tone = undefined;
95
+ } else {
96
+ next.bg = colour;
97
+ }
98
+ i += 4;
99
+ }
100
+ } else if (BASIC_TONE[code]) {
101
+ next.tone = BASIC_TONE[code];
102
+ next.color = undefined;
103
+ }
104
+ }
105
+ return next;
106
+ }
107
+
108
+ /**
109
+ * Parses one line of a script's output into runs. Text with no escapes comes back as a single
110
+ * muted run, which is what a plain script should look like.
111
+ */
112
+ export function parseAnsi(line) {
113
+ const runs = [];
114
+ let style = {
115
+ ...CLEAR
116
+ };
117
+ let at = 0;
118
+ SGR.lastIndex = 0;
119
+ for (let match = SGR.exec(line); match !== null; match = SGR.exec(line)) {
120
+ const text = line.slice(at, match.index);
121
+ if (text) runs.push(toRun(text, style));
122
+ const body = match[1] ?? "";
123
+ style = apply(style, body === "" ? [0] : body.split(";").map(part => Number.parseInt(part, 10) || 0));
124
+ at = match.index + match[0].length;
125
+ }
126
+ const rest = line.slice(at);
127
+ if (rest) runs.push(toRun(rest, style));
128
+ // Anything left over (a line of pure escapes) is nothing to draw.
129
+ return runs;
130
+ }
131
+ function toRun(text, style) {
132
+ const run = {
133
+ text
134
+ };
135
+ if (style.color) run.color = style.color;else run.tone = style.tone ?? "muted";
136
+ if (style.bg) run.bg = style.bg;
137
+ if (style.bold) run.bold = true;
138
+ if (style.dim) run.dim = true;
139
+ return run;
140
+ }
141
+
142
+ /** Every line of a script's output, parsed. Claude Code statuslines may print several rows. */
143
+ export function parseAnsiLines(stdout) {
144
+ return stdout.replace(/\r/g, "").split("\n").map(line => parseAnsi(line)).filter(runs => runs.length > 0);
145
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The public surface a statusline module writes against, published as
3
+ * `@opencode-cockpit/status/segment`.
4
+ *
5
+ * import type { StatusContext, CustomModule } from "@opencode-cockpit/status/segment"
6
+ *
7
+ * Everything here is a type or a pure helper: a module never touches OpenCode's plugin api, only
8
+ * the snapshot it is handed. That is what makes a custom segment as testable as a built-in.
9
+ */
10
+
11
+ export { contextRatio, contextUsed, todoRemaining, unhealthy } from "./context.js";
12
+ export { bar, basename, compact, duration, gradient, money, percent, preciseDuration, shortModel, shortPath, truncate, truncateStart } from "./format.js";
13
+ export { cutSegment, runsOf, segmentText, segmentWidth } from "./segments.js";
@@ -0,0 +1,10 @@
1
+ import { SEGMENTS as model } from "./model.js";
2
+ import { SEGMENTS as place } from "./place.js";
3
+ import { SEGMENTS as session } from "./session.js";
4
+ import { SEGMENTS as system } from "./system.js";
5
+
6
+ /**
7
+ * Every built-in, grouped by what it talks about rather than listed in one file: adding a segment
8
+ * should mean opening the twenty lines it belongs with, not four hundred.
9
+ */
10
+ export const BUILTINS = [...place, ...model, ...session, ...system];
@@ -0,0 +1,204 @@
1
+ /** The model in play, how full its context window is, and what the session is spending. */
2
+
3
+ import { contextRatio, contextUsed } from "../context.js";
4
+ import { bar, compact, gradient, money, percent, shortModel } from "../format.js";
5
+ import { formatted, num, str } from "./settings.js";
6
+ export const SEGMENTS = [{
7
+ name: "model",
8
+ icon: "◆",
9
+ priority: 60,
10
+ render(ctx, config) {
11
+ const model = ctx.session?.model;
12
+ if (!model) return undefined;
13
+ const text = config.full === true ? model.modelID : shortModel(model.modelID);
14
+ return {
15
+ text,
16
+ tone: "muted"
17
+ };
18
+ }
19
+ }, {
20
+ name: "context",
21
+ priority: 85,
22
+ icon: "◔",
23
+ render(ctx, config) {
24
+ const ratio = contextRatio(ctx.session);
25
+ // No declared context window (a proxy, a custom provider) means no denominator. Say nothing
26
+ // rather than invent one.
27
+ if (ratio === undefined) return undefined;
28
+ const warnAt = num(config, "warnAt", 0.75);
29
+ const dangerAt = num(config, "dangerAt", 0.9);
30
+ const tone = ratio >= dangerAt ? "error" : ratio >= warnAt ? "warning" : "muted";
31
+ const style = str(config, "style") ?? "percent";
32
+ const width = num(config, "width", 10);
33
+ if (style === "split") {
34
+ // What is actually in the window, by where it came from: cache reads are the cheap part,
35
+ // fresh input the expensive one, output what the model has added. One bar, three colours,
36
+ // so the shape of the session is readable without a second segment.
37
+ const tokens = ctx.session?.tokens;
38
+ if (!tokens) return undefined;
39
+ const limit = ctx.session?.model?.contextLimit;
40
+ const cells = n => Math.round(n / limit * width);
41
+ const cached = cells(tokens.cache.read + tokens.cache.write);
42
+ const fresh = cells(tokens.input);
43
+ const out = cells(tokens.output + tokens.reasoning);
44
+ const used = Math.min(width, cached + fresh + out);
45
+ return {
46
+ runs: [{
47
+ text: "▐",
48
+ tone: "muted",
49
+ dim: true
50
+ }, {
51
+ text: "█".repeat(cached),
52
+ tone: "success"
53
+ }, {
54
+ text: "█".repeat(fresh),
55
+ tone: "info"
56
+ }, {
57
+ text: "█".repeat(out),
58
+ tone: "accent"
59
+ }, {
60
+ text: "·".repeat(Math.max(0, width - used)),
61
+ tone: "muted",
62
+ dim: true
63
+ }, {
64
+ text: "▌",
65
+ tone: "muted",
66
+ dim: true
67
+ }, {
68
+ text: ` ${percent(ratio)}`,
69
+ tone
70
+ }]
71
+ };
72
+ }
73
+ if (style === "gradient") {
74
+ // Every cell carries the colour of the level it stands for, interpolated rather than
75
+ // bucketed, so the bar reads as a measurement instead of three states.
76
+ const filled = Math.round(ratio * width);
77
+ const runs = [];
78
+ for (let cell = 0; cell < width; cell++) {
79
+ runs.push(cell < filled ? {
80
+ text: "█",
81
+ color: gradient((cell + 1) / width)
82
+ } : {
83
+ text: "░",
84
+ tone: "border"
85
+ });
86
+ }
87
+ runs.push({
88
+ text: ` ${percent(ratio)}`,
89
+ color: gradient(ratio),
90
+ bold: ratio >= 0.85
91
+ });
92
+ return {
93
+ runs
94
+ };
95
+ }
96
+ if (style === "bar") {
97
+ const filled = bar(ratio, width);
98
+ /**
99
+ * The fill carries its own meaning: green while there is room, amber as it tightens, red
100
+ * when it is nearly gone. It used to take the same tone as the text, which is muted below
101
+ * the warning threshold -- so the bar sat grey and dead for most of a session, saying
102
+ * nothing while occupying the widest part of the line.
103
+ */
104
+ const fill = ratio >= dangerAt ? "error" : ratio >= warnAt ? "warning" : "success";
105
+ return {
106
+ runs: [{
107
+ text: "▐",
108
+ tone: "border"
109
+ }, {
110
+ text: filled.trimEnd(),
111
+ tone: fill
112
+ }, {
113
+ text: "·".repeat(filled.length - filled.trimEnd().length),
114
+ tone: "border"
115
+ }, {
116
+ text: "▌",
117
+ tone: "border"
118
+ }, {
119
+ text: ` ${percent(ratio)}`,
120
+ tone: fill,
121
+ bold: ratio >= dangerAt
122
+ }]
123
+ };
124
+ }
125
+ return {
126
+ text: `${percent(ratio)} ctx`,
127
+ tone
128
+ };
129
+ }
130
+ }, {
131
+ name: "tokens",
132
+ icon: "⧉",
133
+ priority: 30,
134
+ /**
135
+ * The total by default, and every part of it through `format` — which is the only way to see
136
+ * what a session is actually made of. A cache share of 99% looks wrong until you can read the
137
+ * figures behind it; with prompt caching it is usually right, and the way to know is to look.
138
+ *
139
+ * { "type": "tokens", "format": "{total} tok · in {input} · cache {cacheRead}" }
140
+ */
141
+ render(ctx, config) {
142
+ const tokens = ctx.session?.tokens;
143
+ const used = contextUsed(tokens);
144
+ if (!tokens || used === 0) return undefined;
145
+ const shaped = formatted(config, {
146
+ total: compact(used),
147
+ totalExact: used,
148
+ input: compact(tokens.input),
149
+ output: compact(tokens.output),
150
+ reasoning: compact(tokens.reasoning),
151
+ cacheRead: compact(tokens.cache.read),
152
+ cacheWrite: compact(tokens.cache.write),
153
+ cache: compact(tokens.cache.read + tokens.cache.write)
154
+ });
155
+ if (shaped) return shaped;
156
+
157
+ /**
158
+ * The parts, coloured by what they are rather than labelled in a row of equal-weight text:
159
+ * cache green, fresh input blue, output accent — the same three colours the split bar uses,
160
+ * so the same quantity reads the same wherever it appears. A part that is zero is left out;
161
+ * "write 0" is a column spent saying nothing happened.
162
+ */
163
+ if (str(config, "style") === "parts") {
164
+ const parts = [["cache", tokens.cache.read + tokens.cache.write, "success"], ["in", tokens.input, "info"], ["out", tokens.output + tokens.reasoning, "accent"]];
165
+ const runs = [{
166
+ text: compact(used),
167
+ tone: "text",
168
+ bold: true
169
+ }];
170
+ for (const [label, value, tone] of parts) {
171
+ if (value === 0) continue;
172
+ runs.push({
173
+ text: ` ${label} `,
174
+ tone: "muted",
175
+ dim: true
176
+ });
177
+ runs.push({
178
+ text: compact(value),
179
+ tone
180
+ });
181
+ }
182
+ return {
183
+ runs
184
+ };
185
+ }
186
+ return {
187
+ text: `${compact(used)} tok`,
188
+ tone: "muted"
189
+ };
190
+ }
191
+ }, {
192
+ name: "cost",
193
+ priority: 65,
194
+ render(ctx, config) {
195
+ const session = ctx.session;
196
+ // Unpriced is not the same as free: hide rather than claim a number nobody configured.
197
+ if (!session?.priced) return undefined;
198
+ if (session.cost <= 0 && config.showZero !== true) return undefined;
199
+ return {
200
+ text: money(session.cost, str(config, "currency") ?? "$"),
201
+ tone: "muted"
202
+ };
203
+ }
204
+ }];