@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/README.md
CHANGED
|
@@ -40,6 +40,19 @@ leaves out:
|
|
|
40
40
|
Everything else is one line of config away — including the things the host shows, if you want them
|
|
41
41
|
in both places.
|
|
42
42
|
|
|
43
|
+
## A whole line by name
|
|
44
|
+
|
|
45
|
+
Composing fourteen segments is a design exercise; most people want a good line. A preset is
|
|
46
|
+
built-ins only — nothing to install, nothing to write:
|
|
47
|
+
|
|
48
|
+
```jsonc
|
|
49
|
+
{ "statusline": { "preset": "default" } }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`minimal` · `default` · `detailed` · `sidebar`. Anything you write beside one wins, so it is a
|
|
53
|
+
starting point and not a mode. See [`examples/`](./examples) for the modules to reach for when a
|
|
54
|
+
preset is not enough.
|
|
55
|
+
|
|
43
56
|
## Configuration
|
|
44
57
|
|
|
45
58
|
`~/.config/opencode-cockpit/config.json` for every project, `<project>/.cockpit.json` for one, and
|
|
@@ -140,6 +153,38 @@ rather than reporting `$0.00`; `context` hides itself where nobody declared a wi
|
|
|
140
153
|
inventing a denominator; `diagnostics` is silent while everything is healthy. That rule matters
|
|
141
154
|
behind a proxy — see [Proxies](#proxies-litellm-and-friends).
|
|
142
155
|
|
|
156
|
+
## Replacing OpenCode's own sidebar blocks
|
|
157
|
+
|
|
158
|
+
Each block of OpenCode's sidebar is an internal plugin, and `tui.json` can switch one off:
|
|
159
|
+
|
|
160
|
+
```jsonc
|
|
161
|
+
// ~/.config/opencode/tui.json
|
|
162
|
+
{
|
|
163
|
+
"plugin": ["@opencode-cockpit/status"],
|
|
164
|
+
"plugin_enabled": { "internal:sidebar-context": false }
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
That removes the host's own `Context / tokens / % used / spent` block, leaving the space to a
|
|
169
|
+
`sidebar` line of your own — the honest way to avoid reading the same figure twice. The same works
|
|
170
|
+
for `internal:sidebar-files`, `-todo`, `-lsp`, `-mcp`, `-footer`, and the home screen's
|
|
171
|
+
`internal:home-footer` and `internal:home-tips`.
|
|
172
|
+
|
|
173
|
+
## What you can draw
|
|
174
|
+
|
|
175
|
+
A segment returns styled runs of text, so the design space is finite and worth seeing all at once.
|
|
176
|
+
`examples/gallery.ts` draws every technique in one column — solid, gradient, fine and split bars, a
|
|
177
|
+
bar painted in background colour, steps, a sparkline, rules, dots, chips, dividers, emphasis, every
|
|
178
|
+
tone, and one segment returning several rows:
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
bunx @opencode-cockpit/status preview --module examples/gallery.ts --state working
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
It is a terminal, not a browser — no DOM, no images, no borders. What there is: truecolor
|
|
185
|
+
foreground and background, bold, dim, and alignment. See
|
|
186
|
+
[What you can draw](https://codestz.github.io/opencode-cockpit/status/drawing/).
|
|
187
|
+
|
|
143
188
|
## Your own segments, in TypeScript
|
|
144
189
|
|
|
145
190
|
The declarative config covers the usual line and a shell command covers anything with a CLI. Neither
|
|
@@ -188,8 +233,10 @@ Returning `undefined` hides the segment. A segment that throws loses only its ow
|
|
|
188
233
|
A module that will not load raises a toast naming the file, rather than silently dropping segments.
|
|
189
234
|
|
|
190
235
|
**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
|
|
192
|
-
|
|
236
|
+
with no sidebar; `sidebar.ts` is a quiet column beside OpenCode's own Context block;
|
|
237
|
+
`sidebar-full.ts` replaces that block; `sidebar-budget.ts` is that column drawn as a table, with a
|
|
238
|
+
budget from a proxy and the branch's diff; `gallery.ts` draws every technique at once. All of them
|
|
239
|
+
are loaded and asserted by the test suite, so none of them can rot.
|
|
193
240
|
|
|
194
241
|
## Your Claude Code statusline
|
|
195
242
|
|
package/dist/cli/ansi.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drawing a statusline to a real terminal, outside OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* The TUI paints runs through OpenTUI against the running theme; here the same runs are written as
|
|
5
|
+
* ANSI so a design can be looked at without restarting anything. The colours approximate a dark
|
|
6
|
+
* theme — close enough to judge a design, never the authority on one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const TONE_RGB = {
|
|
10
|
+
text: [232, 237, 242],
|
|
11
|
+
muted: [138, 148, 160],
|
|
12
|
+
accent: [57, 211, 83],
|
|
13
|
+
success: [57, 211, 83],
|
|
14
|
+
warning: [232, 185, 35],
|
|
15
|
+
error: [248, 81, 73],
|
|
16
|
+
info: [110, 168, 254],
|
|
17
|
+
background: [11, 13, 16],
|
|
18
|
+
panel: [38, 43, 51],
|
|
19
|
+
border: [62, 70, 80]
|
|
20
|
+
};
|
|
21
|
+
function hexRgb(hex) {
|
|
22
|
+
const value = hex.replace("#", "");
|
|
23
|
+
const full = value.length === 3 ? [...value].map(c => c + c).join("") : value;
|
|
24
|
+
if (full.length !== 6) return undefined;
|
|
25
|
+
const n = Number.parseInt(full, 16);
|
|
26
|
+
return Number.isNaN(n) ? undefined : [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
27
|
+
}
|
|
28
|
+
const ESC = String.fromCharCode(27);
|
|
29
|
+
const fg = ([r, g, b]) => `${ESC}[38;2;${r};${g};${b}m`;
|
|
30
|
+
const bg = ([r, g, b]) => `${ESC}[48;2;${r};${g};${b}m`;
|
|
31
|
+
const RESET = `${ESC}[0m`;
|
|
32
|
+
|
|
33
|
+
/** Dim is rendered as a darker colour rather than the SGR attribute, which terminals disagree on. */
|
|
34
|
+
function darken([r, g, b]) {
|
|
35
|
+
return [Math.round(r * 0.62), Math.round(g * 0.62), Math.round(b * 0.62)];
|
|
36
|
+
}
|
|
37
|
+
export function paint(run) {
|
|
38
|
+
const own = run.color ? hexRgb(run.color) : undefined;
|
|
39
|
+
let colour = own ?? TONE_RGB[run.tone ?? "text"];
|
|
40
|
+
if (run.dim) colour = darken(colour);
|
|
41
|
+
const back = run.bg ? hexRgb(run.bg) : run.bgTone ? TONE_RGB[run.bgTone] : undefined;
|
|
42
|
+
return [back ? bg(back) : "", fg(colour), run.bold ? `${ESC}[1m` : "", run.text, RESET].join("");
|
|
43
|
+
}
|
|
44
|
+
export function paintRuns(runs) {
|
|
45
|
+
return runs.map(paint).join("");
|
|
46
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Draw your statusline in this terminal, against sample sessions, without restarting OpenCode.
|
|
4
|
+
*
|
|
5
|
+
* bunx @opencode-cockpit/status preview
|
|
6
|
+
* bunx @opencode-cockpit/status preview --config ~/.config/opencode-cockpit/config.json
|
|
7
|
+
* bunx @opencode-cockpit/status preview --state full --width 60
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: a statusline is a visual thing, and editing TypeScript, restarting OpenCode and
|
|
10
|
+
* squinting is a loop measured in minutes. One sidebar took about twenty restarts to design, and
|
|
11
|
+
* three of the mistakes were glyph choices that read differently in a terminal than they do in a
|
|
12
|
+
* sentence. Nothing here can tell you a design is good; it can tell you what it looks like.
|
|
13
|
+
*/
|
|
14
|
+
import { watch } from "node:fs";
|
|
15
|
+
import { asSegmentConfig, loadStatusConfig, resolveLines } from "../core/config.js";
|
|
16
|
+
import { loadCustomSegments, resolveModulePath } from "../core/custom.js";
|
|
17
|
+
import { FIXTURES } from "../core/fixtures.js";
|
|
18
|
+
import { fit, fitColumn } from "../core/render.js";
|
|
19
|
+
import { buildSegments, segmentWidth } from "../core/segments.js";
|
|
20
|
+
import { paintRuns } from "./ansi.js";
|
|
21
|
+
const args = process.argv.slice(2).filter(arg => arg !== "preview");
|
|
22
|
+
const flag = name => {
|
|
23
|
+
const at = args.indexOf(`--${name}`);
|
|
24
|
+
return at === -1 ? undefined : args[at + 1];
|
|
25
|
+
};
|
|
26
|
+
const has = name => args.includes(`--${name}`);
|
|
27
|
+
if (has("help")) {
|
|
28
|
+
console.log(`
|
|
29
|
+
preview — draw your statusline here, against sample sessions
|
|
30
|
+
|
|
31
|
+
--config <path> a config file (default: your global + project config)
|
|
32
|
+
--module <path> load a module, in addition to any the config names
|
|
33
|
+
--state <name> ${Object.keys(FIXTURES).join(" | ")} (default: every one)
|
|
34
|
+
--width <n> columns available to the line (default: the surface's own)
|
|
35
|
+
--debug mark segments that drew nothing, so silence and typos look different
|
|
36
|
+
--watch redraw whenever the config or a module changes
|
|
37
|
+
`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
const directory = process.cwd();
|
|
41
|
+
const configPath = flag("config");
|
|
42
|
+
const config = configPath ? (await Bun.file(configPath).json()).statusline ?? {} : loadStatusConfig(directory);
|
|
43
|
+
const modules = [...(config.modules ?? []), ...(flag("module") ? [flag("module")] : [])];
|
|
44
|
+
let custom = new Map();
|
|
45
|
+
if (modules.length > 0) {
|
|
46
|
+
const loaded = await loadCustomSegments(modules, directory);
|
|
47
|
+
custom = loaded.segments;
|
|
48
|
+
for (const error of loaded.errors) console.error(` module failed: ${error}`);
|
|
49
|
+
}
|
|
50
|
+
const lines = resolveLines(config);
|
|
51
|
+
const states = flag("state") ? [flag("state")] : Object.keys(FIXTURES);
|
|
52
|
+
const debug = has("debug") || config.debug === true;
|
|
53
|
+
|
|
54
|
+
/** The room each surface actually has in OpenCode, so a preview is not wider than the real thing. */
|
|
55
|
+
const roomFor = (line, terminal) => line.surface === "sidebar" ? 34 : terminal - line.paddingLeft - line.paddingRight;
|
|
56
|
+
const width = Number(flag("width") ?? 0) || 0;
|
|
57
|
+
const dim = text => `${String.fromCharCode(27)}[38;2;110;120;132m${text}${String.fromCharCode(27)}[0m`;
|
|
58
|
+
async function draw() {
|
|
59
|
+
const watched = [];
|
|
60
|
+
for (const state of states) {
|
|
61
|
+
const fixture = FIXTURES[state];
|
|
62
|
+
if (!fixture) {
|
|
63
|
+
console.error(` unknown state "${state}" — try ${Object.keys(FIXTURES).join(", ")}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
console.log(`\n${dim(`── ${state} — ${fixture.about}`)}`);
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const room = width || roomFor(line, process.stdout.columns || 120);
|
|
69
|
+
const ctx = {
|
|
70
|
+
...fixture.ctx,
|
|
71
|
+
width: room
|
|
72
|
+
};
|
|
73
|
+
const built = buildSegments(ctx, line.segments.map(asSegmentConfig), {
|
|
74
|
+
custom,
|
|
75
|
+
icons: line.icons,
|
|
76
|
+
debug
|
|
77
|
+
});
|
|
78
|
+
const fitted = line.stack === "vertical" ? fitColumn(built, room, line.maxRows) : fit(built, room, line.separator);
|
|
79
|
+
if (fitted.segments.length === 0) {
|
|
80
|
+
console.log(` ${dim(`(${line.surface}: nothing to draw)`)}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
console.log(` ${dim(`${line.surface}, ${room} cols`)}`);
|
|
84
|
+
if (line.stack === "vertical") {
|
|
85
|
+
for (const segment of fitted.segments) console.log(` ${paintRuns(segment.runs)}`);
|
|
86
|
+
} else {
|
|
87
|
+
const parts = fitted.segments.map(segment => paintRuns(segment.runs));
|
|
88
|
+
console.log(` ${parts.join(dim(line.separator))}`);
|
|
89
|
+
}
|
|
90
|
+
/** Rows a real sidebar would have dropped in silence. */
|
|
91
|
+
if (fitted.dropped > 0) {
|
|
92
|
+
const over = line.stack === "vertical" ? `maxRows is ${line.maxRows}` : `${room} columns`;
|
|
93
|
+
console.log(` ${dim(`↳ ${fitted.dropped} dropped — ${over}`)}`);
|
|
94
|
+
}
|
|
95
|
+
const widest = Math.max(0, ...fitted.segments.map(segmentWidth));
|
|
96
|
+
if (line.stack === "vertical" && widest > room) {
|
|
97
|
+
console.log(` ${dim(`↳ widest row is ${widest} cols, the column has ${room}`)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
console.log();
|
|
102
|
+
return watched;
|
|
103
|
+
}
|
|
104
|
+
await draw();
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Redraw on change, because the point of a preview is the loop and not the picture. A module is
|
|
108
|
+
* re-imported under a fresh query string: Bun caches modules by specifier, so without it an edit
|
|
109
|
+
* would show the version from the first run forever.
|
|
110
|
+
*/
|
|
111
|
+
if (has("watch")) {
|
|
112
|
+
const files = [...modules.map(m => resolveModulePath(m, directory)), ...(configPath ? [configPath] : [])];
|
|
113
|
+
console.log(dim(` watching ${files.length} file${files.length === 1 ? "" : "s"} — ctrl+c to stop\n`));
|
|
114
|
+
let pending;
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
try {
|
|
117
|
+
watch(file, () => {
|
|
118
|
+
clearTimeout(pending);
|
|
119
|
+
// Editors save in bursts; redraw once the burst is over.
|
|
120
|
+
pending = setTimeout(() => {
|
|
121
|
+
void (async () => {
|
|
122
|
+
if (modules.length > 0) {
|
|
123
|
+
const again = await loadCustomSegments(modules, directory, path => import(`${path}?v=${Date.now()}`));
|
|
124
|
+
custom = again.segments;
|
|
125
|
+
for (const error of again.errors) console.error(` module failed: ${error}`);
|
|
126
|
+
}
|
|
127
|
+
console.clear();
|
|
128
|
+
await draw();
|
|
129
|
+
})();
|
|
130
|
+
}, 120);
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
// A file that cannot be watched is not a reason to stop previewing.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -78,7 +78,7 @@ export function asStatusConfig(input) {
|
|
|
78
78
|
const section = raw.statusline ?? raw.status;
|
|
79
79
|
if (section && typeof section === "object") return section;
|
|
80
80
|
const own = {};
|
|
81
|
-
for (const key of ["enabled", "surface", "segments", "separator", "stack", "icons", "lines", "commands", "modules"]) {
|
|
81
|
+
for (const key of ["enabled", "preset", "surface", "segments", "separator", "stack", "icons", "debug", "maxRows", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "lines", "commands", "modules"]) {
|
|
82
82
|
if (raw[key] !== undefined) Object.assign(own, {
|
|
83
83
|
[key]: raw[key]
|
|
84
84
|
});
|
|
@@ -132,6 +132,82 @@ export const DEFAULT_SEGMENTS = [{
|
|
|
132
132
|
icon: ""
|
|
133
133
|
}, "todo", "session.status", "diagnostics"];
|
|
134
134
|
export const DEFAULT_SEPARATOR = " │ ";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whole lines, by the name of what you want.
|
|
138
|
+
*
|
|
139
|
+
* Composing a good statusline from fourteen segments is a design exercise, and most people want a
|
|
140
|
+
* good line rather than the exercise. Every preset is built-ins only — none needs a module, a
|
|
141
|
+
* command, or anything installed beside it.
|
|
142
|
+
*/
|
|
143
|
+
export const PRESETS = {
|
|
144
|
+
minimal: {
|
|
145
|
+
about: "how full the context is, and what changed",
|
|
146
|
+
surface: "bottom",
|
|
147
|
+
segments: [{
|
|
148
|
+
type: "context",
|
|
149
|
+
style: "bar",
|
|
150
|
+
width: 12,
|
|
151
|
+
icon: ""
|
|
152
|
+
}, {
|
|
153
|
+
type: "session.diff",
|
|
154
|
+
icon: ""
|
|
155
|
+
}, "session.status", "diagnostics"]
|
|
156
|
+
},
|
|
157
|
+
default: {
|
|
158
|
+
about: "the capacity bar, where the tokens went, what changed, how long",
|
|
159
|
+
surface: "bottom",
|
|
160
|
+
segments: DEFAULT_SEGMENTS
|
|
161
|
+
},
|
|
162
|
+
detailed: {
|
|
163
|
+
about: "everything the built-ins know, for a wide window",
|
|
164
|
+
surface: "bottom",
|
|
165
|
+
segments: [{
|
|
166
|
+
type: "context",
|
|
167
|
+
style: "split",
|
|
168
|
+
width: 14,
|
|
169
|
+
icon: ""
|
|
170
|
+
}, {
|
|
171
|
+
type: "tokens",
|
|
172
|
+
style: "parts",
|
|
173
|
+
icon: ""
|
|
174
|
+
}, {
|
|
175
|
+
type: "model",
|
|
176
|
+
icon: ""
|
|
177
|
+
}, "cost", {
|
|
178
|
+
type: "session.diff",
|
|
179
|
+
icon: ""
|
|
180
|
+
}, "todo", {
|
|
181
|
+
type: "session.time",
|
|
182
|
+
icon: ""
|
|
183
|
+
}, "session.status", "diagnostics"]
|
|
184
|
+
},
|
|
185
|
+
sidebar: {
|
|
186
|
+
about: "a quiet column beside OpenCode's own blocks",
|
|
187
|
+
surface: "sidebar",
|
|
188
|
+
segments: [{
|
|
189
|
+
type: "context",
|
|
190
|
+
style: "bar",
|
|
191
|
+
width: 14,
|
|
192
|
+
icon: ""
|
|
193
|
+
}, {
|
|
194
|
+
type: "tokens",
|
|
195
|
+
format: "tk {total}",
|
|
196
|
+
icon: ""
|
|
197
|
+
}, {
|
|
198
|
+
type: "tokens",
|
|
199
|
+
format: "cache {cacheRead}",
|
|
200
|
+
color: "success",
|
|
201
|
+
icon: ""
|
|
202
|
+
}, {
|
|
203
|
+
type: "session.diff",
|
|
204
|
+
icon: ""
|
|
205
|
+
}, {
|
|
206
|
+
type: "session.time",
|
|
207
|
+
icon: ""
|
|
208
|
+
}, "todo", "diagnostics"]
|
|
209
|
+
}
|
|
210
|
+
};
|
|
135
211
|
/** What each surface needs to sit level with the host's own content. */
|
|
136
212
|
const PADDING = {
|
|
137
213
|
// OpenCode's footer indents three columns, and a line hard against the bottom of the window
|
|
@@ -154,27 +230,33 @@ const PADDING = {
|
|
|
154
230
|
/** Normalises whatever the config said into the lines the renderer draws. */
|
|
155
231
|
export function resolveLines(config) {
|
|
156
232
|
const lines = config.lines?.length ? config.lines : [{
|
|
233
|
+
preset: config.preset,
|
|
157
234
|
surface: config.surface,
|
|
158
235
|
segments: config.segments,
|
|
159
236
|
separator: config.separator,
|
|
160
237
|
stack: config.stack,
|
|
161
|
-
icons: config.icons
|
|
238
|
+
icons: config.icons,
|
|
239
|
+
debug: config.debug,
|
|
240
|
+
maxRows: config.maxRows
|
|
162
241
|
}];
|
|
163
242
|
return lines.map(line => {
|
|
164
|
-
|
|
243
|
+
// A preset fills in what was not written; it never overrides what was.
|
|
244
|
+
const preset = PRESETS[line.preset ?? config.preset ?? ""];
|
|
245
|
+
const surface = line.surface ?? config.surface ?? preset?.surface ?? "bottom";
|
|
165
246
|
// The sidebar is a narrow column: across, it would be three truncated words.
|
|
166
247
|
const stack = line.stack ?? config.stack ?? (surface === "sidebar" ? "vertical" : "horizontal");
|
|
167
248
|
return {
|
|
168
249
|
surface,
|
|
169
|
-
segments: line.segments ?? config.segments ?? DEFAULT_SEGMENTS,
|
|
250
|
+
segments: line.segments ?? config.segments ?? preset?.segments ?? DEFAULT_SEGMENTS,
|
|
170
251
|
separator: line.separator ?? config.separator ?? (stack === "vertical" ? "" : DEFAULT_SEPARATOR),
|
|
171
252
|
stack,
|
|
172
|
-
maxRows: line.maxRows ?? 8,
|
|
253
|
+
maxRows: line.maxRows ?? config.maxRows ?? 8,
|
|
173
254
|
icons: line.icons ?? config.icons ?? true,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
255
|
+
debug: line.debug ?? config.debug ?? false,
|
|
256
|
+
paddingLeft: line.paddingLeft ?? config.paddingLeft ?? PADDING[surface].left,
|
|
257
|
+
paddingRight: line.paddingRight ?? config.paddingRight ?? PADDING[surface].right,
|
|
258
|
+
paddingTop: line.paddingTop ?? config.paddingTop ?? PADDING[surface].top,
|
|
259
|
+
paddingBottom: line.paddingBottom ?? config.paddingBottom ?? PADDING[surface].bottom
|
|
178
260
|
};
|
|
179
261
|
});
|
|
180
262
|
}
|
package/dist/core/custom.js
CHANGED
|
@@ -55,8 +55,26 @@ const AUTHORING = "@opencode-cockpit/status/segment";
|
|
|
55
55
|
* imports still work. Only on failure: a module inside a project that installed the bay never
|
|
56
56
|
* takes this path.
|
|
57
57
|
*/
|
|
58
|
+
/**
|
|
59
|
+
* This file's own authoring module, from a checkout or from a build.
|
|
60
|
+
*
|
|
61
|
+
* The specifier is a string argument rather than an import, so the build's `./x.ts` → `./x.js`
|
|
62
|
+
* rewrite never touched it: published copies asked for a `.ts` that is not beside them and threw,
|
|
63
|
+
* which took out the whole fallback and with it every module living outside a project. Ask for
|
|
64
|
+
* both, in the order that keeps a checkout resolving to its source.
|
|
65
|
+
*/
|
|
66
|
+
function authoringModule() {
|
|
67
|
+
for (const candidate of ["./authoring.ts", "./authoring.js"]) {
|
|
68
|
+
try {
|
|
69
|
+
return Bun.resolveSync(candidate, import.meta.dir);
|
|
70
|
+
} catch {
|
|
71
|
+
// try the other extension
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
throw new Error("cannot find the statusline authoring module beside this one");
|
|
75
|
+
}
|
|
58
76
|
async function importWithAuthoring(full) {
|
|
59
|
-
const resolved =
|
|
77
|
+
const resolved = authoringModule();
|
|
60
78
|
const source = await Bun.file(full).text();
|
|
61
79
|
const patched = source.replaceAll(AUTHORING, pathToFileURL(resolved).href);
|
|
62
80
|
if (patched === source) throw new Error(`does not import ${AUTHORING}`);
|
|
@@ -101,6 +119,8 @@ export async function loadCustomSegments(paths, directory, importer = path => im
|
|
|
101
119
|
render(ctx, config) {
|
|
102
120
|
const value = render(ctx, config);
|
|
103
121
|
if (value === undefined) return undefined;
|
|
122
|
+
// Several rows: each is drawn on its own, and empty ones are left out.
|
|
123
|
+
if (Array.isArray(value)) return value.length > 0 ? value : undefined;
|
|
104
124
|
if (typeof value === "string") return value ? {
|
|
105
125
|
text: value,
|
|
106
126
|
tone: "muted"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sample sessions to draw a statusline against, without an OpenCode to draw it in.
|
|
3
|
+
*
|
|
4
|
+
* Designing a statusline by editing TypeScript, restarting OpenCode and looking is a loop measured
|
|
5
|
+
* in minutes; one sidebar cost roughly twenty restarts. These are the states worth checking a
|
|
6
|
+
* design against — and the ones a design usually gets wrong, because they are the states you are
|
|
7
|
+
* not in while you are designing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const session = (over = {}) => ({
|
|
11
|
+
id: "ses_preview",
|
|
12
|
+
title: "A session",
|
|
13
|
+
status: "idle",
|
|
14
|
+
cost: 0.42,
|
|
15
|
+
priced: true,
|
|
16
|
+
messages: 8,
|
|
17
|
+
startedAt: 0,
|
|
18
|
+
model: {
|
|
19
|
+
providerID: "anthropic",
|
|
20
|
+
modelID: "claude-opus-5-20260101",
|
|
21
|
+
contextLimit: 200_000
|
|
22
|
+
},
|
|
23
|
+
tokens: {
|
|
24
|
+
input: 265,
|
|
25
|
+
output: 60,
|
|
26
|
+
reasoning: 0,
|
|
27
|
+
cache: {
|
|
28
|
+
read: 84_900,
|
|
29
|
+
write: 0
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
diff: {
|
|
33
|
+
files: 3,
|
|
34
|
+
additions: 42,
|
|
35
|
+
deletions: 7
|
|
36
|
+
},
|
|
37
|
+
todo: {
|
|
38
|
+
total: 5,
|
|
39
|
+
completed: 2
|
|
40
|
+
},
|
|
41
|
+
...over
|
|
42
|
+
});
|
|
43
|
+
const base = (over = {}) => ({
|
|
44
|
+
now: 2 * 24 * 3600_000 + 15 * 3600_000,
|
|
45
|
+
directory: "/Users/you/code/checkout-service/src",
|
|
46
|
+
worktree: "/Users/you/code/checkout-service",
|
|
47
|
+
home: "/Users/you",
|
|
48
|
+
branch: "feature/checkout",
|
|
49
|
+
defaultBranch: "main",
|
|
50
|
+
version: "0.3.0",
|
|
51
|
+
lsp: [{
|
|
52
|
+
name: "tsserver",
|
|
53
|
+
status: "connected"
|
|
54
|
+
}],
|
|
55
|
+
mcp: [{
|
|
56
|
+
name: "github",
|
|
57
|
+
status: "connected"
|
|
58
|
+
}],
|
|
59
|
+
commands: {},
|
|
60
|
+
width: 120,
|
|
61
|
+
...over
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Each one is a state a design has to survive, not merely a different set of numbers. */
|
|
65
|
+
export const FIXTURES = {
|
|
66
|
+
/** Before the first reply: no model, no tokens, no cost. Most designs render a wall of zeroes. */
|
|
67
|
+
fresh: {
|
|
68
|
+
about: "a new session, before the first reply",
|
|
69
|
+
ctx: base({
|
|
70
|
+
session: session({
|
|
71
|
+
messages: 0,
|
|
72
|
+
cost: 0,
|
|
73
|
+
priced: false,
|
|
74
|
+
model: undefined,
|
|
75
|
+
tokens: undefined,
|
|
76
|
+
diff: {
|
|
77
|
+
files: 0,
|
|
78
|
+
additions: 0,
|
|
79
|
+
deletions: 0
|
|
80
|
+
},
|
|
81
|
+
todo: {
|
|
82
|
+
total: 0,
|
|
83
|
+
completed: 0
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
/** The ordinary case: a few turns in, mostly cache. */
|
|
89
|
+
working: {
|
|
90
|
+
about: "a few turns in, mostly served from cache",
|
|
91
|
+
ctx: base({
|
|
92
|
+
session: session()
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
/** Nearly out of room, which is when the design has to shout. */
|
|
96
|
+
full: {
|
|
97
|
+
about: "the context nearly full, a long session",
|
|
98
|
+
ctx: base({
|
|
99
|
+
session: session({
|
|
100
|
+
cost: 26.24,
|
|
101
|
+
tokens: {
|
|
102
|
+
input: 4_200,
|
|
103
|
+
output: 2_100,
|
|
104
|
+
reasoning: 900,
|
|
105
|
+
cache: {
|
|
106
|
+
read: 181_000,
|
|
107
|
+
write: 3_400
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
diff: {
|
|
111
|
+
files: 28,
|
|
112
|
+
additions: 1_840,
|
|
113
|
+
deletions: 620
|
|
114
|
+
},
|
|
115
|
+
todo: {
|
|
116
|
+
total: 9,
|
|
117
|
+
completed: 9
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
/** Behind a proxy: tokens flow, but nobody declared prices or a window. */
|
|
123
|
+
unpriced: {
|
|
124
|
+
about: "behind a proxy — no declared prices or context window",
|
|
125
|
+
ctx: base({
|
|
126
|
+
session: session({
|
|
127
|
+
priced: false,
|
|
128
|
+
cost: 0,
|
|
129
|
+
model: {
|
|
130
|
+
providerID: "litellm",
|
|
131
|
+
modelID: "claude-opus-5"
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
},
|
|
136
|
+
/** Stalled, which OpenCode itself shows only as a spinner. */
|
|
137
|
+
retrying: {
|
|
138
|
+
about: "a stalled turn, retrying",
|
|
139
|
+
ctx: base({
|
|
140
|
+
now: 1_000,
|
|
141
|
+
session: session({
|
|
142
|
+
status: "retry",
|
|
143
|
+
startedAt: 0,
|
|
144
|
+
retry: {
|
|
145
|
+
attempt: 2,
|
|
146
|
+
message: "rate limited",
|
|
147
|
+
next: 6_000
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
},
|
|
152
|
+
/** Off a session route entirely: everything session-shaped must stay quiet. */
|
|
153
|
+
empty: {
|
|
154
|
+
about: "no session at all",
|
|
155
|
+
ctx: base()
|
|
156
|
+
}
|
|
157
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brief `/statusline` hands to the agent.
|
|
3
|
+
*
|
|
4
|
+
* The command draws nothing. Customising a statusline is an editing job — a JSON file the TUI never
|
|
5
|
+
* names, or a TypeScript module against an api that is written down only on a website — so the
|
|
6
|
+
* useful thing to put on screen is not a help panel the user then has to act on themselves. It is a
|
|
7
|
+
* message to the agent already sitting in the session, carrying the facts it cannot look up: which
|
|
8
|
+
* config file this project actually reads, what is in it right now, and where the taste rules live.
|
|
9
|
+
*
|
|
10
|
+
* Plain text rather than a UI also means the same brief works from anywhere the agent can be asked
|
|
11
|
+
* a question, and can be tested as a string.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { BUILTINS } from "./builtins/index.js";
|
|
15
|
+
import { PRESETS } from "./config.js";
|
|
16
|
+
/** The file an edit should go to: the project's if it exists, else the one for every project. */
|
|
17
|
+
export function targetConfig(report) {
|
|
18
|
+
const project = report.sources[1];
|
|
19
|
+
const global = report.sources[0];
|
|
20
|
+
const chosen = project?.found ? project : global ?? project;
|
|
21
|
+
return {
|
|
22
|
+
path: chosen?.path ?? "",
|
|
23
|
+
exists: chosen?.found ?? false
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function statuslineBrief(report) {
|
|
27
|
+
const target = targetConfig(report);
|
|
28
|
+
const drawing = report.lines.length === 0 ? "nothing — no line is configured" : report.lines.map(line => `${line.surface} (${line.stack}), ${line.segments} segment${line.segments === 1 ? "" : "s"}`).join("; ");
|
|
29
|
+
const modules = report.modules.listed.length === 0 ? "none" : `${report.modules.listed.join(", ")} — ${report.modules.registered} segment${report.modules.registered === 1 ? "" : "s"} registered`;
|
|
30
|
+
return ["Help me customise my opencode-cockpit statusline.", "", "## Where it stands", "", `- Config to edit: ${target.path}${target.exists ? "" : " (does not exist yet — create it)"}`, `- Drawing now: ${drawing}`, `- My modules: ${modules}`, ...(report.modules.errors.length > 0 ? ["- Failing to load:", ...report.modules.errors.map(error => ` - ${error}`)] : []), `- Version: ${report.version}`, "", "## What you can change", "", 'The config file holds `{ "statusline": { ... } }`. Two surfaces: `bottom` (a line under the', "prompt) and `sidebar` (a column). A whole line by name with `preset`, then anything written", "beside it wins:", "", ...Object.entries(PRESETS).map(([name, preset]) => `- \`"preset": "${name}"\` — ${preset.about} (${preset.surface})`), "", `Built-in segment names: ${BUILTINS.map(segment => segment.name).join(", ")}.`, 'A segment can also be `{ "type": "...", ... }` with its own settings, or a shell command.', "", "For anything the built-ins do not cover, write a TypeScript module and list it in `modules`:", "it exports `{ segments: { name(ctx, config) { return { runs: [...] } } } }` against", "`@opencode-cockpit/status/segment`, is handed a snapshot rather than OpenCode's api, and is", "called on every repaint so it can keep history. Returning `undefined` hides a segment.", "", "## Before you say it is done", "", "Look at it. Do not edit, restart OpenCode and judge from a sentence:", "", "```sh", "bunx @opencode-cockpit/status preview --watch # redraws on every save", "bunx @opencode-cockpit/status preview --debug # mark segments that drew nothing", "bunx @opencode-cockpit/status preview --module <my module> --state full", "```", "", "The design rules are a skill shipped with the package at", "`node_modules/@opencode-cockpit/status/skills/statusline-design/SKILL.md` — read it before", "designing anything, and copy the examples beside it rather than inventing glyphs. The reference", "is https://codestz.github.io/opencode-cockpit/status/.", "", "Ask me what I want it to show before you edit anything."].join("\n");
|
|
31
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the statusline is actually doing right now, as data.
|
|
3
|
+
*
|
|
4
|
+
* The bay's own rule is that a segment with nothing to say says nothing, which is right on screen
|
|
5
|
+
* and leaves exactly one question unanswerable from the screen itself: is this line quiet because
|
|
6
|
+
* there is nothing to report, or because the config never arrived? This is the answer — which files
|
|
7
|
+
* were read, which surfaces are drawing, how many segments each carries, and what a module did when
|
|
8
|
+
* it failed to load. The `/statusline` command draws it; it lives here because it is a pure
|
|
9
|
+
* function of the settings, and so can be tested without a terminal.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { globalConfigPath, PROJECT_FILE } from "./config.js";
|
|
15
|
+
export function buildReport(input) {
|
|
16
|
+
const global = globalConfigPath(input.env ?? process.env);
|
|
17
|
+
const project = join(input.directory, PROJECT_FILE);
|
|
18
|
+
return {
|
|
19
|
+
version: input.version,
|
|
20
|
+
sources: [global, project].map(path => ({
|
|
21
|
+
path,
|
|
22
|
+
found: existsSync(path)
|
|
23
|
+
})),
|
|
24
|
+
lines: input.lines.map(line => ({
|
|
25
|
+
surface: line.surface,
|
|
26
|
+
stack: line.stack,
|
|
27
|
+
segments: line.segments.length,
|
|
28
|
+
maxRows: line.stack === "vertical" ? line.maxRows : undefined
|
|
29
|
+
})),
|
|
30
|
+
modules: {
|
|
31
|
+
listed: [...(input.modules ?? [])],
|
|
32
|
+
registered: input.registered,
|
|
33
|
+
errors: [...input.errors]
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The one sentence a report is worth opening for: whether anything is drawing at all, and if not,
|
|
40
|
+
* the likeliest reason given what was found.
|
|
41
|
+
*/
|
|
42
|
+
export function reportHeadline(report) {
|
|
43
|
+
if (report.modules.errors.length > 0) {
|
|
44
|
+
const count = report.modules.errors.length;
|
|
45
|
+
return `${count} module${count === 1 ? "" : "s"} failed to load — segments from ${count === 1 ? "it" : "them"} are missing`;
|
|
46
|
+
}
|
|
47
|
+
if (report.lines.length === 0) return "Nothing is drawing: no line is configured";
|
|
48
|
+
if (report.sources.every(source => !source.found)) {
|
|
49
|
+
return "Drawing the defaults — neither config file exists yet";
|
|
50
|
+
}
|
|
51
|
+
const rows = report.lines.reduce((sum, line) => sum + line.segments, 0);
|
|
52
|
+
const where = [...new Set(report.lines.map(line => line.surface))].join(" and ");
|
|
53
|
+
return `${rows} segment${rows === 1 ? "" : "s"} across the ${where}`;
|
|
54
|
+
}
|