@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 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. Both are loaded
192
- and asserted by the test suite, so neither can rot.
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
 
@@ -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
+ /**
2
+ * Draw your statusline in this terminal, against sample sessions, without restarting OpenCode.
3
+ *
4
+ * bunx @opencode-cockpit/status preview
5
+ * bunx @opencode-cockpit/status preview --config ~/.config/opencode-cockpit/config.json
6
+ * bunx @opencode-cockpit/status preview --state full --width 60
7
+ *
8
+ * Why this exists: a statusline is a visual thing, and editing TypeScript, restarting OpenCode and
9
+ * squinting is a loop measured in minutes. One sidebar took about twenty restarts to design, and
10
+ * three of the mistakes were glyph choices that read differently in a terminal than they do in a
11
+ * sentence. Nothing here can tell you a design is good; it can tell you what it looks like.
12
+ */
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
+ }
@@ -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
- const surface = line.surface ?? "bottom";
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
- paddingLeft: line.paddingLeft ?? PADDING[surface].left,
175
- paddingRight: line.paddingRight ?? PADDING[surface].right,
176
- paddingTop: line.paddingTop ?? PADDING[surface].top,
177
- paddingBottom: line.paddingBottom ?? PADDING[surface].bottom
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
  }
@@ -101,6 +101,8 @@ export async function loadCustomSegments(paths, directory, importer = path => im
101
101
  render(ctx, config) {
102
102
  const value = render(ctx, config);
103
103
  if (value === undefined) return undefined;
104
+ // Several rows: each is drawn on its own, and empty ones are left out.
105
+ if (Array.isArray(value)) return value.length > 0 ? value : undefined;
104
106
  if (typeof value === "string") return value ? {
105
107
  text: value,
106
108
  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
+ };