@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.
@@ -78,7 +78,8 @@ export function buildSegments(ctx, configs, options = {}) {
78
78
  // A bare map is accepted so the common case reads as `buildSegments(ctx, configs, custom)`.
79
79
  const {
80
80
  custom,
81
- icons = true
81
+ icons = true,
82
+ debug = false
82
83
  } = options instanceof Map ? {
83
84
  custom: options
84
85
  } : options;
@@ -87,58 +88,92 @@ export function buildSegments(ctx, configs, options = {}) {
87
88
  for (const config of configs) {
88
89
  // Your own segments are looked up first, so a module can replace a built-in by name.
89
90
  const def = custom?.get(config.type) ?? findSegment(config.type);
90
- if (!def) continue;
91
+ if (!def) {
92
+ // A name nothing answers to: a typo, or a segment from a module that failed to load.
93
+ if (debug) out.push(marker(`?${config.type}`, "error", config.type, seen));
94
+ continue;
95
+ }
91
96
  let piece;
92
97
  try {
93
98
  piece = def.render(ctx, config);
94
99
  } catch {
95
- continue; // a segment that throws costs its own place on the line and nothing else
100
+ // A segment that throws costs its own place on the line and nothing else.
101
+ if (debug) out.push(marker(`!${config.type}`, "error", config.type, seen));
102
+ continue;
103
+ }
104
+ if (!piece) {
105
+ // It ran and chose silence: the input it needs is missing, not its name.
106
+ if (debug) out.push(marker(config.type, "border", config.type, seen));
107
+ continue;
96
108
  }
97
- if (!piece) continue;
98
109
  const wanted = typeof config.color === "string" ? config.color : undefined;
99
110
  const forcedTone = wanted ? toTone(wanted) : undefined;
100
111
  const forcedColor = wanted && isLiteralColor(wanted) ? wanted : undefined;
101
- const runs = runsOf(piece).filter(run => run.text.length > 0);
102
- if (runs.length === 0) continue;
103
- const icon = typeof config.icon === "string" ? config.icon : icons ? def.icon : undefined;
104
- const prefix = typeof config.prefix === "string" ? config.prefix : "";
105
- const suffix = typeof config.suffix === "string" ? config.suffix : "";
106
- // The icon gets its own run so it can be dimmed apart from the value it labels.
107
- if (icon) runs.unshift({
108
- text: `${icon} `,
109
- tone: runs[0]?.tone,
110
- dim: true
111
- });
112
- if (prefix) runs.unshift({
113
- text: prefix,
114
- tone: runs[0]?.tone
115
- });
116
- if (suffix) runs.push({
117
- text: suffix,
118
- tone: runs[runs.length - 1]?.tone
119
- });
120
112
 
121
- // A colour named on the segment overrides every run in it, icon included; that is what makes
122
- // `{"type": "cost", "color": "#ff8800"}` do what it looks like it should.
123
- const styled = forcedTone || forcedColor ? runs.map(run => ({
124
- ...run,
125
- ...(forcedTone ? {
126
- tone: forcedTone
127
- } : {}),
128
- ...(forcedColor ? {
129
- color: forcedColor
130
- } : {})
131
- })) : runs;
132
- const count = (seen.get(config.type) ?? 0) + 1;
133
- seen.set(config.type, count);
134
- out.push({
135
- id: count === 1 ? config.type : `${config.type}#${count}`,
136
- runs: styled,
137
- priority: typeof config.priority === "number" ? config.priority : def.priority
138
- });
113
+ // Several rows are placed one after another: down a column each is a row of its own.
114
+ const pieces = Array.isArray(piece) ? piece : [piece];
115
+ let drew = false;
116
+ for (const one of pieces) {
117
+ const runs = runsOf(one).filter(run => run.text.length > 0);
118
+ if (runs.length === 0) continue;
119
+ const icon = typeof config.icon === "string" ? config.icon : icons ? def.icon : undefined;
120
+ const prefix = typeof config.prefix === "string" ? config.prefix : "";
121
+ const suffix = typeof config.suffix === "string" ? config.suffix : "";
122
+ // The icon gets its own run so it can be dimmed apart from the value it labels.
123
+ if (icon) runs.unshift({
124
+ text: `${icon} `,
125
+ tone: runs[0]?.tone,
126
+ dim: true
127
+ });
128
+ if (prefix) runs.unshift({
129
+ text: prefix,
130
+ tone: runs[0]?.tone
131
+ });
132
+ if (suffix) runs.push({
133
+ text: suffix,
134
+ tone: runs[runs.length - 1]?.tone
135
+ });
136
+
137
+ // A colour named on the segment overrides every run in it, icon included; that is what makes
138
+ // `{"type": "cost", "color": "#ff8800"}` do what it looks like it should.
139
+ const styled = forcedTone || forcedColor ? runs.map(run => ({
140
+ ...run,
141
+ ...(forcedTone ? {
142
+ tone: forcedTone
143
+ } : {}),
144
+ ...(forcedColor ? {
145
+ color: forcedColor
146
+ } : {})
147
+ })) : runs;
148
+ const count = (seen.get(config.type) ?? 0) + 1;
149
+ seen.set(config.type, count);
150
+ out.push({
151
+ id: count === 1 ? config.type : `${config.type}#${count}`,
152
+ runs: styled,
153
+ priority: typeof config.priority === "number" ? config.priority : def.priority
154
+ });
155
+ drew = true;
156
+ }
157
+ if (!drew && debug) out.push(marker(config.type, "border", config.type, seen));
139
158
  }
140
159
  return out;
141
160
  }
161
+
162
+ /** What a silent segment looks like while `debug` is on. */
163
+ function marker(text, tone, type, seen) {
164
+ const count = (seen.get(type) ?? 0) + 1;
165
+ seen.set(type, count);
166
+ return {
167
+ id: count === 1 ? type : `${type}#${count}`,
168
+ runs: [{
169
+ text: `⟨${text}⟩`,
170
+ tone,
171
+ dim: true
172
+ }],
173
+ // Above everything, so the thing you are debugging is not the first dropped when it is narrow.
174
+ priority: 100
175
+ };
176
+ }
142
177
  const TONES = new Set(["text", "muted", "accent", "success", "warning", "error", "info"]);
143
178
  function toTone(value) {
144
179
  return TONES.has(value) ? value : undefined;
@@ -1,9 +1,9 @@
1
1
  import { insertNode as _$insertNode } from "@opentui/solid";
2
2
  import { memo as _$memo } from "@opentui/solid";
3
3
  import { effect as _$effect } from "@opentui/solid";
4
- import { insert as _$insert } from "@opentui/solid";
5
4
  import { createComponent as _$createComponent } from "@opentui/solid";
6
5
  import { setProp as _$setProp } from "@opentui/solid";
6
+ import { insert as _$insert } from "@opentui/solid";
7
7
  import { createElement as _$createElement } from "@opentui/solid";
8
8
  /** @jsxImportSource @opentui/solid */
9
9
 
@@ -56,13 +56,40 @@ function runStyle(theme, run) {
56
56
  fg
57
57
  };
58
58
  }
59
+
60
+ /**
61
+ * Emphasis is markup, not a style property.
62
+ *
63
+ * OpenTUI draws bold, italic and underline through `<b>`, `<i>` and `<u>` around the text — there
64
+ * is no attribute to set. Passing one as a prop on the span type-checks and renders nothing at
65
+ * all, which is how every attribute disappeared at once while the colours kept working.
66
+ */
67
+ function decorate(run) {
68
+ let node = run.text;
69
+ if (run.underline) node = (() => {
70
+ var _el$ = _$createElement("u");
71
+ _$insert(_el$, node);
72
+ return _el$;
73
+ })();
74
+ if (run.italic) node = (() => {
75
+ var _el$2 = _$createElement("i");
76
+ _$insert(_el$2, node);
77
+ return _el$2;
78
+ })();
79
+ if (run.bold) node = (() => {
80
+ var _el$3 = _$createElement("b");
81
+ _$insert(_el$3, node);
82
+ return _el$3;
83
+ })();
84
+ return node;
85
+ }
59
86
  export function StatusLine(props) {
60
87
  const theme = () => props.api.theme.current;
61
88
  const down = () => props.stack === "vertical";
62
89
  return (() => {
63
- var _el$ = _$createElement("box");
64
- _$setProp(_el$, "flexShrink", 0);
65
- _$insert(_el$, _$createComponent(For, {
90
+ var _el$4 = _$createElement("box");
91
+ _$setProp(_el$4, "flexShrink", 0);
92
+ _$insert(_el$4, _$createComponent(For, {
66
93
  get each() {
67
94
  return props.segments();
68
95
  },
@@ -71,18 +98,18 @@ export function StatusLine(props) {
71
98
  return _$memo(() => !!(index() > 0 && !down()))() && props.separator.length > 0;
72
99
  },
73
100
  get children() {
74
- var _el$2 = _$createElement("text");
75
- _$setProp(_el$2, "wrapMode", "none");
76
- _$setProp(_el$2, "flexShrink", 0);
77
- _$insert(_el$2, () => props.separator);
78
- _$effect(_$p => _$setProp(_el$2, "fg", theme().borderSubtle, _$p));
79
- return _el$2;
101
+ var _el$5 = _$createElement("text");
102
+ _$setProp(_el$5, "wrapMode", "none");
103
+ _$setProp(_el$5, "flexShrink", 0);
104
+ _$insert(_el$5, () => props.separator);
105
+ _$effect(_$p => _$setProp(_el$5, "fg", theme().borderSubtle, _$p));
106
+ return _el$5;
80
107
  }
81
108
  }), (() => {
82
- var _el$3 = _$createElement("text");
83
- _$setProp(_el$3, "wrapMode", "none");
84
- _$setProp(_el$3, "flexShrink", 0);
85
- _$insert(_el$3, _$createComponent(For, {
109
+ var _el$6 = _$createElement("text");
110
+ _$setProp(_el$6, "wrapMode", "none");
111
+ _$setProp(_el$6, "flexShrink", 0);
112
+ _$insert(_el$6, _$createComponent(For, {
86
113
  get each() {
87
114
  return segment.runs;
88
115
  },
@@ -92,23 +119,23 @@ export function StatusLine(props) {
92
119
  },
93
120
  get fallback() {
94
121
  return (() => {
95
- var _el$6 = _$createElement("span");
96
- _$insert(_el$6, () => run.text);
97
- _$effect(_$p => _$setProp(_el$6, "style", runStyle(theme(), run), _$p));
98
- return _el$6;
122
+ var _el$9 = _$createElement("span");
123
+ _$insert(_el$9, () => decorate(run));
124
+ _$effect(_$p => _$setProp(_el$9, "style", runStyle(theme(), run), _$p));
125
+ return _el$9;
99
126
  })();
100
127
  },
101
128
  get children() {
102
- var _el$4 = _$createElement("span"),
103
- _el$5 = _$createElement("b");
104
- _$insertNode(_el$4, _el$5);
105
- _$insert(_el$5, () => run.text);
106
- _$effect(_$p => _$setProp(_el$4, "style", runStyle(theme(), run), _$p));
107
- return _el$4;
129
+ var _el$7 = _$createElement("span"),
130
+ _el$8 = _$createElement("b");
131
+ _$insertNode(_el$7, _el$8);
132
+ _$insert(_el$8, () => run.text);
133
+ _$effect(_$p => _$setProp(_el$7, "style", runStyle(theme(), run), _$p));
134
+ return _el$7;
108
135
  }
109
136
  })
110
137
  }));
111
- return _el$3;
138
+ return _el$6;
112
139
  })()]
113
140
  }));
114
141
  _$effect(_p$ => {
@@ -117,11 +144,11 @@ export function StatusLine(props) {
117
144
  _v$3 = props.paddingRight ?? 1,
118
145
  _v$4 = props.paddingTop ?? 0,
119
146
  _v$5 = props.paddingBottom ?? 0;
120
- _v$ !== _p$.e && (_p$.e = _$setProp(_el$, "flexDirection", _v$, _p$.e));
121
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "paddingLeft", _v$2, _p$.t));
122
- _v$3 !== _p$.a && (_p$.a = _$setProp(_el$, "paddingRight", _v$3, _p$.a));
123
- _v$4 !== _p$.o && (_p$.o = _$setProp(_el$, "paddingTop", _v$4, _p$.o));
124
- _v$5 !== _p$.i && (_p$.i = _$setProp(_el$, "paddingBottom", _v$5, _p$.i));
147
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$4, "flexDirection", _v$, _p$.e));
148
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "paddingLeft", _v$2, _p$.t));
149
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$4, "paddingRight", _v$3, _p$.a));
150
+ _v$4 !== _p$.o && (_p$.o = _$setProp(_el$4, "paddingTop", _v$4, _p$.o));
151
+ _v$5 !== _p$.i && (_p$.i = _$setProp(_el$4, "paddingBottom", _v$5, _p$.i));
125
152
  return _p$;
