@opencode-cockpit/status 0.3.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +254 -0
  3. package/dist/core/ansi.js +145 -0
  4. package/dist/core/authoring.js +13 -0
  5. package/dist/core/builtins/index.js +10 -0
  6. package/dist/core/builtins/model.js +204 -0
  7. package/dist/core/builtins/place.js +66 -0
  8. package/dist/core/builtins/session.js +72 -0
  9. package/dist/core/builtins/settings.js +28 -0
  10. package/dist/core/builtins/system.js +58 -0
  11. package/dist/core/claude-code.js +79 -0
  12. package/dist/core/command.js +77 -0
  13. package/dist/core/config.js +187 -0
  14. package/dist/core/context.js +24 -0
  15. package/dist/core/custom.js +125 -0
  16. package/dist/core/format.js +156 -0
  17. package/dist/core/render.js +88 -0
  18. package/dist/core/segments.js +148 -0
  19. package/dist/core/types.js +1 -0
  20. package/dist/tui/components/statusline.js +135 -0
  21. package/dist/tui/index.js +110 -0
  22. package/dist/tui/state/snapshot.js +144 -0
  23. package/dist/tui/state/store.js +51 -0
  24. package/package.json +63 -0
  25. package/types/core/ansi.d.ts +8 -0
  26. package/types/core/authoring.d.ts +17 -0
  27. package/types/core/builtins/index.d.ts +6 -0
  28. package/types/core/builtins/model.d.ts +3 -0
  29. package/types/core/builtins/place.d.ts +3 -0
  30. package/types/core/builtins/session.d.ts +3 -0
  31. package/types/core/builtins/settings.d.ts +13 -0
  32. package/types/core/builtins/system.d.ts +3 -0
  33. package/types/core/claude-code.d.ts +61 -0
  34. package/types/core/command.d.ts +35 -0
  35. package/types/core/config.d.ts +141 -0
  36. package/types/core/context.d.ts +78 -0
  37. package/types/core/custom.d.ts +49 -0
  38. package/types/core/format.d.ts +56 -0
  39. package/types/core/render.d.ts +22 -0
  40. package/types/core/segments.d.ts +26 -0
  41. package/types/core/types.d.ts +52 -0
  42. package/types/tui/components/statusline.d.ts +26 -0
  43. package/types/tui/index.d.ts +10 -0
  44. package/types/tui/state/snapshot.d.ts +11 -0
  45. package/types/tui/state/store.d.ts +28 -0
