@opencode-cockpit/status 0.3.0 → 0.3.2
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 +21 -1
- package/dist/core/fixtures.js +157 -0
- package/dist/core/instructions.js +31 -0
- package/dist/core/report.js +54 -0
- package/dist/core/segments.js +76 -41
- package/dist/tui/components/statusline.js +58 -31
- package/dist/tui/index.js +62 -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 +12 -2
- package/skills/statusline-design/SKILL.md +125 -0
- package/types/cli/ansi.d.ts +10 -0
- package/types/cli/preview.d.ts +14 -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/instructions.d.ts +19 -0
- package/types/core/report.d.ts +50 -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
|
@@ -7,7 +7,9 @@ import { createMemo } from "solid-js";
|
|
|
7
7
|
import pkg from "../../package.json" with { type: "json" };
|
|
8
8
|
import { asSegmentConfig, loadStatusConfig, resolveLines } from "../core/config.js";
|
|
9
9
|
import { loadCustomSegments } from "../core/custom.js";
|
|
10
|
+
import { statuslineBrief } from "../core/instructions.js";
|
|
10
11
|
import { fit, fitColumn } from "../core/render.js";
|
|
12
|
+
import { buildReport } from "../core/report.js";
|
|
11
13
|
import { buildSegments } from "../core/segments.js";
|
|
12
14
|
import { StatusLine } from "./components/statusline.js";
|
|
13
15
|
import { buildContext } from "./state/snapshot.js";
|
|
@@ -37,10 +39,18 @@ export function createStatusTui({
|
|
|
37
39
|
|
|
38
40
|
// Your own segments, loaded before the first draw so they are never missing from frame one.
|
|
39
41
|
let custom = new Map();
|
|
42
|
+
const moduleErrors = [];
|
|
40
43
|
if (config.modules?.length) {
|
|
41
44
|
const loaded = await loadCustomSegments(config.modules, directory);
|
|
42
45
|
custom = loaded.segments;
|
|
43
|
-
|
|
46
|
+
/**
|
|
47
|
+
* A module that will not load is worth saying out loud twice over: its segments simply are
|
|
48
|
+
* not there, which looks exactly like a plugin that did nothing. The toast is gone in ten
|
|
49
|
+
* seconds, so the reason also goes to OpenCode's log, where it can still be read afterwards
|
|
50
|
+
* — a whole session was once spent diagnosing an import that had already explained itself
|
|
51
|
+
* and then disappeared.
|
|
52
|
+
*/
|
|
53
|
+
moduleErrors.push(...loaded.errors);
|
|
44
54
|
for (const error of loaded.errors) {
|
|
45
55
|
api.ui.toast({
|
|
46
56
|
variant: "error",
|
|
@@ -48,6 +58,11 @@ export function createStatusTui({
|
|
|
48
58
|
message: error,
|
|
49
59
|
duration: 10_000
|
|
50
60
|
});
|
|
61
|
+
void api.client.app.log({
|
|
62
|
+
service: "opencode-cockpit.status",
|
|
63
|
+
level: "error",
|
|
64
|
+
message: `statusline module failed to load: ${error}`
|
|
65
|
+
}).catch(() => {});
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
const lines = resolveLines(config);
|
|
@@ -62,7 +77,8 @@ export function createStatusTui({
|
|
|
62
77
|
const segments = createMemo(() => {
|
|
63
78
|
const built = buildSegments(store.context(), spec.segments.map(asSegmentConfig), {
|
|
64
79
|
custom,
|
|
65
|
-
icons: spec.icons
|
|
80
|
+
icons: spec.icons,
|
|
81
|
+
debug: spec.debug
|
|
66
82
|
});
|
|
67
83
|
return spec.stack === "vertical" ? fitColumn(built, width(), spec.maxRows).segments : fit(built, width(), spec.separator).segments;
|
|
68
84
|
});
|
|
@@ -89,6 +105,50 @@ export function createStatusTui({
|
|
|
89
105
|
}
|
|
90
106
|
});
|
|
91
107
|
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `/statusline` draws nothing. Customising a line is an editing job in a file the TUI never
|
|
111
|
+
* names, so the useful thing is not a help panel the user then has to act on themselves — it is
|
|
112
|
+
* a brief handed to the agent already in the session, carrying what it cannot look up: which
|
|
113
|
+
* config file this project reads, what is in it now, and what would not load.
|
|
114
|
+
*/
|
|
115
|
+
api.keymap.registerLayer({
|
|
116
|
+
commands: [{
|
|
117
|
+
name: "cockpit.status.customise",
|
|
118
|
+
title: "Statusline: ask the agent to customise it",
|
|
119
|
+
category: "Statusline",
|
|
120
|
+
namespace: "palette",
|
|
121
|
+
slashName: "statusline",
|
|
122
|
+
run: () => {
|
|
123
|
+
const brief = statuslineBrief(buildReport({
|
|
124
|
+
version: pkg.version,
|
|
125
|
+
directory,
|
|
126
|
+
lines,
|
|
127
|
+
modules: config.modules,
|
|
128
|
+
registered: custom.size,
|
|
129
|
+
errors: moduleErrors
|
|
130
|
+
}));
|
|
131
|
+
/**
|
|
132
|
+
* Sent, not left in the prompt. The brief is forty lines; parked in the input it is a
|
|
133
|
+
* wall of text the user has to scroll past to type their own sentence, and it ends by
|
|
134
|
+
* asking what they want anyway — so the agent is the right place for it to land.
|
|
135
|
+
*
|
|
136
|
+
* On the next tick, because running a slash command clears the prompt it was typed
|
|
137
|
+
* into: writing during the command itself is wiped a moment later, which looks exactly
|
|
138
|
+
* like a command that did nothing.
|
|
139
|
+
*/
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
void api.client.tui.appendPrompt({
|
|
142
|
+
text: brief
|
|
143
|
+
}).then(() => api.client.tui.submitPrompt()).catch(() => api.ui.toast({
|
|
144
|
+
variant: "error",
|
|
145
|
+
title: "Statusline",
|
|
146
|
+
message: "could not reach the prompt"
|
|
147
|
+
}));
|
|
148
|
+
}, 0);
|
|
149
|
+
}
|
|
150
|
+
}]
|
|
151
|
+
});
|
|
92
152
|
const on = surface => lines.filter(spec => spec.surface === surface);
|
|
93
153
|
const bottom = on("bottom");
|
|
94
154
|
const sidebar = on("sidebar");
|
|
@@ -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
|