@opencode-cockpit/status 0.3.0 → 0.3.1
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 +49 -2
- package/dist/cli/ansi.js +46 -0
- package/dist/cli/preview.js +136 -0
- package/dist/core/config.js +91 -9
- package/dist/core/custom.js +2 -0
- package/dist/core/fixtures.js +157 -0
- package/dist/core/segments.js +76 -41
- package/dist/tui/components/statusline.js +58 -31
- package/dist/tui/index.js +14 -2
- package/examples/README.md +60 -0
- package/examples/bottom.ts +150 -0
- package/examples/gallery.ts +209 -0
- package/examples/sidebar-budget.ts +257 -0
- package/examples/sidebar-full.ts +160 -0
- package/examples/sidebar.ts +82 -0
- package/package.json +11 -2
- package/skills/statusline-design/SKILL.md +105 -0
- package/types/cli/ansi.d.ts +10 -0
- package/types/cli/preview.d.ts +13 -0
- package/types/core/config.d.ts +34 -0
- package/types/core/custom.d.ts +2 -2
- package/types/core/fixtures.d.ts +15 -0
- package/types/core/segments.d.ts +10 -1
- package/types/core/types.d.ts +17 -2
- package/types/tui/components/statusline.d.ts +2 -1
package/dist/core/segments.js
CHANGED
|
@@ -78,7 +78,8 @@ export function buildSegments(ctx, configs, options = {}) {
|
|
|
78
78
|
// A bare map is accepted so the common case reads as `buildSegments(ctx, configs, custom)`.
|
|
79
79
|
const {
|
|
80
80
|
custom,
|
|
81
|
-
icons = true
|
|
81
|
+
icons = true,
|
|
82
|
+
debug = false
|
|
82
83
|
} = options instanceof Map ? {
|
|
83
84
|
custom: options
|
|
84
85
|
} : options;
|
|
@@ -87,58 +88,92 @@ export function buildSegments(ctx, configs, options = {}) {
|
|
|
87
88
|
for (const config of configs) {
|
|
88
89
|
// Your own segments are looked up first, so a module can replace a built-in by name.
|
|
89
90
|
const def = custom?.get(config.type) ?? findSegment(config.type);
|
|
90
|
-
if (!def)
|
|
91
|
+
if (!def) {
|
|
92
|
+
// A name nothing answers to: a typo, or a segment from a module that failed to load.
|
|
93
|
+
if (debug) out.push(marker(`?${config.type}`, "error", config.type, seen));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
91
96
|
let piece;
|
|
92
97
|
try {
|
|
93
98
|
piece = def.render(ctx, config);
|
|
94
99
|
} catch {
|
|
95
|
-
|
|
100
|
+
// A segment that throws costs its own place on the line and nothing else.
|
|
101
|
+
if (debug) out.push(marker(`!${config.type}`, "error", config.type, seen));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!piece) {
|
|
105
|
+
// It ran and chose silence: the input it needs is missing, not its name.
|
|
106
|
+
if (debug) out.push(marker(config.type, "border", config.type, seen));
|
|
107
|
+
continue;
|
|
96
108
|
}
|
|
97
|
-
if (!piece) continue;
|
|
98
109
|
const wanted = typeof config.color === "string" ? config.color : undefined;
|
|
99
110
|
const forcedTone = wanted ? toTone(wanted) : undefined;
|
|
100
111
|
const forcedColor = wanted && isLiteralColor(wanted) ? wanted : undefined;
|
|
101
|
-
const runs = runsOf(piece).filter(run => run.text.length > 0);
|
|
102
|
-
if (runs.length === 0) continue;
|
|
103
|
-
const icon = typeof config.icon === "string" ? config.icon : icons ? def.icon : undefined;
|
|
104
|
-
const prefix = typeof config.prefix === "string" ? config.prefix : "";
|
|
105
|
-
const suffix = typeof config.suffix === "string" ? config.suffix : "";
|
|
106
|
-
// The icon gets its own run so it can be dimmed apart from the value it labels.
|
|
107
|
-
if (icon) runs.unshift({
|
|
108
|
-
text: `${icon} `,
|
|
109
|
-
tone: runs[0]?.tone,
|
|
110
|
-
dim: true
|
|
111
|
-
});
|
|
112
|
-
if (prefix) runs.unshift({
|
|
113
|
-
text: prefix,
|
|
114
|
-
tone: runs[0]?.tone
|
|
115
|
-
});
|
|
116
|
-
if (suffix) runs.push({
|
|
117
|
-
text: suffix,
|
|
118
|
-
tone: runs[runs.length - 1]?.tone
|
|
119
|
-
});
|
|
120
112
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
runs
|
|
137
|
-
|
|
138
|
-
|
|
113
|
+
// Several rows are placed one after another: down a column each is a row of its own.
|
|
114
|
+
const pieces = Array.isArray(piece) ? piece : [piece];
|
|
115
|
+
let drew = false;
|
|
116
|
+
for (const one of pieces) {
|
|
117
|
+
const runs = runsOf(one).filter(run => run.text.length > 0);
|
|
118
|
+
if (runs.length === 0) continue;
|
|
119
|
+
const icon = typeof config.icon === "string" ? config.icon : icons ? def.icon : undefined;
|
|
120
|
+
const prefix = typeof config.prefix === "string" ? config.prefix : "";
|
|
121
|
+
const suffix = typeof config.suffix === "string" ? config.suffix : "";
|
|
122
|
+
// The icon gets its own run so it can be dimmed apart from the value it labels.
|
|
123
|
+
if (icon) runs.unshift({
|
|
124
|
+
text: `${icon} `,
|
|
125
|
+
tone: runs[0]?.tone,
|
|
126
|
+
dim: true
|
|
127
|
+
});
|
|
128
|
+
if (prefix) runs.unshift({
|
|
129
|
+
text: prefix,
|
|
130
|
+
tone: runs[0]?.tone
|
|
131
|
+
});
|
|
132
|
+
if (suffix) runs.push({
|
|
133
|
+
text: suffix,
|
|
134
|
+
tone: runs[runs.length - 1]?.tone
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// A colour named on the segment overrides every run in it, icon included; that is what makes
|
|
138
|
+
// `{"type": "cost", "color": "#ff8800"}` do what it looks like it should.
|
|
139
|
+
const styled = forcedTone || forcedColor ? runs.map(run => ({
|
|
140
|
+
...run,
|
|
141
|
+
...(forcedTone ? {
|
|
142
|
+
tone: forcedTone
|
|
143
|
+
} : {}),
|
|
144
|
+
...(forcedColor ? {
|
|
145
|
+
color: forcedColor
|
|
146
|
+
} : {})
|
|
147
|
+
})) : runs;
|
|
148
|
+
const count = (seen.get(config.type) ?? 0) + 1;
|
|
149
|
+
seen.set(config.type, count);
|
|
150
|
+
out.push({
|
|
151
|
+
id: count === 1 ? config.type : `${config.type}#${count}`,
|
|
152
|
+
runs: styled,
|
|
153
|
+
priority: typeof config.priority === "number" ? config.priority : def.priority
|
|
154
|
+
});
|
|
155
|
+
drew = true;
|
|
156
|
+
}
|
|
157
|
+
if (!drew && debug) out.push(marker(config.type, "border", config.type, seen));
|
|
139
158
|
}
|
|
140
159
|
return out;
|
|
141
160
|
}
|
|
161
|
+
|
|
162
|
+
/** What a silent segment looks like while `debug` is on. */
|
|
163
|
+
function marker(text, tone, type, seen) {
|
|
164
|
+
const count = (seen.get(type) ?? 0) + 1;
|
|
165
|
+
seen.set(type, count);
|
|
166
|
+
return {
|
|
167
|
+
id: count === 1 ? type : `${type}#${count}`,
|
|
168
|
+
runs: [{
|
|
169
|
+
text: `⟨${text}⟩`,
|
|
170
|
+
tone,
|
|
171
|
+
dim: true
|
|
172
|
+
}],
|
|
173
|
+
// Above everything, so the thing you are debugging is not the first dropped when it is narrow.
|
|
174
|
+
priority: 100
|
|
175
|
+
};
|
|
176
|
+
}
|
|
142
177
|
const TONES = new Set(["text", "muted", "accent", "success", "warning", "error", "info"]);
|
|
143
178
|
function toTone(value) {
|
|
144
179
|
return TONES.has(value) ? value : undefined;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
2
2
|
import { memo as _$memo } from "@opentui/solid";
|
|
3
3
|
import { effect as _$effect } from "@opentui/solid";
|
|
4
|
-
import { insert as _$insert } from "@opentui/solid";
|
|
5
4
|
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
6
5
|
import { setProp as _$setProp } from "@opentui/solid";
|
|
6
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
7
7
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
8
8
|
/** @jsxImportSource @opentui/solid */
|
|
9
9
|
|
|
@@ -56,13 +56,40 @@ function runStyle(theme, run) {
|
|
|
56
56
|
fg
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Emphasis is markup, not a style property.
|
|
62
|
+
*
|
|
63
|
+
* OpenTUI draws bold, italic and underline through `<b>`, `<i>` and `<u>` around the text — there
|
|
64
|
+
* is no attribute to set. Passing one as a prop on the span type-checks and renders nothing at
|
|
65
|
+
* all, which is how every attribute disappeared at once while the colours kept working.
|
|
66
|
+
*/
|
|
67
|
+
function decorate(run) {
|
|
68
|
+
let node = run.text;
|
|
69
|
+
if (run.underline) node = (() => {
|
|
70
|
+
var _el$ = _$createElement("u");
|
|
71
|
+
_$insert(_el$, node);
|
|
72
|
+
return _el$;
|
|
73
|
+
})();
|
|
74
|
+
if (run.italic) node = (() => {
|
|
75
|
+
var _el$2 = _$createElement("i");
|
|
76
|
+
_$insert(_el$2, node);
|
|
77
|
+
return _el$2;
|
|
78
|
+
})();
|
|
79
|
+
if (run.bold) node = (() => {
|
|
80
|
+
var _el$3 = _$createElement("b");
|
|
81
|
+
_$insert(_el$3, node);
|
|
82
|
+
return _el$3;
|
|
83
|
+
})();
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
59
86
|
export function StatusLine(props) {
|
|
60
87
|
const theme = () => props.api.theme.current;
|
|
61
88
|
const down = () => props.stack === "vertical";
|
|
62
89
|
return (() => {
|
|
63
|
-
var _el$ = _$createElement("box");
|
|
64
|
-
_$setProp(_el
|
|
65
|
-
_$insert(_el
|
|
90
|
+
var _el$4 = _$createElement("box");
|
|
91
|
+
_$setProp(_el$4, "flexShrink", 0);
|
|
92
|
+
_$insert(_el$4, _$createComponent(For, {
|
|
66
93
|
get each() {
|
|
67
94
|
return props.segments();
|
|
68
95
|
},
|
|
@@ -71,18 +98,18 @@ export function StatusLine(props) {
|
|
|
71
98
|
return _$memo(() => !!(index() > 0 && !down()))() && props.separator.length > 0;
|
|
72
99
|
},
|
|
73
100
|
get children() {
|
|
74
|
-
var _el$
|
|
75
|
-
_$setProp(_el$
|
|
76
|
-
_$setProp(_el$
|
|
77
|
-
_$insert(_el$
|
|
78
|
-
_$effect(_$p => _$setProp(_el$
|
|
79
|
-
return _el$
|
|
101
|
+
var _el$5 = _$createElement("text");
|
|
102
|
+
_$setProp(_el$5, "wrapMode", "none");
|
|
103
|
+
_$setProp(_el$5, "flexShrink", 0);
|
|
104
|
+
_$insert(_el$5, () => props.separator);
|
|
105
|
+
_$effect(_$p => _$setProp(_el$5, "fg", theme().borderSubtle, _$p));
|
|
106
|
+
return _el$5;
|
|
80
107
|
}
|
|
81
108
|
}), (() => {
|
|
82
|
-
var _el$
|
|
83
|
-
_$setProp(_el$
|
|
84
|
-
_$setProp(_el$
|
|
85
|
-
_$insert(_el$
|
|
109
|
+
var _el$6 = _$createElement("text");
|
|
110
|
+
_$setProp(_el$6, "wrapMode", "none");
|
|
111
|
+
_$setProp(_el$6, "flexShrink", 0);
|
|
112
|
+
_$insert(_el$6, _$createComponent(For, {
|
|
86
113
|
get each() {
|
|
87
114
|
return segment.runs;
|
|
88
115
|
},
|
|
@@ -92,23 +119,23 @@ export function StatusLine(props) {
|
|
|
92
119
|
},
|
|
93
120
|
get fallback() {
|
|
94
121
|
return (() => {
|
|
95
|
-
var _el$
|
|
96
|
-
_$insert(_el$
|
|
97
|
-
_$effect(_$p => _$setProp(_el$
|
|
98
|
-
return _el$
|
|
122
|
+
var _el$9 = _$createElement("span");
|
|
123
|
+
_$insert(_el$9, () => decorate(run));
|
|
124
|
+
_$effect(_$p => _$setProp(_el$9, "style", runStyle(theme(), run), _$p));
|
|
125
|
+
return _el$9;
|
|
99
126
|
})();
|
|
100
127
|
},
|
|
101
128
|
get children() {
|
|
102
|
-
var _el$
|
|
103
|
-
_el$
|
|
104
|
-
_$insertNode(_el$
|
|
105
|
-
_$insert(_el$
|
|
106
|
-
_$effect(_$p => _$setProp(_el$
|
|
107
|
-
return _el$
|
|
129
|
+
var _el$7 = _$createElement("span"),
|
|
130
|
+
_el$8 = _$createElement("b");
|
|
131
|
+
_$insertNode(_el$7, _el$8);
|
|
132
|
+
_$insert(_el$8, () => run.text);
|
|
133
|
+
_$effect(_$p => _$setProp(_el$7, "style", runStyle(theme(), run), _$p));
|
|
134
|
+
return _el$7;
|
|
108
135
|
}
|
|
109
136
|
})
|
|
110
137
|
}));
|
|
111
|
-
return _el$
|
|
138
|
+
return _el$6;
|
|
112
139
|
})()]
|
|
113
140
|
}));
|
|
114
141
|
_$effect(_p$ => {
|
|
@@ -117,11 +144,11 @@ export function StatusLine(props) {
|
|
|
117
144
|
_v$3 = props.paddingRight ?? 1,
|
|
118
145
|
_v$4 = props.paddingTop ?? 0,
|
|
119
146
|
_v$5 = props.paddingBottom ?? 0;
|
|
120
|
-
_v$ !== _p$.e && (_p$.e = _$setProp(_el
|
|
121
|
-
_v$2 !== _p$.t && (_p$.t = _$setProp(_el
|
|
122
|
-
_v$3 !== _p$.a && (_p$.a = _$setProp(_el
|
|
123
|
-
_v$4 !== _p$.o && (_p$.o = _$setProp(_el
|
|
124
|
-
_v$5 !== _p$.i && (_p$.i = _$setProp(_el
|
|
147
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$4, "flexDirection", _v$, _p$.e));
|
|
148
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "paddingLeft", _v$2, _p$.t));
|
|
149
|
+
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$4, "paddingRight", _v$3, _p$.a));
|
|
150
|
+
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$4, "paddingTop", _v$4, _p$.o));
|
|
151
|
+
_v$5 !== _p$.i && (_p$.i = _$setProp(_el$4, "paddingBottom", _v$5, _p$.i));
|
|
125
152
|
return _p$;
|
|
126
153
|
}, {
|
|
127
154
|
e: undefined,
|
|
@@ -130,6 +157,6 @@ export function StatusLine(props) {
|
|
|
130
157
|
o: undefined,
|
|
131
158
|
i: undefined
|
|
132
159
|
});
|
|
133
|
-
return _el
|
|
160
|
+
return _el$4;
|
|
134
161
|
})();
|
|
135
162
|
}
|
package/dist/tui/index.js
CHANGED
|
@@ -40,7 +40,13 @@ export function createStatusTui({
|
|
|
40
40
|
if (config.modules?.length) {
|
|
41
41
|
const loaded = await loadCustomSegments(config.modules, directory);
|
|
42
42
|
custom = loaded.segments;
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* A module that will not load is worth saying out loud twice over: its segments simply are
|
|
45
|
+
* not there, which looks exactly like a plugin that did nothing. The toast is gone in ten
|
|
46
|
+
* seconds, so the reason also goes to OpenCode's log, where it can still be read afterwards
|
|
47
|
+
* — a whole session was once spent diagnosing an import that had already explained itself
|
|
48
|
+
* and then disappeared.
|
|
49
|
+
*/
|
|
44
50
|
for (const error of loaded.errors) {
|
|
45
51
|
api.ui.toast({
|
|
46
52
|
variant: "error",
|
|
@@ -48,6 +54,11 @@ export function createStatusTui({
|
|
|
48
54
|
message: error,
|
|
49
55
|
duration: 10_000
|
|
50
56
|
});
|
|
57
|
+
void api.client.app.log({
|
|
58
|
+
service: "opencode-cockpit.status",
|
|
59
|
+
level: "error",
|
|
60
|
+
message: `statusline module failed to load: ${error}`
|
|
61
|
+
}).catch(() => {});
|
|
51
62
|
}
|
|
52
63
|
}
|
|
53
64
|
const lines = resolveLines(config);
|
|
@@ -62,7 +73,8 @@ export function createStatusTui({
|
|
|
62
73
|
const segments = createMemo(() => {
|
|
63
74
|
const built = buildSegments(store.context(), spec.segments.map(asSegmentConfig), {
|
|
64
75
|
custom,
|
|
65
|
-
icons: spec.icons
|
|
76
|
+
icons: spec.icons,
|
|
77
|
+
debug: spec.debug
|
|
66
78
|
});
|
|
67
79
|
return spec.stack === "vertical" ? fitColumn(built, width(), spec.maxRows).segments : fit(built, width(), spec.separator).segments;
|
|
68
80
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
Start with a **preset** — a whole line by name, built-ins only, nothing to install:
|
|
4
|
+
|
|
5
|
+
```jsonc
|
|
6
|
+
// ~/.config/opencode-cockpit/config.json
|
|
7
|
+
{ "statusline": { "preset": "default" } }
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
| Preset | Surface | What you get |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| `minimal` | bottom | how full the context is, and what changed |
|
|
13
|
+
| `default` | bottom | the bar, where the tokens went, what changed, how long |
|
|
14
|
+
| `detailed` | bottom | everything the built-ins know, for a wide window |
|
|
15
|
+
| `sidebar` | sidebar | a quiet column beside OpenCode's own blocks |
|
|
16
|
+
|
|
17
|
+
Anything you write beside a preset wins, so it is a starting point rather than a mode:
|
|
18
|
+
|
|
19
|
+
```jsonc
|
|
20
|
+
{ "statusline": { "preset": "default", "separator": " " } }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## When a preset is not enough
|
|
24
|
+
|
|
25
|
+
These are modules — TypeScript you copy and cut. Point `modules` at one and use its segments by
|
|
26
|
+
name. Every one is loaded and asserted by the test suite, so none of them can rot.
|
|
27
|
+
|
|
28
|
+
| File | For | Segments |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| [`bottom.ts`](./bottom.ts) | the whole statusline on one line, no sidebar open | `bar` `filling` `rate` `cached` `tokens` |
|
|
31
|
+
| [`sidebar.ts`](./sidebar.ts) | a small column **beside** OpenCode's Context block | `bar` `split` `changes` |
|
|
32
|
+
| [`sidebar-full.ts`](./sidebar-full.ts) | a column that **replaces** that block — turn it off with `plugin_enabled` | `bar` `window` `cached` `spend` `elapsed` `changes` `todo` |
|
|
33
|
+
| [`sidebar-budget.ts`](./sidebar-budget.ts) | a column as a **table**: fixed label gutter, one bar, a proxy's budget, the branch diff | `title` `bar` `tokens` `in` `out` `cache` `write` `sep` `spend` `avail` `git` |
|
|
34
|
+
| [`gallery.ts`](./gallery.ts) | not a statusline — every technique the renderer can draw, labelled | all of them |
|
|
35
|
+
|
|
36
|
+
```jsonc
|
|
37
|
+
{
|
|
38
|
+
"statusline": {
|
|
39
|
+
"modules": ["~/.config/opencode-cockpit/modules/bottom.ts"],
|
|
40
|
+
"surface": "bottom",
|
|
41
|
+
"segments": ["bar", "filling", "rate", "cached", "session.diff", "session.time"]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Look at it before you ship it
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
bunx @opencode-cockpit/status preview --watch # redraws on every save
|
|
50
|
+
bunx @opencode-cockpit/status preview --state fresh # before the first reply
|
|
51
|
+
bunx @opencode-cockpit/status preview --debug # mark segments that drew nothing
|
|
52
|
+
bunx @opencode-cockpit/status preview --module examples/gallery.ts
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The six sample sessions are the states a design gets wrong: `fresh` (no model, no tokens — most
|
|
56
|
+
designs render a wall of zeroes), `working`, `full`, `unpriced` (behind a proxy, nothing declared),
|
|
57
|
+
`retrying`, and `empty`.
|
|
58
|
+
|
|
59
|
+
The taste rules this bay learned the expensive way live in
|
|
60
|
+
[`../skills/statusline-design/`](../skills/statusline-design/SKILL.md).
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything on one line, for a window with no sidebar open.
|
|
3
|
+
*
|
|
4
|
+
* Every segment here reads the session snapshot, which is the line this bay draws: anything a CLI
|
|
5
|
+
* can already print belongs in a `command`, shaped in the shell, not in a segment that duplicates
|
|
6
|
+
* it. The working tree, for instance, needs no code from us at all:
|
|
7
|
+
*
|
|
8
|
+
* "commands": { "tree": { "run": "git diff --shortstat | awk '{print \"+\"$4\" -\"$6}'" } }
|
|
9
|
+
*
|
|
10
|
+
* This is the whole statusline for someone who lives in the bottom line: how full the context is,
|
|
11
|
+
* which way it is going, what the session has changed, and whether anything needs them. It is the
|
|
12
|
+
* densest of the examples on purpose -- the bottom line is the only surface with real width.
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* "statusline": {
|
|
16
|
+
* "modules": ["<this file>"],
|
|
17
|
+
* "lines": [
|
|
18
|
+
* { "surface": "bottom", "separator": " │ ",
|
|
19
|
+
* "segments": ["bar", "filling", "rate", "cached", "session.diff", "todo",
|
|
20
|
+
* "session.time", "diagnostics"] }
|
|
21
|
+
* ]
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* Two lines on the same surface stack, if you would rather split it in two rows.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { CustomModule, Run, StatusContext } from "@opencode-cockpit/status/segment"
|
|
29
|
+
import { compact, contextRatio, contextUsed, gradient } from "@opencode-cockpit/status/segment"
|
|
30
|
+
|
|
31
|
+
/** Samples kept between ticks: the shape of a session is not visible in any single reading. */
|
|
32
|
+
const samples: { at: number; ratio: number; cost: number }[] = []
|
|
33
|
+
|
|
34
|
+
function sample(ctx: StatusContext): void {
|
|
35
|
+
const last = samples[samples.length - 1]
|
|
36
|
+
if (last && ctx.now - last.at < 1000) return
|
|
37
|
+
samples.push({
|
|
38
|
+
at: ctx.now,
|
|
39
|
+
ratio: contextRatio(ctx.session) ?? 0,
|
|
40
|
+
cost: ctx.session?.cost ?? 0,
|
|
41
|
+
})
|
|
42
|
+
if (samples.length > 30) samples.shift()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default {
|
|
46
|
+
segments: {
|
|
47
|
+
/**
|
|
48
|
+
* The context window as a bar with a scale: every cell carries the colour of the level it
|
|
49
|
+
* stands for, and a quarter tick marks the empty half so the bar can be read against
|
|
50
|
+
* something rather than eyeballed.
|
|
51
|
+
*/
|
|
52
|
+
bar(ctx: StatusContext, config) {
|
|
53
|
+
const ratio = contextRatio(ctx.session)
|
|
54
|
+
if (ratio === undefined) return undefined
|
|
55
|
+
const width = typeof config.width === "number" ? config.width : 20
|
|
56
|
+
const filled = Math.round(ratio * width)
|
|
57
|
+
const runs: Run[] = [{ text: "▕", tone: "border" }]
|
|
58
|
+
for (let cell = 0; cell < width; cell++) {
|
|
59
|
+
if (cell < filled) {
|
|
60
|
+
runs.push({ text: "█", color: gradient((cell + 1) / width) })
|
|
61
|
+
} else {
|
|
62
|
+
const tick = cell > 0 && Math.abs(((cell + 1) / width) % 0.25) < 1 / width
|
|
63
|
+
runs.push({ text: tick ? "┊" : "░", tone: "border" })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
runs.push({ text: "▏", tone: "border" })
|
|
67
|
+
runs.push({
|
|
68
|
+
text: ` ${Math.round(ratio * 100)}%`,
|
|
69
|
+
color: gradient(ratio),
|
|
70
|
+
bold: ratio >= 0.85,
|
|
71
|
+
})
|
|
72
|
+
return { runs }
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How fast the window is filling, as a figure rather than a picture.
|
|
77
|
+
*
|
|
78
|
+
* This was a sparkline. A sparkline redraws its whole shape every second, and a shape moving
|
|
79
|
+
* in the corner of your eye pulls attention away from what you are reading -- which is the one
|
|
80
|
+
* thing a statusline must not do. The same information as a rate changes its digits and
|
|
81
|
+
* nothing else.
|
|
82
|
+
*/
|
|
83
|
+
filling(ctx: StatusContext) {
|
|
84
|
+
sample(ctx)
|
|
85
|
+
const seen = samples.filter((entry) => entry.ratio > 0)
|
|
86
|
+
const first = seen[0]
|
|
87
|
+
const last = seen[seen.length - 1]
|
|
88
|
+
if (!first || !last || last.at === first.at) return undefined
|
|
89
|
+
const perMinute = ((last.ratio - first.ratio) / (last.at - first.at)) * 60_000 * 100
|
|
90
|
+
if (Math.abs(perMinute) < 0.05) return undefined
|
|
91
|
+
// Minutes left at this rate is the figure worth knowing; the rate itself is the input.
|
|
92
|
+
const headroom = (1 - last.ratio) * 100
|
|
93
|
+
const minutesLeft = perMinute > 0 ? headroom / perMinute : Number.POSITIVE_INFINITY
|
|
94
|
+
return {
|
|
95
|
+
runs: [
|
|
96
|
+
{ text: `+${perMinute.toFixed(1)}%/min`, tone: "muted" as const },
|
|
97
|
+
...(Number.isFinite(minutesLeft) && minutesLeft < 90
|
|
98
|
+
? [
|
|
99
|
+
{
|
|
100
|
+
text: ` · ${Math.round(minutesLeft)}m left`,
|
|
101
|
+
tone: minutesLeft < 15 ? ("warning" as const) : ("muted" as const),
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
: []),
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/** Spend per minute with a direction. Silent where nobody declared prices. */
|
|
110
|
+
rate(ctx: StatusContext) {
|
|
111
|
+
sample(ctx)
|
|
112
|
+
const session = ctx.session
|
|
113
|
+
if (!session?.priced || session.cost <= 0) return undefined
|
|
114
|
+
const first = samples[0]
|
|
115
|
+
const last = samples[samples.length - 1]
|
|
116
|
+
if (!first || !last || last.at === first.at) return undefined
|
|
117
|
+
const perMinute = ((last.cost - first.cost) / (last.at - first.at)) * 60_000
|
|
118
|
+
if (perMinute < 0.005) return undefined
|
|
119
|
+
return {
|
|
120
|
+
runs: [
|
|
121
|
+
{ text: perMinute > 0.5 ? "▲" : "▸", tone: perMinute > 0.5 ? "warning" : "muted" },
|
|
122
|
+
{ text: ` $${perMinute.toFixed(2)}/min`, tone: "muted" },
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* How much of the window is cache rather than fresh input. High is cheap and fast; low means
|
|
129
|
+
* the session keeps re-sending what it already sent.
|
|
130
|
+
*/
|
|
131
|
+
cached(ctx: StatusContext) {
|
|
132
|
+
const tokens = ctx.session?.tokens
|
|
133
|
+
const total = contextUsed(tokens)
|
|
134
|
+
if (!tokens || total === 0) return undefined
|
|
135
|
+
const share = tokens.cache.read / total
|
|
136
|
+
return {
|
|
137
|
+
runs: [
|
|
138
|
+
{ text: "▌", tone: share > 0.5 ? "success" : "muted" },
|
|
139
|
+
{ text: `${Math.round(share * 100)}% cached`, tone: "muted" },
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/** The session's own size, for when a window has quietly filled up with one long turn. */
|
|
145
|
+
tokens(ctx: StatusContext) {
|
|
146
|
+
const used = contextUsed(ctx.session?.tokens)
|
|
147
|
+
return used > 0 ? { text: `${compact(used)} tok`, tone: "muted" as const } : undefined
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
} satisfies CustomModule
|