126
153
  }, {
127
154
  e: undefined,
@@ -130,6 +157,6 @@ export function StatusLine(props) {
130
157
  o: undefined,
131
158
  i: undefined
132
159
  });
133
- return _el$;
160
+ return _el$4;
134
161
  })();
135
162
  }
package/dist/tui/index.js CHANGED
@@ -40,7 +40,13 @@ export function createStatusTui({
40
40
  if (config.modules?.length) {
41
41
  const loaded = await loadCustomSegments(config.modules, directory);
42
42
  custom = loaded.segments;
43
- // A module that will not load is worth saying out loud: its segments silently vanish.
43
+ /**
44
+ * A module that will not load is worth saying out loud twice over: its segments simply are
45
+ * not there, which looks exactly like a plugin that did nothing. The toast is gone in ten
46
+ * seconds, so the reason also goes to OpenCode's log, where it can still be read afterwards
47
+ * — a whole session was once spent diagnosing an import that had already explained itself
48
+ * and then disappeared.
49
+ */
44
50
  for (const error of loaded.errors) {
45
51
  api.ui.toast({
46
52
  variant: "error",
@@ -48,6 +54,11 @@ export function createStatusTui({
48
54
  message: error,
49
55
  duration: 10_000
50
56
  });
57
+ void api.client.app.log({
58
+ service: "opencode-cockpit.status",
59
+ level: "error",
60
+ message: `statusline module failed to load: ${error}`
61
+ }).catch(() => {});
51
62
  }
52
63
  }
53
64
  const lines = resolveLines(config);
@@ -62,7 +73,8 @@ export function createStatusTui({
62
73
  const segments = createMemo(() => {
63
74
  const built = buildSegments(store.context(), spec.segments.map(asSegmentConfig), {
64
75
  custom,
65
- icons: spec.icons
76
+ icons: spec.icons,
77
+ debug: spec.debug
66
78
  });
67
79
  return spec.stack === "vertical" ? fitColumn(built, width(), spec.maxRows).segments : fit(built, width(), spec.separator).segments;
68
80
  });
@@ -0,0 +1,60 @@
1
+ # Examples
2
+
3
+ Start with a **preset** — a whole line by name, built-ins only, nothing to install:
4
+
5
+ ```jsonc
6
+ // ~/.config/opencode-cockpit/config.json
7
+ { "statusline": { "preset": "default" } }
8
+ ```
9
+
10
+ | Preset | Surface | What you get |
11
+ | --- | --- | --- |
12
+ | `minimal` | bottom | how full the context is, and what changed |
13
+ | `default` | bottom | the bar, where the tokens went, what changed, how long |
14
+ | `detailed` | bottom | everything the built-ins know, for a wide window |
15
+ | `sidebar` | sidebar | a quiet column beside OpenCode's own blocks |
16
+
17
+ Anything you write beside a preset wins, so it is a starting point rather than a mode:
18
+
19
+ ```jsonc
20
+ { "statusline": { "preset": "default", "separator": " " } }
21
+ ```
22
+
23
+ ## When a preset is not enough
24
+
25
+ These are modules — TypeScript you copy and cut. Point `modules` at one and use its segments by
26
+ name. Every one is loaded and asserted by the test suite, so none of them can rot.
27
+
28
+ | File | For | Segments |
29
+ | --- | --- | --- |
30
+ | [`bottom.ts`](./bottom.ts) | the whole statusline on one line, no sidebar open | `bar` `filling` `rate` `cached` `tokens` |
31
+ | [`sidebar.ts`](./sidebar.ts) | a small column **beside** OpenCode's Context block | `bar` `split` `changes` |
32
+ | [`sidebar-full.ts`](./sidebar-full.ts) | a column that **replaces** that block — turn it off with `plugin_enabled` | `bar` `window` `cached` `spend` `elapsed` `changes` `todo` |
33
+ | [`sidebar-budget.ts`](./sidebar-budget.ts) | a column as a **table**: fixed label gutter, one bar, a proxy's budget, the branch diff | `title` `bar` `tokens` `in` `out` `cache` `write` `sep` `spend` `avail` `git` |
34
+ | [`gallery.ts`](./gallery.ts) | not a statusline — every technique the renderer can draw, labelled | all of them |
35
+
36
+ ```jsonc
37
+ {
38
+ "statusline": {
39
+ "modules": ["~/.config/opencode-cockpit/modules/bottom.ts"],
40
+ "surface": "bottom",
41
+ "segments": ["bar", "filling", "rate", "cached", "session.diff", "session.time"]
42
+ }
43
+ }
44
+ ```
45
+
46
+ ## Look at it before you ship it
47
+
48
+ ```sh
49
+ bunx @opencode-cockpit/status preview --watch # redraws on every save
50
+ bunx @opencode-cockpit/status preview --state fresh # before the first reply
51
+ bunx @opencode-cockpit/status preview --debug # mark segments that drew nothing
52
+ bunx @opencode-cockpit/status preview --module examples/gallery.ts
53
+ ```
54
+
55
+ The six sample sessions are the states a design gets wrong: `fresh` (no model, no tokens — most
56
+ designs render a wall of zeroes), `working`, `full`, `unpriced` (behind a proxy, nothing declared),
57
+ `retrying`, and `empty`.
58
+
59
+ The taste rules this bay learned the expensive way live in
60
+ [`../skills/statusline-design/`](../skills/statusline-design/SKILL.md).
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Everything on one line, for a window with no sidebar open.
3
+ *
4
+ * Every segment here reads the session snapshot, which is the line this bay draws: anything a CLI
5
+ * can already print belongs in a `command`, shaped in the shell, not in a segment that duplicates
6
+ * it. The working tree, for instance, needs no code from us at all:
7
+ *
8
+ * "commands": { "tree": { "run": "git diff --shortstat | awk '{print \"+\"$4\" -\"$6}'" } }
9
+ *
10
+ * This is the whole statusline for someone who lives in the bottom line: how full the context is,
11
+ * which way it is going, what the session has changed, and whether anything needs them. It is the
12
+ * densest of the examples on purpose -- the bottom line is the only surface with real width.
13
+ *
14
+ * {
15
+ * "statusline": {
16
+ * "modules": ["<this file>"],
17
+ * "lines": [
18
+ * { "surface": "bottom", "separator": " │ ",
19
+ * "segments": ["bar", "filling", "rate", "cached", "session.diff", "todo",
20
+ * "session.time", "diagnostics"] }
21
+ * ]
22
+ * }
23
+ * }
24
+ *
25
+ * Two lines on the same surface stack, if you would rather split it in two rows.
26
+ */
27
+
28
+ import type { CustomModule, Run, StatusContext } from "@opencode-cockpit/status/segment"
29
+ import { compact, contextRatio, contextUsed, gradient } from "@opencode-cockpit/status/segment"
30
+
31
+ /** Samples kept between ticks: the shape of a session is not visible in any single reading. */
32
+ const samples: { at: number; ratio: number; cost: number }[] = []
33
+
34
+ function sample(ctx: StatusContext): void {
35
+ const last = samples[samples.length - 1]
36
+ if (last && ctx.now - last.at < 1000) return
37
+ samples.push({
38
+ at: ctx.now,
39
+ ratio: contextRatio(ctx.session) ?? 0,
40
+ cost: ctx.session?.cost ?? 0,
41
+ })
42
+ if (samples.length > 30) samples.shift()
43
+ }
44
+
45
+ export default {
46
+ segments: {
47
+ /**
48
+ * The context window as a bar with a scale: every cell carries the colour of the level it
49
+ * stands for, and a quarter tick marks the empty half so the bar can be read against
50
+ * something rather than eyeballed.
51
+ */
52
+ bar(ctx: StatusContext, config) {
53
+ const ratio = contextRatio(ctx.session)
54
+ if (ratio === undefined) return undefined
55
+ const width = typeof config.width === "number" ? config.width : 20
56
+ const filled = Math.round(ratio * width)
57
+ const runs: Run[] = [{ text: "▕", tone: "border" }]
58
+ for (let cell = 0; cell < width; cell++) {
59
+ if (cell < filled) {
60
+ runs.push({ text: "█", color: gradient((cell + 1) / width) })
61
+ } else {
62
+ const tick = cell > 0 && Math.abs(((cell + 1) / width) % 0.25) < 1 / width
63
+ runs.push({ text: tick ? "┊" : "░", tone: "border" })
64
+ }
65
+ }
66
+ runs.push({ text: "▏", tone: "border" })
67
+ runs.push({
68
+ text: ` ${Math.round(ratio * 100)}%`,
69
+ color: gradient(ratio),
70
+ bold: ratio >= 0.85,
71
+ })
72
+ return { runs }
73
+ },
74
+
75
+ /**
76
+ * How fast the window is filling, as a figure rather than a picture.
77
+ *
78
+ * This was a sparkline. A sparkline redraws its whole shape every second, and a shape moving
79
+ * in the corner of your eye pulls attention away from what you are reading -- which is the one
80
+ * thing a statusline must not do. The same information as a rate changes its digits and
81
+ * nothing else.
82
+ */
83
+ filling(ctx: StatusContext) {
84
+ sample(ctx)
85
+ const seen = samples.filter((entry) => entry.ratio > 0)
86
+ const first = seen[0]
87
+ const last = seen[seen.length - 1]
88
+ if (!first || !last || last.at === first.at) return undefined
89
+ const perMinute = ((last.ratio - first.ratio) / (last.at - first.at)) * 60_000 * 100
90
+ if (Math.abs(perMinute) < 0.05) return undefined
91
+ // Minutes left at this rate is the figure worth knowing; the rate itself is the input.
92
+ const headroom = (1 - last.ratio) * 100
93
+ const minutesLeft = perMinute > 0 ? headroom / perMinute : Number.POSITIVE_INFINITY
94
+ return {
95
+ runs: [
96
+ { text: `+${perMinute.toFixed(1)}%/min`, tone: "muted" as const },
97
+ ...(Number.isFinite(minutesLeft) && minutesLeft < 90
98
+ ? [
99
+ {
100
+ text: ` · ${Math.round(minutesLeft)}m left`,
101
+ tone: minutesLeft < 15 ? ("warning" as const) : ("muted" as const),
102
+ },
103
+ ]
104
+ : []),
105
+ ],
106
+ }
107
+ },
108
+
109
+ /** Spend per minute with a direction. Silent where nobody declared prices. */
110
+ rate(ctx: StatusContext) {
111
+ sample(ctx)
112
+ const session = ctx.session
113
+ if (!session?.priced || session.cost <= 0) return undefined
114
+ const first = samples[0]
115
+ const last = samples[samples.length - 1]
116
+ if (!first || !last || last.at === first.at) return undefined
117
+ const perMinute = ((last.cost - first.cost) / (last.at - first.at)) * 60_000
118
+ if (perMinute < 0.005) return undefined
119
+ return {
120
+ runs: [
121
+ { text: perMinute > 0.5 ? "▲" : "▸", tone: perMinute > 0.5 ? "warning" : "muted" },
122
+ { text: ` $${perMinute.toFixed(2)}/min`, tone: "muted" },
123
+ ],
124
+ }
125
+ },
126
+
127
+ /**
128
+ * How much of the window is cache rather than fresh input. High is cheap and fast; low means
129
+ * the session keeps re-sending what it already sent.
130
+ */
131
+ cached(ctx: StatusContext) {
132
+ const tokens = ctx.session?.tokens
133
+ const total = contextUsed(tokens)
134
+ if (!tokens || total === 0) return undefined
135
+ const share = tokens.cache.read / total
136
+ return {
137
+ runs: [
138
+ { text: "▌", tone: share > 0.5 ? "success" : "muted" },
139
+ { text: `${Math.round(share * 100)}% cached`, tone: "muted" },
140
+ ],
141
+ }
142
+ },
143
+
144
+ /** The session's own size, for when a window has quietly filled up with one long turn. */
145
+ tokens(ctx: StatusContext) {
146
+ const used = contextUsed(ctx.session?.tokens)
147
+ return used > 0 ? { text: `${compact(used)} tok`, tone: "muted" as const } : undefined
148
+ },
149
+ },
150
+ } satisfies CustomModule