@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 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
+ #!/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
+ }
@@ -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
  }
@@ -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 = Bun.resolveSync("./authoring.ts", import.meta.dir);
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
+ }