@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.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Every technique the renderer can draw, in one column, labelled.
3
+ *
4
+ * Not a statusline — a catalogue. A segment can only return styled runs of text, so "what can I
5
+ * draw?" has a finite answer, and nobody can design against a list of segment names. Preview it
6
+ * and copy the row you want:
7
+ *
8
+ * bunx @opencode-cockpit/status preview --module <this file> --state working
9
+ *
10
+ * The bounds, so they are written down somewhere: a run carries a foreground colour (a theme tone
11
+ * or a hex), a background, bold and dim. A segment draws one row, or an array of rows. There are
12
+ * no borders, no images and no HTML — this is a terminal, and the freedom is in colour, in block
13
+ * glyphs, and in alignment.
14
+ */
15
+
16
+ import type { CustomModule, Piece, Run, StatusContext, Tone } from "@opencode-cockpit/status/segment"
17
+ import { contextRatio, gradient } from "@opencode-cockpit/status/segment"
18
+
19
+ const W = 16
20
+ const ratio = (ctx: StatusContext) => contextRatio(ctx.session) ?? 0.42
21
+
22
+ /** A label column, so a catalogue of rows reads as a table. */
23
+ function labelled(label: string, runs: Run[]): { runs: Run[] } {
24
+ return { runs: [{ text: label.padEnd(11), tone: "muted", dim: true }, ...runs] }
25
+ }
26
+
27
+ /** Eighths, for a bar that resolves more than one cell at a time. */
28
+ const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]
29
+
30
+ export default {
31
+ segments: {
32
+ /** Solid fill against a solid dark track. The safest bar: no gaps, no dashes. */
33
+ barSolid(ctx: StatusContext) {
34
+ const filled = Math.round(ratio(ctx) * W)
35
+ return labelled("solid", [
36
+ { text: "▕", tone: "border" },
37
+ ...Array.from({ length: W }, (_, c) =>
38
+ c < filled ? { text: "█", tone: "accent" as const } : { text: "█", tone: "border" as const },
39
+ ),
40
+ { text: "▏", tone: "border" },
41
+ ])
42
+ },
43
+
44
+ /** The same bar, each filled cell coloured by the level it stands for. */
45
+ barGradient(ctx: StatusContext) {
46
+ const filled = Math.round(ratio(ctx) * W)
47
+ return labelled("gradient", [
48
+ { text: "▕", tone: "border" },
49
+ ...Array.from({ length: W }, (_, c) =>
50
+ c < filled ? { text: "█", color: gradient((c + 1) / W) } : { text: "█", tone: "border" as const },
51
+ ),
52
+ { text: "▏", tone: "border" },
53
+ ])
54
+ },
55
+
56
+ /** Eighths resolve a fraction of a cell — worth it when the bar is short. */
57
+ barFine(ctx: StatusContext) {
58
+ const exact = ratio(ctx) * W
59
+ const full = Math.floor(exact)
60
+ const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ""
61
+ return labelled("fine", [
62
+ { text: "█".repeat(full), tone: "accent" },
63
+ ...(part ? [{ text: part, tone: "accent" as const }] : []),
64
+ { text: "█".repeat(Math.max(0, W - full - (part ? 1 : 0))), tone: "border" },
65
+ ])
66
+ },
67
+
68
+ /** One bar coloured by what fills it, for a quantity made of parts. */
69
+ barSplit(ctx: StatusContext) {
70
+ const tokens = ctx.session?.tokens
71
+ if (!tokens) return undefined
72
+ const total = tokens.input + tokens.output + tokens.cache.read + tokens.cache.write
73
+ const cells = (n: number) => Math.round((n / total) * W * ratio(ctx))
74
+ return labelled("split", [
75
+ { text: "█".repeat(cells(tokens.cache.read)), tone: "success" },
76
+ { text: "█".repeat(cells(tokens.input)), tone: "info" },
77
+ { text: "█".repeat(cells(tokens.output)), tone: "accent" },
78
+ { text: "█".repeat(Math.max(0, W - Math.round(ratio(ctx) * W))), tone: "border" },
79
+ ])
80
+ },
81
+
82
+ /** Discrete steps, when the number is a count rather than a proportion. */
83
+ barSteps(ctx: StatusContext) {
84
+ const filled = Math.round(ratio(ctx) * 10)
85
+ return labelled("steps", [
86
+ {
87
+ text: Array.from({ length: 10 }, (_, c) => (c < filled ? "▮" : "▯")).join(""),
88
+ tone: "accent",
89
+ },
90
+ ])
91
+ },
92
+
93
+ /** A bar drawn in background colour: taller-looking, and it keeps text on top of it. */
94
+ barFilled(ctx: StatusContext) {
95
+ const filled = Math.round(ratio(ctx) * W)
96
+ return labelled("background", [
97
+ { text: " ".repeat(filled), bgTone: "accent" },
98
+ { text: " ".repeat(W - filled), bgTone: "panel" },
99
+ { text: ` ${Math.round(ratio(ctx) * 100)}%`, tone: "muted" },
100
+ ])
101
+ },
102
+
103
+ /**
104
+ * Background *and* level colour: the chunkiest bar available, because nothing is drawn at
105
+ * all — every cell is a painted space, so there is no glyph shape to read around.
106
+ */
107
+ barPaint(ctx: StatusContext) {
108
+ const filled = Math.round(ratio(ctx) * W)
109
+ return labelled("paint", [
110
+ ...Array.from({ length: W }, (_, c) =>
111
+ c < filled ? { text: " ", bg: gradient((c + 1) / W) } : { text: " ", bgTone: "border" as const },
112
+ ),
113
+ { text: ` ${Math.round(ratio(ctx) * 100)}%`, color: gradient(ratio(ctx)) },
114
+ ])
115
+ },
116
+
117
+ /**
118
+ * Braille packs two columns per cell, so a bar is twice the resolution in the same width.
119
+ * It is also the smallest thing here — good beside text, poor as a headline.
120
+ */
121
+ barBraille(ctx: StatusContext) {
122
+ const dots = Math.round(ratio(ctx) * W * 2)
123
+ const cells = Array.from({ length: W }, (_, c) => {
124
+ const left = dots > c * 2
125
+ const right = dots > c * 2 + 1
126
+ return left && right ? "⣿" : left ? "⡇" : "⠀"
127
+ })
128
+ return labelled("braille", [{ text: cells.join(""), tone: "accent" }])
129
+ },
130
+
131
+ /** A sparkline: only worth it for history, and only where movement is acceptable. */
132
+ spark() {
133
+ const points = [0.1, 0.18, 0.3, 0.28, 0.46, 0.52, 0.5, 0.71]
134
+ return labelled("sparkline", [
135
+ {
136
+ text: points.map((p) => "▁▂▃▄▅▆▇█"[Math.min(7, Math.floor(p * 8))]).join(""),
137
+ tone: "info",
138
+ },
139
+ ])
140
+ },
141
+
142
+ /** Markers: a rule reads at one column wide, a chip needs the width of its text. */
143
+ markers() {
144
+ return labelled("markers", [
145
+ { text: "▌", tone: "success" },
146
+ { text: "rule ", tone: "muted" },
147
+ { text: "▪ ", tone: "warning" },
148
+ { text: "dot ", tone: "muted" },
149
+ { text: " chip ", tone: "background", bgTone: "info", bold: true },
150
+ ])
151
+ },
152
+
153
+ /** Separating things: a hairline, an inline pipe, and plain space. */
154
+ dividers() {
155
+ return labelled("dividers", [
156
+ { text: "──────", tone: "border", dim: true },
157
+ { text: " │ ", tone: "border" },
158
+ { text: "· · ·", tone: "border", dim: true },
159
+ ])
160
+ },
161
+
162
+ /**
163
+ * Emphasis, and a warning: a terminal with no bold face draws bold exactly like plain, so
164
+ * emphasis is a bonus and never the thing that carries the meaning. Colour always renders.
165
+ */
166
+ emphasis() {
167
+ return labelled("emphasis", [
168
+ { text: "bold ", tone: "text", bold: true },
169
+ { text: "plain ", tone: "text" },
170
+ { text: "dim ", tone: "muted", dim: true },
171
+ { text: "ital", tone: "text", italic: true },
172
+ ])
173
+ },
174
+
175
+ /** Underline, and the reliable way to make a run shout: give it a background. */
176
+ emphasis2() {
177
+ return labelled("", [
178
+ { text: "underline ", tone: "text", underline: true },
179
+ { text: " on a background ", tone: "background", bgTone: "accent" },
180
+ ])
181
+ },
182
+
183
+ /** Every tone, so a design can be picked from what the theme actually provides. */
184
+ /**
185
+ * Every tone the theme provides, over two rows — one row is cut short in a sidebar column,
186
+ * which is exactly the kind of thing you only find by looking at it.
187
+ */
188
+ tones(): Piece[] {
189
+ const show = (names: readonly Tone[]) => names.map((tone) => ({ text: `${tone} `, tone }))
190
+ return [
191
+ labelled("tones", show(["text", "muted", "accent", "success"])),
192
+ labelled("", show(["warning", "error", "info", "border"])),
193
+ ]
194
+ },
195
+
196
+ /**
197
+ * Several rows from one segment — a gauge, a table, a row per item. Returning an array is how
198
+ * any repeated element is drawn; it is not one row containing newlines.
199
+ */
200
+ rows(ctx: StatusContext): Piece[] {
201
+ const pct = Math.round(ratio(ctx) * 100)
202
+ return [
203
+ labelled("rows", [{ text: "one segment,", tone: "muted" }]),
204
+ labelled("", [{ text: `three rows — ${pct}%`, tone: "muted" }]),
205
+ labelled("", [{ text: "returned as an array", tone: "muted" }]),
206
+ ]
207
+ },
208
+ },
209
+ } satisfies CustomModule
@@ -0,0 +1,257 @@
1
+ /**
2
+ * A sidebar built as a table: a context header, the window as one solid bar, the tokens broken
3
+ * into named rows, a budget read from a proxy, and the branch's whole diff.
4
+ *
5
+ * This is the layout a user arrived at after five rejected iterations, kept here because the
6
+ * reasons are worth more than the rows: every number gets a word, the labels are a fixed column
7
+ * so the values line up, the bar is solid rather than dashed, and the groups are separated by
8
+ * hairlines rather than headings — a heading cannot know whether the rows under it will draw.
9
+ *
10
+ * {
11
+ * "statusline": {
12
+ * "modules": ["<this file>"],
13
+ * "lines": [{
14
+ * "surface": "sidebar",
15
+ * "maxRows": 13,
16
+ * "segments": ["title", "bar", "tokens", "in", "out", "cache", "write",
17
+ * "sep", "spend", "avail", "sep", "git"]
18
+ * }]
19
+ * }
20
+ * }
21
+ *
22
+ * It replaces OpenCode's own Context block, so turn that off:
23
+ * { "plugin_enabled": { "internal:sidebar-context": false } }
24
+ *
25
+ * The budget rows read a file a proxy writes. Without one they stay silent, which is right in a
26
+ * statusline and unhelpful while you are designing — `"demo": true` on either segment fills in
27
+ * sample figures so the layout can be looked at.
28
+ */
29
+
30
+ import { execFile } from "node:child_process"
31
+ import { readFileSync } from "node:fs"
32
+ import { homedir } from "node:os"
33
+ import { join } from "node:path"
34
+ import type { CustomModule, Piece, Run, SegmentConfig, StatusContext } from "@opencode-cockpit/status/segment"
35
+ import { compact, contextRatio, contextUsed, gradient } from "@opencode-cockpit/status/segment"
36
+
37
+ /** The label column, padded so every value starts in the same place. */
38
+ const LABEL = 6
39
+ function row(label: string, value: Run[]): { runs: Run[] } {
40
+ return { runs: [{ text: label.padEnd(LABEL), tone: "muted", dim: true }, ...value] }
41
+ }
42
+
43
+ /** A marker in a category colour, so the label and the colour say the same thing twice. */
44
+ function mark(tone: "success" | "info" | "text" | "warning"): Run {
45
+ return { text: "▪ ", tone }
46
+ }
47
+
48
+ const BAR = 16
49
+
50
+ /** What each token row needs: its own figure, and its share of the window. */
51
+ function share(ctx: StatusContext, value: number, tone: "success" | "info" | "text" | "warning") {
52
+ const total = contextUsed(ctx.session?.tokens)
53
+ if (total === 0) return undefined
54
+ return [
55
+ mark(tone),
56
+ { text: compact(value), tone: "muted" as const },
57
+ { text: ` · ${Math.round((value / total) * 100)}%`, tone: "muted" as const, dim: true },
58
+ ]
59
+ }
60
+
61
+ /**
62
+ * The spend a proxy reports. LiteLLM's IAP plugin writes this; anything that writes
63
+ * `{ baseline, delta, cap }` will do. Re-read at most every five seconds — the file is tiny, but
64
+ * a statusline redraws every second and this is not worth a syscall each time.
65
+ */
66
+ interface Spend {
67
+ baseline?: number
68
+ delta?: number
69
+ cap?: number
70
+ }
71
+ let cached: { at: number; spend: Spend | undefined } | undefined
72
+ function spendState(config: SegmentConfig): { total: number; cap: number } | undefined {
73
+ if (config.demo === true) return { total: 26.24, cap: 200 }
74
+ const now = Date.now()
75
+ if (!cached || now - cached.at > 5000) {
76
+ const file =
77
+ typeof config.file === "string"
78
+ ? config.file
79
+ : join(homedir(), ".cache", "opencode-litellm-iap", "spend.json")
80
+ try {
81
+ cached = { at: now, spend: JSON.parse(readFileSync(file, "utf8")) as Spend }
82
+ } catch {
83
+ cached = { at: now, spend: undefined }
84
+ }
85
+ }
86
+ const { baseline, delta, cap } = cached.spend ?? {}
87
+ if (typeof baseline !== "number" || typeof cap !== "number" || cap <= 0) return undefined
88
+ return { total: baseline + (typeof delta === "number" ? delta : 0), cap }
89
+ }
90
+
91
+ /** Green with room, amber as it tightens, red when it is nearly gone. */
92
+ function budgetColour(left: number): string {
93
+ return left >= 0.5 ? "#39d353" : left >= 0.2 ? "#e8b923" : "#f85149"
94
+ }
95
+
96
+ /**
97
+ * The branch's whole diff against where it forked: every commit on the branch plus uncommitted
98
+ * edits — what a reviewer would read, rather than what this session happened to touch. Git runs
99
+ * away from the draw path and the row shows whatever the last finished reading produced.
100
+ */
101
+ interface BranchDiff {
102
+ files: number
103
+ added: number
104
+ removed: number
105
+ }
106
+ let branch: BranchDiff | undefined
107
+ let asking = false
108
+ let askedAt = 0
109
+ function refreshBranch(ctx: StatusContext): void {
110
+ if (asking || Date.now() - askedAt < 10_000) return
111
+ asking = true
112
+ const base = ctx.defaultBranch ?? "main"
113
+ const run = execFile as unknown as (
114
+ cmd: string,
115
+ args: string[],
116
+ opts: { timeout: number; cwd: string },
117
+ cb: (err: Error | null, out: string) => void,
118
+ ) => void
119
+ const opts = { timeout: 3000, cwd: ctx.worktree }
120
+ run("git", ["merge-base", base, "HEAD"], opts, (err, fork) => {
121
+ if (err || !fork.trim()) {
122
+ asking = false
123
+ askedAt = Date.now()
124
+ return
125
+ }
126
+ run("git", ["diff", "--shortstat", fork.trim()], opts, (err2, out) => {
127
+ asking = false
128
+ askedAt = Date.now()
129
+ if (err2) return
130
+ const found = /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/.exec(out)
131
+ if (!found) return
132
+ branch = {
133
+ files: Number(found[1]),
134
+ added: Number(found[2] ?? 0),
135
+ removed: Number(found[3] ?? 0),
136
+ }
137
+ })
138
+ })
139
+ }
140
+
141
+ export default {
142
+ segments: {
143
+ /** The column's subject, so the table below it has one. */
144
+ title() {
145
+ return { runs: [{ text: "Context", tone: "text", bold: true }] }
146
+ },
147
+
148
+ /**
149
+ * One solid bar: filled cells coloured by level, empty cells a solid dark track. Not `░`,
150
+ * which reads as floating gaps, and not `─`, which reads as a row of dashes.
151
+ *
152
+ * No end caps. `▕` and `▏` are eighth-blocks whose ink sits against one edge of the cell, so an
153
+ * opening cap indents the row by most of a column and the bar stops lining up with the labels
154
+ * above and below it. The dark track already shows how far the bar could go.
155
+ *
156
+ * No figure beside it either: the `tokens` row directly below already reads the percentage out,
157
+ * and a number printed twice in a column of ten rows is the thing the eye catches on.
158
+ */
159
+ bar(ctx: StatusContext) {
160
+ const ratio = contextRatio(ctx.session)
161
+ if (ratio === undefined) return undefined
162
+ const filled = Math.round(ratio * BAR)
163
+ const runs: Run[] = Array.from({ length: BAR }, (_, cell) =>
164
+ cell < filled
165
+ ? { text: "█", color: gradient((cell + 1) / BAR) }
166
+ : { text: "█", tone: "border" as const },
167
+ )
168
+ return { runs }
169
+ },
170
+
171
+ /** The whole window, and how full it is. */
172
+ tokens(ctx: StatusContext) {
173
+ const total = contextUsed(ctx.session?.tokens)
174
+ if (total === 0) return undefined
175
+ const ratio = contextRatio(ctx.session)
176
+ return row("tokens", [
177
+ { text: compact(total), tone: "text" },
178
+ ...(ratio === undefined ? [] : [{ text: ` · ${Math.round(ratio * 100)}%`, color: gradient(ratio) }]),
179
+ ])
180
+ },
181
+
182
+ /** Fresh prompt tokens: neither cached nor generated. */
183
+ in(ctx: StatusContext) {
184
+ const value = share(ctx, ctx.session?.tokens?.input ?? 0, "info")
185
+ return value && row("in", value)
186
+ },
187
+
188
+ /** What the model wrote, reasoning included. */
189
+ out(ctx: StatusContext) {
190
+ const tokens = ctx.session?.tokens
191
+ const value = share(ctx, (tokens?.output ?? 0) + (tokens?.reasoning ?? 0), "text")
192
+ return value && row("out", value)
193
+ },
194
+
195
+ /** Served from the prompt cache — cheap, and usually most of the window. */
196
+ cache(ctx: StatusContext) {
197
+ const value = share(ctx, ctx.session?.tokens?.cache.read ?? 0, "success")
198
+ return value && row("cache", value)
199
+ },
200
+
201
+ /** Written into the cache this session — a one-time premium each. Not the same as "out". */
202
+ write(ctx: StatusContext) {
203
+ const value = share(ctx, ctx.session?.tokens?.cache.write ?? 0, "warning")
204
+ return value && row("write", value)
205
+ },
206
+
207
+ /** A hairline, to group without a heading that might strand itself. */
208
+ sep() {
209
+ return { runs: [{ text: "─".repeat(14), tone: "border", dim: true }] }
210
+ },
211
+
212
+ /** Spent so far against the cap. */
213
+ spend(_ctx: StatusContext, config: SegmentConfig) {
214
+ const state = spendState(config)
215
+ if (!state) return undefined
216
+ const colour = budgetColour(Math.max(0, state.cap - state.total) / state.cap)
217
+ return row("spend", [
218
+ { text: "▪ ", color: colour },
219
+ { text: `$${state.total.toFixed(2)}`, color: colour, bold: true },
220
+ ])
221
+ },
222
+
223
+ /** What is left, and the share of the cap that decides the colour. */
224
+ avail(_ctx: StatusContext, config: SegmentConfig) {
225
+ const state = spendState(config)
226
+ if (!state) return undefined
227
+ const left = Math.max(0, state.cap - state.total)
228
+ const ratio = left / state.cap
229
+ const colour = budgetColour(ratio)
230
+ return row("avail", [
231
+ { text: "▪ ", color: colour },
232
+ { text: `$${left.toFixed(2)}`, color: colour, bold: true },
233
+ { text: ` · ${Math.round(ratio * 100)}%`, color: colour },
234
+ ])
235
+ },
236
+
237
+ /** The branch as a reviewer will see it, not as this session left it. */
238
+ git(ctx: StatusContext, config: SegmentConfig): Piece | undefined {
239
+ if (config.demo === true) {
240
+ return row("git", [
241
+ { text: "3f", tone: "muted" },
242
+ { text: " +42", tone: "success" },
243
+ { text: " -7", tone: "error" },
244
+ { text: ` vs ${ctx.defaultBranch ?? "main"}`, tone: "muted", dim: true },
245
+ ])
246
+ }
247
+ refreshBranch(ctx)
248
+ if (!branch || branch.files === 0) return undefined
249
+ return row("git", [
250
+ { text: `${branch.files}f`, tone: "muted" },
251
+ { text: ` +${compact(branch.added)}`, tone: "success" },
252
+ { text: ` -${compact(branch.removed)}`, tone: "error" },
253
+ { text: ` vs ${ctx.defaultBranch ?? "main"}`, tone: "muted", dim: true },
254
+ ])
255
+ },
256
+ },
257
+ } satisfies CustomModule
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The sidebar as the whole instrument panel, for people who would rather not read a line under
3
+ * the prompt at all.
4
+ *
5
+ * This one is meant to *replace* OpenCode's own Context block rather than sit beside it, so it
6
+ * carries the figures that block carried — tokens, percentage, spend — and then the ones it never
7
+ * did. Turn the host's block off and give the column to this:
8
+ *
9
+ * // ~/.config/opencode/tui.json
10
+ * { "plugin": ["opencode-cockpit"], "plugin_enabled": { "internal:sidebar-context": false } }
11
+ *
12
+ * // ~/.config/opencode-cockpit/config.json
13
+ * {
14
+ * "statusline": {
15
+ * "modules": ["<this file>"],
16
+ * "surface": "sidebar",
17
+ * "segments": [
18
+ * { "type": "text", "value": "CONTEXT", "color": "border" },
19
+ * "gauge", "window", "cache",
20
+ * { "type": "text", "value": "SPEND", "color": "border" },
21
+ * "spend",
22
+ * { "type": "text", "value": "SESSION", "color": "border" },
23
+ * "elapsed", "work", "tasks"
24
+ * ],
25
+ * "maxRows": 14
26
+ * }
27
+ * }
28
+ *
29
+ * Every row is self-labelled and built to read at about thirty characters, and there are
30
+ * deliberately no section headings: a heading cannot know whether the rows under it will draw
31
+ * anything, so on an unpriced model a "SPEND" title strands itself above nothing.
32
+ *
33
+ * `tasks` is here but left out of the config above, because OpenCode's own Todo list carries the
34
+ * task names and this is only a count. Add it if you switch that list off too
35
+ * (`internal:sidebar-todo`).
36
+ */
37
+
38
+ import type { CustomModule, Run, StatusContext } from "@opencode-cockpit/status/segment"
39
+ import {
40
+ compact,
41
+ contextRatio,
42
+ contextUsed,
43
+ gradient,
44
+ money,
45
+ preciseDuration,
46
+ } from "@opencode-cockpit/status/segment"
47
+
48
+ /** Spend samples, so a rate can be shown beside the total. */
49
+ const samples: { at: number; cost: number }[] = []
50
+
51
+ /** A row of label and value, so a column of them lines up as a table would. */
52
+ function row(label: string, value: Run[]): { runs: Run[] } {
53
+ return { runs: [{ text: `${label} `, tone: "muted", dim: true }, ...value] }
54
+ }
55
+
56
+ export default {
57
+ segments: {
58
+ /** The headline figure, and the one the host's block led with. */
59
+ bar(ctx: StatusContext, config) {
60
+ const ratio = contextRatio(ctx.session)
61
+ if (ratio === undefined) return undefined
62
+ const width = typeof config.width === "number" ? config.width : 16
63
+ const filled = Math.round(ratio * width)
64
+ const runs: Run[] = []
65
+ for (let cell = 0; cell < width; cell++) {
66
+ runs.push(
67
+ cell < filled
68
+ ? { text: "█", color: gradient((cell + 1) / width) }
69
+ : { text: "░", tone: "border" as const },
70
+ )
71
+ }
72
+ runs.push({
73
+ text: ` ${Math.round(ratio * 100)}%`,
74
+ color: gradient(ratio),
75
+ bold: ratio >= 0.85,
76
+ })
77
+ return { runs }
78
+ },
79
+
80
+ /** What the percentage is a percentage of — the denominator the bar hides. */
81
+ window(ctx: StatusContext) {
82
+ const used = contextUsed(ctx.session?.tokens)
83
+ const limit = ctx.session?.model?.contextLimit
84
+ if (used === 0) return undefined
85
+ return limit
86
+ ? row("of", [
87
+ { text: compact(used), tone: "text" },
88
+ { text: ` / ${compact(limit)}`, tone: "muted" },
89
+ ])
90
+ : row("used", [{ text: `${compact(used)} tok`, tone: "text" }])
91
+ },
92
+
93
+ /**
94
+ * What was replayed from cache against what had to be sent fresh — the two figures side by
95
+ * side rather than a share of them. A share reads as "100%" for most of a cached session,
96
+ * which looks like a bug even when it is arithmetic.
97
+ */
98
+ cached(ctx: StatusContext) {
99
+ const tokens = ctx.session?.tokens
100
+ if (!tokens || contextUsed(tokens) === 0) return undefined
101
+ const fresh = tokens.input + tokens.output + tokens.reasoning
102
+ return row("cache", [
103
+ { text: compact(tokens.cache.read), tone: "success" },
104
+ { text: " · fresh ", tone: "muted", dim: true },
105
+ { text: compact(fresh), tone: "info" },
106
+ ])
107
+ },
108
+
109
+ /** What the session has cost, and what it is costing. Silent where nobody declared prices. */
110
+ spend(ctx: StatusContext) {
111
+ const session = ctx.session
112
+ if (!session?.priced) return undefined
113
+ const last = samples[samples.length - 1]
114
+ if (!last || ctx.now - last.at >= 1000) samples.push({ at: ctx.now, cost: session.cost })
115
+ if (samples.length > 60) samples.shift()
116
+
117
+ const first = samples[0]
118
+ const latest = samples[samples.length - 1]
119
+ const runs: Run[] = [{ text: money(session.cost), tone: "warning" }]
120
+ if (first && latest && latest.at > first.at) {
121
+ const perMinute = ((latest.cost - first.cost) / (latest.at - first.at)) * 60_000
122
+ if (perMinute >= 0.005) {
123
+ runs.push({ text: ` · $${perMinute.toFixed(2)}/min`, tone: "muted", dim: true })
124
+ }
125
+ }
126
+ return { runs }
127
+ },
128
+
129
+ /** How long this has been going, in a unit a person reads without converting. */
130
+ elapsed(ctx: StatusContext) {
131
+ const started = ctx.session?.startedAt
132
+ if (started === undefined) return undefined
133
+ return row("for", [{ text: preciseDuration(ctx.now - started), tone: "muted" }])
134
+ },
135
+
136
+ /** What the session has done to the tree. */
137
+ changes(ctx: StatusContext) {
138
+ const diff = ctx.session?.diff
139
+ if (!diff || diff.files === 0) return undefined
140
+ return row("diff", [
141
+ { text: `+${compact(diff.additions)}`, tone: "success" },
142
+ { text: ` -${compact(diff.deletions)}`, tone: "error" },
143
+ { text: ` · ${diff.files}f`, tone: "muted", dim: true },
144
+ ])
145
+ },
146
+
147
+ /**
148
+ * Work outstanding, for a sidebar where OpenCode's own Todo list is switched off. With that
149
+ * list on, this is a worse copy of it — the list carries the task names.
150
+ */
151
+ todo(ctx: StatusContext) {
152
+ const todo = ctx.session?.todo
153
+ if (!todo || todo.total === 0 || todo.completed === todo.total) return undefined
154
+ return row("todo", [
155
+ { text: `${todo.completed}/${todo.total}`, tone: "text" },
156
+ { text: ` · ${todo.total - todo.completed} left`, tone: "muted", dim: true },
157
+ ])
158
+ },
159
+ },
160
+ } satisfies CustomModule