@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.
- package/LICENSE +21 -0
- package/README.md +254 -0
- package/dist/core/ansi.js +145 -0
- package/dist/core/authoring.js +13 -0
- package/dist/core/builtins/index.js +10 -0
- package/dist/core/builtins/model.js +204 -0
- package/dist/core/builtins/place.js +66 -0
- package/dist/core/builtins/session.js +72 -0
- package/dist/core/builtins/settings.js +28 -0
- package/dist/core/builtins/system.js +58 -0
- package/dist/core/claude-code.js +79 -0
- package/dist/core/command.js +77 -0
- package/dist/core/config.js +187 -0
- package/dist/core/context.js +24 -0
- package/dist/core/custom.js +125 -0
- package/dist/core/format.js +156 -0
- package/dist/core/render.js +88 -0
- package/dist/core/segments.js +148 -0
- package/dist/core/types.js +1 -0
- package/dist/tui/components/statusline.js +135 -0
- package/dist/tui/index.js +110 -0
- package/dist/tui/state/snapshot.js +144 -0
- package/dist/tui/state/store.js +51 -0
- package/package.json +63 -0
- package/types/core/ansi.d.ts +8 -0
- package/types/core/authoring.d.ts +17 -0
- package/types/core/builtins/index.d.ts +6 -0
- package/types/core/builtins/model.d.ts +3 -0
- package/types/core/builtins/place.d.ts +3 -0
- package/types/core/builtins/session.d.ts +3 -0
- package/types/core/builtins/settings.d.ts +13 -0
- package/types/core/builtins/system.d.ts +3 -0
- package/types/core/claude-code.d.ts +61 -0
- package/types/core/command.d.ts +35 -0
- package/types/core/config.d.ts +141 -0
- package/types/core/context.d.ts +78 -0
- package/types/core/custom.d.ts +49 -0
- package/types/core/format.d.ts +56 -0
- package/types/core/render.d.ts +22 -0
- package/types/core/segments.d.ts +26 -0
- package/types/core/types.d.ts +52 -0
- package/types/tui/components/statusline.d.ts +26 -0
- package/types/tui/index.d.ts +10 -0
- package/types/tui/state/snapshot.d.ts +11 -0
- 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
|
+

|
|
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
|
+
}];
|