@@ -0,0 +1,66 @@
1
+ /** Where you are: the folder, the branch, and what has changed in it. */
2
+
3
+ import { compact, shortPath, truncateStart } from "../format.js";
4
+ import { formatted, num } from "./settings.js";
5
+ export const SEGMENTS = [{
6
+ name: "cwd",
7
+ icon: "▸",
8
+ priority: 80,
9
+ render(ctx, config) {
10
+ // A directory outside both the worktree and home has no short form, and an absolute path can
11
+ // be longer than the terminal. Keep the tail: the end of a path is the part that identifies it.
12
+ const text = truncateStart(shortPath(ctx.directory, ctx.worktree, ctx.home), num(config, "maxWidth", 28));
13
+ return text ? {
14
+ text,
15
+ tone: "accent"
16
+ } : undefined;
17
+ }
18
+ }, {
19
+ name: "git.branch",
20
+ icon: "⑂",
21
+ priority: 70,
22
+ render(ctx) {
23
+ if (!ctx.branch) return undefined;
24
+ // The default branch is the boring answer; a feature branch is the one worth noticing.
25
+ const onDefault = ctx.defaultBranch !== undefined && ctx.branch === ctx.defaultBranch;
26
+ return {
27
+ text: ctx.branch,
28
+ tone: onDefault ? "muted" : "info"
29
+ };
30
+ }
31
+ }, {
32
+ /**
33
+ * What *this session* changed, which is what OpenCode's own Files list shows -- not what `git
34
+ * status` would. A file you edited by hand was never part of the session and does not appear
35
+ * here, which is why the old name, `git.diff`, was a trap. That name still works.
36
+ *
37
+ * For the working tree, use a command segment: `git diff --shortstat`.
38
+ */
39
+ name: "session.diff",
40
+ icon: "±",
41
+ priority: 50,
42
+ render(ctx, config) {
43
+ const diff = ctx.session?.diff;
44
+ if (!diff || diff.additions === 0 && diff.deletions === 0) return undefined;
45
+ const shaped = formatted(config, {
46
+ files: diff.files,
47
+ added: diff.additions,
48
+ removed: diff.deletions
49
+ });
50
+ if (shaped) return shaped;
51
+ // Two colours: what was added and what was taken away are read separately.
52
+ return {
53
+ runs: [{
54
+ text: `+${compact(diff.additions)}`,
55
+ tone: "success"
56
+ }, {
57
+ text: " / ",
58
+ tone: "muted",
59
+ dim: true
60
+ }, {
61
+ text: `-${compact(diff.deletions)}`,
62
+ tone: "error"
63
+ }]
64
+ };
65
+ }
66
+ }];
@@ -0,0 +1,72 @@
1
+ /** How the session is going: work outstanding, work in progress, time spent. */
2
+
3
+ import { todoRemaining } from "../context.js";
4
+ import { duration, preciseDuration } from "../format.js";
5
+ import { formatted } from "./settings.js";
6
+ export const SEGMENTS = [{
7
+ name: "todo",
8
+ icon: "▤",
9
+ priority: 55,
10
+ render(ctx, config) {
11
+ const todo = ctx.session?.todo;
12
+ if (!todo || todo.total === 0) return undefined;
13
+ const left = todoRemaining(ctx.session);
14
+ /**
15
+ * A finished list has nothing left to act on, and todos live for the whole session -- so
16
+ * "5/5 todo" would sit there for the rest of it, saying only that you already finished.
17
+ * `showComplete` keeps it for anyone who wants the confirmation.
18
+ */
19
+ if (left === 0 && config.showComplete !== true) return undefined;
20
+ const shaped = formatted(config, {
21
+ done: todo.completed,
22
+ total: todo.total,
23
+ left
24
+ });
25
+ if (shaped) return shaped;
26
+ return {
27
+ text: `${todo.completed}/${todo.total} todo`,
28
+ tone: left === 0 ? "success" : "muted"
29
+ };
30
+ }
31
+ }, {
32
+ name: "session.status",
33
+ icon: "●",
34
+ priority: 95,
35
+ render(ctx) {
36
+ const session = ctx.session;
37
+ if (!session) return undefined;
38
+ if (session.status === "retry") {
39
+ // Retries are invisible in OpenCode today; a stuck session looks identical to a slow one.
40
+ const retry = session.retry;
41
+ const wait = retry ? duration(Math.max(0, retry.next - ctx.now)) : "";
42
+ return {
43
+ text: `retry ${retry?.attempt ?? 1}${wait ? ` in ${wait}` : ""}`,
44
+ tone: "warning"
45
+ };
46
+ }
47
+ if (session.status === "busy") {
48
+ const started = session.startedAt;
49
+ return {
50
+ text: started ? `working ${preciseDuration(ctx.now - started)}` : "working",
51
+ tone: "info"
52
+ };
53
+ }
54
+ return undefined; // idle is the normal state; saying so every frame is noise
55
+ }
56
+ }, {
57
+ name: "session.time",
58
+ icon: "◷",
59
+ priority: 20,
60
+ render(ctx, config) {
61
+ const started = ctx.session?.startedAt;
62
+ // `=== undefined`, not falsy: a startedAt of 0 is a real instant, not a missing one.
63
+ if (started === undefined) return undefined;
64
+ const elapsed = ctx.now - started;
65
+ // "3m42s" while you are watching it; "2h 5m" once the seconds stop mattering.
66
+ const text = config.coarse === true ? duration(elapsed) : preciseDuration(elapsed);
67
+ return {
68
+ text,
69
+ tone: "muted"
70
+ };
71
+ }
72
+ }];
@@ -0,0 +1,28 @@
1
+ /** Reading a segment's own settings out of its config entry, safely. */
2
+
3
+ import { template } from "../format.js";
4
+ export function num(config, key, fallback) {
5
+ const value = config[key];
6
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
7
+ }
8
+ export function str(config, key) {
9
+ const value = config[key];
10
+ return typeof value === "string" ? value : undefined;
11
+ }
12
+
13
+ /**
14
+ * A segment's own shape, when the config asked for one.
15
+ *
16
+ * Returns `undefined` when no `format` was written, so the segment draws its default — which is
17
+ * usually several runs in several colours. A format is one run in one tone: full control of the
18
+ * words, at the cost of the colouring, which is the honest trade and worth saying out loud.
19
+ */
20
+ export function formatted(config, values, tone = "muted") {
21
+ const shape = str(config, "format");
22
+ if (shape === undefined) return undefined;
23
+ const text = template(shape, values);
24
+ return text.length > 0 ? {
25
+ text,
26
+ tone
27
+ } : undefined;
28
+ }
@@ -0,0 +1,58 @@
1
+ /** Everything outside the conversation: service health, versions, and your own commands. */
2
+
3
+ import { parseAnsi } from "../ansi.js";
4
+ import { outputRows } from "../command.js";
5
+ import { unhealthy } from "../context.js";
6
+ import { num, str } from "./settings.js";
7
+ export const SEGMENTS = [{
8
+ name: "diagnostics",
9
+ priority: 90,
10
+ render(ctx) {
11
+ // Silent while everything is healthy: a statusline that always shows "LSP ✓" has spent a
12
+ // column to tell you nothing.
13
+ const broken = [...unhealthy(ctx.lsp), ...unhealthy(ctx.mcp)];
14
+ if (broken.length === 0) return undefined;
15
+ const names = broken.slice(0, 2).map(item => item.name).join(", ");
16
+ const more = broken.length > 2 ? ` +${broken.length - 2}` : "";
17
+ return {
18
+ text: `⚠ ${names}${more}`,
19
+ tone: "error"
20
+ };
21
+ }
22
+ }, {
23
+ name: "version",
24
+ icon: "⌁",
25
+ priority: 10,
26
+ render(ctx) {
27
+ return ctx.version ? {
28
+ text: `v${ctx.version}`,
29
+ tone: "muted"
30
+ } : undefined;
31
+ }
32
+ }, {
33
+ name: "text",
34
+ priority: 40,
35
+ render(_ctx, config) {
36
+ const value = str(config, "value");
37
+ return value ? {
38
+ text: value,
39
+ tone: "muted"
40
+ } : undefined;
41
+ }
42
+ }, {
43
+ name: "command",
44
+ priority: 45,
45
+ render(ctx, config) {
46
+ const name = str(config, "name") ?? "default";
47
+ const value = ctx.commands[name];
48
+ if (!value) return undefined;
49
+ // A Claude Code statusline may print several rows; `row` picks one, and each row keeps the
50
+ // colours the script asked for rather than being flattened to grey.
51
+ const row = outputRows(value)[num(config, "row", 0)];
52
+ if (row === undefined) return undefined;
53
+ const runs = parseAnsi(row);
54
+ return runs.length > 0 ? {
55
+ runs
56
+ } : undefined;
57
+ }
58
+ }];
@@ -0,0 +1,79 @@
1
+ import { contextUsed } from "./context.js";
2
+ import { shortModel } from "./format.js";
3
+
4
+ /**
5
+ * The escape hatch: a shell command whose stdout becomes a segment.
6
+ *
7
+ * It is fed the same JSON on stdin that Claude Code's statusLine hook sends, so a statusline
8
+ * script someone already wrote works here unchanged. That matters more than elegance — nobody
9
+ * rewrites a working statusline to try a new editor.
10
+ *
11
+ * Unlike Claude Code's, this is not on the draw path: the command runs on its own interval and the
12
+ * line renders whatever it last returned, so a slow script makes the value stale rather than making
13
+ * the interface stutter.
14
+ */
15
+
16
+ /**
17
+ * Claude Code's statusLine stdin payload, as close as our data allows.
18
+ *
19
+ * The fields real statuslines actually read are the context-window ones — a script that draws a
20
+ * capacity bar wants `context_window.used_percentage`, not a token total it has to divide itself.
21
+ * `rate_limits` is deliberately absent: it describes an Anthropic plan's quota, which has no
22
+ * meaning behind a proxy or another provider, and inventing a number there would be worse than
23
+ * the field being missing.
24
+ */
25
+
26
+ export function claudeCodeInput(ctx) {
27
+ const session = ctx.session;
28
+ const model = session?.model;
29
+ const tokens = session?.tokens;
30
+ const used = contextUsed(tokens);
31
+ const limit = model?.contextLimit;
32
+ const payload = {
33
+ hook_event_name: "Status",
34
+ session_id: session?.id ?? "",
35
+ cwd: ctx.directory,
36
+ model: {
37
+ id: model?.modelID ?? "",
38
+ display_name: model ? shortModel(model.modelID) : ""
39
+ },
40
+ workspace: {
41
+ current_dir: ctx.directory,
42
+ project_dir: ctx.worktree
43
+ },
44
+ version: ctx.version,
45
+ output_style: {
46
+ name: "default"
47
+ },
48
+ cost: {
49
+ total_cost_usd: session?.cost ?? 0,
50
+ total_duration_ms: session?.startedAt ? Math.max(0, ctx.now - session.startedAt) : 0,
51
+ total_lines_added: session?.diff.additions ?? 0,
52
+ total_lines_removed: session?.diff.deletions ?? 0
53
+ },
54
+ exceeds_200k_tokens: used > 200_000
55
+ };
56
+ if (session?.title) payload.session_name = session.title;
57
+ if (ctx.worktree) payload.workspace.git_worktree = ctx.worktree;
58
+ if (tokens) {
59
+ payload.current_usage = {
60
+ input_tokens: tokens.input,
61
+ output_tokens: tokens.output,
62
+ cache_creation_tokens: tokens.cache.write,
63
+ cache_read_tokens: tokens.cache.read
64
+ };
65
+ }
66
+ // Only when a window was actually declared: a script dividing by a made-up size draws a
67
+ // confident wrong bar, which is the one thing worse than an empty segment.
68
+ if (tokens && limit && limit > 0) {
69
+ const share = Math.min(100, used / limit * 100);
70
+ payload.context_window = {
71
+ used_percentage: Number(share.toFixed(2)),
72
+ remaining_percentage: Number((100 - share).toFixed(2)),
73
+ context_window_size: limit,
74
+ total_input_tokens: tokens.input + tokens.cache.read + tokens.cache.write,
75
+ total_output_tokens: tokens.output + tokens.reasoning
76
+ };
77
+ }
78
+ return payload;
79
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Running a shell command for a segment.
3
+ *
4
+ * Unlike Claude Code's statusline, this is not on the draw path: the command runs on its own
5
+ * interval and the line renders whatever it last returned, so a slow script makes the value stale
6
+ * rather than making the interface stutter.
7
+ */
8
+
9
+ import { claudeCodeInput } from "./claude-code.js";
10
+ /**
11
+ * What a command's output is worth keeping: every row, escapes and all.
12
+ *
13
+ * The colour escapes are deliberately *not* stripped — they are parsed into styled runs when the
14
+ * segment draws, so a script someone already tuned for Claude Code looks the same here. Claude
15
+ * Code statuslines may also print several rows, so the rows are kept rather than the first one.
16
+ */
17
+ export function cleanOutput(stdout) {
18
+ return stdout.replace(/\r/g, "").replace(/\s+$/, "");
19
+ }
20
+
21
+ /** The rows of a command's last output. */
22
+ export function outputRows(value) {
23
+ return value.length === 0 ? [] : value.split("\n");
24
+ }
25
+ export function createRunner(config, host) {
26
+ const interval = Math.max(250, config.intervalMs ?? 2000);
27
+ const timeout = Math.max(100, config.timeoutMs ?? 1000);
28
+ const compat = config.claudeCodeCompat !== false;
29
+ let value = "";
30
+ let lastRun = Number.NEGATIVE_INFINITY;
31
+ let running = false;
32
+ let disposed = false;
33
+ return {
34
+ value: () => value,
35
+ maybeRun(ctx) {
36
+ if (disposed || running || host.now() - lastRun < interval) return;
37
+ running = true;
38
+ lastRun = host.now();
39
+ const stdin = compat ? JSON.stringify(claudeCodeInput(ctx)) : "";
40
+ host.exec(config.run, stdin, timeout).then(stdout => {
41
+ if (disposed) return;
42
+ const next = cleanOutput(stdout);
43
+ if (next !== value) {
44
+ value = next;
45
+ host.onValue();
46
+ }
47
+ })
48
+ // A failing command leaves the previous value in place: a statusline that empties itself
49
+ // because a script had a bad second is worse than one that is briefly stale.
50
+ .catch(() => {}).finally(() => {
51
+ running = false;
52
+ });
53
+ },
54
+ dispose() {
55
+ disposed = true;
56
+ }
57
+ };
58
+ }
59
+
60
+ /** The real shell, used outside tests. */
61
+ export async function execShell(command, stdin, timeoutMs) {
62
+ const proc = Bun.spawn(["/bin/sh", "-c", command], {
63
+ stdin: new TextEncoder().encode(stdin),
64
+ stdout: "pipe",
65
+ stderr: "ignore",
66
+ env: process.env
67
+ });
68
+ const timer = setTimeout(() => proc.kill(), timeoutMs);
69
+ try {
70
+ const out = await new Response(proc.stdout).text();
71
+ const code = await proc.exited;
72
+ if (code !== 0) throw new Error(`exit ${code}`);
73
+ return out;
74
+ } finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }
@@ -0,0 +1,187 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Statusline settings, read from the same two files every cockpit bay uses:
7
+ *
8
+ * ~/.config/opencode-cockpit/config.json → <project>/.cockpit.json → plugin-entry options
9
+ *
10
+ * Only the `statusline` section is read here. An unreadable or invalid file is ignored rather than
11
+ * fatal — a typo in a config should never cost you the interface.
12
+ */
13
+
14
+ export const CONFIG_FILE = "config.json";
15
+ export const PROJECT_FILE = ".cockpit.json";
16
+
17
+ /**
18
+ * Where a line is drawn.
19
+ *
20
+ * Two surfaces, deliberately. A third sat inside the prompt box, which is both the narrowest place
21
+ * in the window and the one OpenCode already fills with the agent, the model and the elapsed time:
22
+ * a line there had almost no room and almost nothing left to say.
23
+ */
24
+
25
+ /**
26
+ * A segment is either a built-in named by string ("cwd"), or that name with settings. `when` and
27
+ * `priority` are what make a line survive a narrow terminal instead of wrapping into noise.
28
+ */
29
+
30
+ /**
31
+ * How a line lays its segments out. A wide line under the prompt reads across; a sidebar four
32
+ * columns wide reads down.
33
+ */
34
+
35
+ export function globalConfigPath(env = process.env) {
36
+ const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config");
37
+ return join(base, "opencode-cockpit", CONFIG_FILE);
38
+ }
39
+
40
+ /** Reads and merges every source. `options` is the plugin entry's own options object. */
41
+ export function loadStatusConfig(directory, options, env = process.env) {
42
+ return mergeStatus(mergeStatus(readStatusFile(globalConfigPath(env)), readStatusFile(join(directory, PROJECT_FILE))), asStatusConfig(options));
43
+ }
44
+ export function readStatusFile(path) {
45
+ if (!existsSync(path)) return {};
46
+ try {
47
+ return asStatusConfig(JSON.parse(readFileSync(path, "utf8")));
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Section-wise merge. `segments` is replaced rather than concatenated: a project that lists its
55
+ * own segments means "this line", not "these as well as the global ones".
56
+ */
57
+ export function mergeStatus(base, over) {
58
+ const merged = {
59
+ ...base,
60
+ ...over
61
+ };
62
+ if (base.commands || over.commands) merged.commands = {
63
+ ...base.commands,
64
+ ...over.commands
65
+ };
66
+ // Modules add up: a project can bring its own segments without losing the ones you use everywhere.
67
+ if (base.modules || over.modules) merged.modules = [...(base.modules ?? []), ...(over.modules ?? [])];
68
+ return merged;
69
+ }
70
+
71
+ /**
72
+ * Accepts either a whole cockpit config (`{ statusline: {...} }`) or the statusline section on its
73
+ * own, because plugin-entry options are written straight onto the `tui.json` entry.
74
+ */
75
+ export function asStatusConfig(input) {
76
+ if (!input || typeof input !== "object") return {};
77
+ const raw = input;
78
+ const section = raw.statusline ?? raw.status;
79
+ if (section && typeof section === "object") return section;
80
+ const own = {};
81
+ for (const key of ["enabled", "surface", "segments", "separator", "stack", "icons", "lines", "commands", "modules"]) {
82
+ if (raw[key] !== undefined) Object.assign(own, {
83
+ [key]: raw[key]
84
+ });
85
+ }
86
+ return own;
87
+ }
88
+
89
+ /**
90
+ * The default line: what someone who writes nothing at all should see.
91
+ *
92
+ * It took a long walk to arrive here, and the shape is the point. A capacity bar that means
93
+ * something at a glance, the total beside the three quantities that make it up, what changed, how
94
+ * long it has been. Colour carries which is which; the separators carry the grouping.
95
+ *
96
+ * It does repeat one thing OpenCode already shows -- the token count and the percentage, which its
97
+ * footer carries in a corner. That is deliberate. The rule is not to avoid every fact the host
98
+ * mentions, it is to avoid saying it no better than the host does: a bar you can read without
99
+ * looking, with the breakdown beside it, is a different instrument from "78.5K (39%)" in the
100
+ * corner. What stays out are the facts a second copy adds nothing to -- the path, the branch, the
101
+ * model, the spend.
102
+ */
103
+ export const DEFAULT_SEGMENTS = [{
104
+ type: "context",
105
+ style: "bar",
106
+ width: 14,
107
+ icon: ""
108
+ }, {
109
+ type: "tokens",
110
+ format: "tk {total}",
111
+ icon: ""
112
+ }, {
113
+ type: "tokens",
114
+ format: "cache {cacheRead}",
115
+ color: "success",
116
+ icon: ""
117
+ }, {
118
+ type: "tokens",
119
+ format: "in {input}",
120
+ color: "info",
121
+ icon: ""
122
+ }, {
123
+ type: "tokens",
124
+ format: "out {output}",
125
+ color: "accent",
126
+ icon: ""
127
+ }, {
128
+ type: "session.diff",
129
+ icon: ""
130
+ }, {
131
+ type: "session.time",
132
+ icon: ""
133
+ }, "todo", "session.status", "diagnostics"];
134
+ export const DEFAULT_SEPARATOR = " │ ";
135
+ /** What each surface needs to sit level with the host's own content. */
136
+ const PADDING = {
137
+ // OpenCode's footer indents three columns, and a line hard against the bottom of the window
138
+ // reads as clipped, so this one keeps a row clear underneath it.
139
+ bottom: {
140
+ left: 3,
141
+ right: 2,
142
+ top: 0,
143
+ bottom: 1
144
+ },
145
+ // Flush with the sidebar's own content, which the shell bay draws with no padding at all.
146
+ sidebar: {
147
+ left: 0,
148
+ right: 0,
149
+ top: 0,
150
+ bottom: 0
151
+ }
152
+ };
153
+
154
+ /** Normalises whatever the config said into the lines the renderer draws. */
155
+ export function resolveLines(config) {
156
+ const lines = config.lines?.length ? config.lines : [{
157
+ surface: config.surface,
158
+ segments: config.segments,
159
+ separator: config.separator,
160
+ stack: config.stack,
161
+ icons: config.icons
162
+ }];
163
+ return lines.map(line => {
164
+ const surface = line.surface ?? "bottom";
165
+ // The sidebar is a narrow column: across, it would be three truncated words.
166
+ const stack = line.stack ?? config.stack ?? (surface === "sidebar" ? "vertical" : "horizontal");
167
+ return {
168
+ surface,
169
+ segments: line.segments ?? config.segments ?? DEFAULT_SEGMENTS,
170
+ separator: line.separator ?? config.separator ?? (stack === "vertical" ? "" : DEFAULT_SEPARATOR),
171
+ stack,
172
+ maxRows: line.maxRows ?? 8,
173
+ 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
178
+ };
179
+ });
180
+ }
181
+
182
+ /** A segment written as a bare string is that built-in with no settings. */
183
+ export function asSegmentConfig(entry) {
184
+ return typeof entry === "string" ? {
185
+ type: entry
186
+ } : entry;
187
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The snapshot a segment sees. Deliberately a plain object rather than the live plugin api: every
3
+ * built-in is then a pure function of it, which is what makes the line testable without an
4
+ * OpenCode to draw it in.
5
+ */
6
+
7
+ /** Tokens that occupy the context window right now: everything the model reads back. */
8
+ export function contextUsed(tokens) {
9
+ if (!tokens) return 0;
10
+ return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
11
+ }
12
+ export function contextRatio(session) {
13
+ const limit = session?.model?.contextLimit;
14
+ if (!limit || limit <= 0 || !session?.tokens) return undefined;
15
+ return Math.min(1, contextUsed(session.tokens) / limit);
16
+ }
17
+ export function todoRemaining(session) {
18
+ if (!session) return 0;
19
+ return Math.max(0, session.todo.total - session.todo.completed);
20
+ }
21
+ const HEALTHY = new Set(["connected", "ready", "ok", "running", "active"]);
22
+ export function unhealthy(list) {
23
+ return list.filter(item => !HEALTHY.has(item.status.toLowerCase()));
24
+ }