@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,125 @@
1
+ import { rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ /**
7
+ * Your own segments, written in TypeScript.
8
+ *
9
+ * The declarative config covers the usual line and a shell command covers anything with a CLI, but
10
+ * neither can read the session and decide. A module can: it is handed the same snapshot the
11
+ * built-ins get, and what it returns is placed, coloured, prioritised and collapsed exactly like
12
+ * one of them.
13
+ *
14
+ * A module default-exports its segments by name:
15
+ *
16
+ * import type { StatusContext } from "@opencode-cockpit/status/segment"
17
+ *
18
+ * export default {
19
+ * segments: {
20
+ * burn: (ctx: StatusContext) => {
21
+ * const mins = (ctx.now - (ctx.session?.startedAt ?? ctx.now)) / 60000
22
+ * if (!ctx.session?.priced || mins < 1) return undefined
23
+ * return { text: `$${(ctx.session.cost / mins).toFixed(2)}/min`, tone: "warning" }
24
+ * },
25
+ * },
26
+ * }
27
+ *
28
+ * and the name is then usable in the config like any built-in: `"segments": ["burn"]`.
29
+ */
30
+
31
+ /** What a module's segment function is handed and what it may return. */
32
+
33
+ /** `~/x`, an absolute path, or one relative to the project. */
34
+ export function resolveModulePath(path, directory, home = homedir()) {
35
+ if (path.startsWith("~/")) return resolve(home, path.slice(2));
36
+ if (isAbsolute(path)) return path;
37
+ return resolve(directory, path);
38
+ }
39
+ const DEFAULT_PRIORITY = 45;
40
+
41
+ /** The specifier a module is written against, which is the whole point of the failure below. */
42
+ const AUTHORING = "@opencode-cockpit/status/segment";
43
+
44
+ /**
45
+ * Loading a module that lives outside a project.
46
+ *
47
+ * A statusline module belongs next to the config it serves, and the natural home for that is
48
+ * `~/.config/opencode-cockpit/`. But a bare import resolves from the importing file's own
49
+ * directory, and a config directory has no `node_modules` -- so every example in our own README
50
+ * fails for exactly the people the README is written for, and its segments vanish from the line
51
+ * with only a start-up toast to say why.
52
+ *
53
+ * The specifier resolves perfectly well from *this* file, so the fallback rewrites it to that
54
+ * resolved path and imports a copy placed beside the original, where the module's own relative
55
+ * imports still work. Only on failure: a module inside a project that installed the bay never
56
+ * takes this path.
57
+ */
58
+ async function importWithAuthoring(full) {
59
+ const resolved = Bun.resolveSync("./authoring.ts", import.meta.dir);
60
+ const source = await Bun.file(full).text();
61
+ const patched = source.replaceAll(AUTHORING, pathToFileURL(resolved).href);
62
+ if (patched === source) throw new Error(`does not import ${AUTHORING}`);
63
+
64
+ // Beside the original, so `./helpers.ts` next to a module keeps resolving.
65
+ const shim = join(dirname(full), `.${basename(full, extname(full))}.cockpit.${extname(full).slice(1)}`);
66
+ try {
67
+ await Bun.write(shim, patched);
68
+ return await import(`${shim}?t=${Date.now()}`);
69
+ } finally {
70
+ rmSync(shim, {
71
+ force: true
72
+ });
73
+ }
74
+ }
75
+ export async function loadCustomSegments(paths, directory, importer = path => import(path)) {
76
+ const segments = new Map();
77
+ const errors = [];
78
+ for (const path of paths) {
79
+ const full = resolveModulePath(path, directory);
80
+ try {
81
+ let loaded;
82
+ try {
83
+ loaded = await importer(full);
84
+ } catch (err) {
85
+ // Only the one failure is worth retrying; anything else is the module's own problem.
86
+ const message = err instanceof Error ? err.message : String(err);
87
+ if (!message.includes(AUTHORING)) throw err;
88
+ loaded = await importWithAuthoring(full);
89
+ }
90
+ const module = loaded.default ?? loaded;
91
+ for (const [name, entry] of Object.entries(module.segments ?? {})) {
92
+ const render = typeof entry === "function" ? entry : entry.render;
93
+ if (typeof render !== "function") {
94
+ errors.push(`${path}: segment "${name}" is not a function`);
95
+ continue;
96
+ }
97
+ const priority = typeof entry === "function" ? DEFAULT_PRIORITY : entry.priority ?? DEFAULT_PRIORITY;
98
+ segments.set(name, {
99
+ name,
100
+ priority,
101
+ render(ctx, config) {
102
+ const value = render(ctx, config);
103
+ if (value === undefined) return undefined;
104
+ if (typeof value === "string") return value ? {
105
+ text: value,
106
+ tone: "muted"
107
+ } : undefined;
108
+ if ("runs" in value) return value.runs.length > 0 ? value : undefined;
109
+ return value.text ? {
110
+ text: value.text,
111
+ tone: value.tone ?? "muted",
112
+ color: value.color
113
+ } : undefined;
114
+ }
115
+ });
116
+ }
117
+ } catch (err) {
118
+ errors.push(`${path}: ${err instanceof Error ? err.message : String(err)}`);
119
+ }
120
+ }
121
+ return {
122
+ segments,
123
+ errors
124
+ };
125
+ }
@@ -0,0 +1,156 @@
1
+ /** Formatting for a line where every column costs something. */
2
+
3
+ /** 1234 → "1.2k", 1_200_000 → "1.2M". Whole numbers below 1000 stay as they are. */
4
+ export function compact(n) {
5
+ const abs = Math.abs(n);
6
+ if (abs < 1000) return String(Math.round(n));
7
+ if (abs < 1_000_000) return `${trim(n / 1000)}k`;
8
+ if (abs < 1_000_000_000) return `${trim(n / 1_000_000)}M`;
9
+ return `${trim(n / 1_000_000_000)}B`;
10
+ }
11
+ function trim(n) {
12
+ const one = n.toFixed(1);
13
+ return one.endsWith(".0") ? one.slice(0, -2) : one;
14
+ }
15
+
16
+ /**
17
+ * Money, at the precision the amount deserves: cents matter at $0.42, they do not at $124.
18
+ * Sub-cent spend reads as "<$0.01" rather than "$0.00", which looks like nothing was spent.
19
+ */
20
+ export function money(amount, currency = "$") {
21
+ if (amount <= 0) return `${currency}0`;
22
+ if (amount < 0.01) return `<${currency}0.01`;
23
+ if (amount < 100) return `${currency}${amount.toFixed(2)}`;
24
+ return `${currency}${Math.round(amount)}`;
25
+ }
26
+
27
+ /** "4s", "3m", "2h 5m" — a statusline has no room for "2 hours, 5 minutes". */
28
+ export function duration(ms) {
29
+ const s = Math.max(0, Math.floor(ms / 1000));
30
+ if (s < 60) return `${s}s`;
31
+ const m = Math.floor(s / 60);
32
+ if (m < 60) return `${m}m`;
33
+ const h = Math.floor(m / 60);
34
+ const rest = m % 60;
35
+ return rest === 0 ? `${h}h` : `${h}h ${rest}m`;
36
+ }
37
+
38
+ /** A percentage with no decimal point, because the last digit never changes a decision. */
39
+ export function percent(ratio) {
40
+ return `${Math.round(ratio * 100)}%`;
41
+ }
42
+ const BLOCKS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
43
+
44
+ /** A fractional bar: "███▌ ". Width is in cells, and the result is always exactly that wide. */
45
+ export function bar(ratio, width) {
46
+ const w = Math.max(0, Math.floor(width));
47
+ if (w === 0) return "";
48
+ const clamped = Math.min(1, Math.max(0, ratio));
49
+ const exact = clamped * w;
50
+ const full = Math.floor(exact);
51
+ const part = BLOCKS[Math.floor((exact - full) * BLOCKS.length)] ?? "";
52
+ return `${"█".repeat(full)}${part}`.padEnd(w, " ").slice(0, w);
53
+ }
54
+
55
+ /**
56
+ * The path as a person would say it: "" at the worktree root, "src/tui" inside it, "~/other"
57
+ * elsewhere under home, and the plain path otherwise.
58
+ */
59
+ export function shortPath(directory, worktree, home) {
60
+ const trim = p => p.replace(/\/+$/, "");
61
+ const dir = trim(directory);
62
+ const root = trim(worktree);
63
+ if (dir === root) return basename(root);
64
+ if (root && dir.startsWith(`${root}/`)) return dir.slice(root.length + 1);
65
+ const h = trim(home);
66
+ if (h && dir === h) return "~";
67
+ if (h && dir.startsWith(`${h}/`)) return `~/${dir.slice(h.length + 1)}`;
68
+ return dir;
69
+ }
70
+ export function basename(path) {
71
+ const parts = path.replace(/\/+$/, "").split("/");
72
+ return parts[parts.length - 1] || path;
73
+ }
74
+
75
+ /**
76
+ * Model ids carry a vendor prefix and a date nobody reads at a glance
77
+ * ("anthropic/claude-opus-5-20260101" → "claude-opus-5").
78
+ */
79
+ export function shortModel(modelID) {
80
+ const tail = modelID.includes("/") ? modelID.split("/").pop() : modelID;
81
+ return tail.replace(/-\d{8}$/, "").replace(/-latest$/, "");
82
+ }
83
+
84
+ /**
85
+ * Cuts from the left, keeping the end. For a path the tail is what identifies it: "…/src/tui"
86
+ * says where you are, "/Users/me/very/lo…" does not.
87
+ */
88
+ export function truncateStart(text, max) {
89
+ if (max <= 0) return "";
90
+ if (text.length <= max) return text;
91
+ if (max === 1) return "…";
92
+ return `…${text.slice(text.length - (max - 1))}`;
93
+ }
94
+
95
+ /** Cuts to `max` cells, marking the cut. Never returns more than `max`. */
96
+ export function truncate(text, max) {
97
+ if (max <= 0) return "";
98
+ if (text.length <= max) return text;
99
+ if (max === 1) return "…";
100
+ return `${text.slice(0, max - 1)}…`;
101
+ }
102
+
103
+ /* --------------------------------------------------------------------- colour */
104
+
105
+ /** Green, through amber, to red: the gradient a capacity bar is read against. */
106
+ const HEAT = [[46, 204, 113], [241, 196, 15], [231, 76, 60]];
107
+ function hex([r, g, b]) {
108
+ return `#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, "0")).join("")}`;
109
+ }
110
+
111
+ /**
112
+ * The colour at `t` (0..1) along a gradient, interpolated rather than bucketed. A bar whose cells
113
+ * step smoothly from green to red reads as a measurement; one that flips between three colours
114
+ * reads as three states.
115
+ */
116
+ export function gradient(t, stops = HEAT) {
117
+ const clamped = Math.min(1, Math.max(0, t));
118
+ if (stops.length === 0) return "#ffffff";
119
+ if (stops.length === 1) return hex(stops[0]);
120
+ const span = 1 / (stops.length - 1);
121
+ const index = Math.min(stops.length - 2, Math.floor(clamped / span));
122
+ const from = stops[index];
123
+ const to = stops[index + 1];
124
+ const local = (clamped - index * span) / span;
125
+ return hex([from[0] + (to[0] - from[0]) * local, from[1] + (to[1] - from[1]) * local, from[2] + (to[2] - from[2]) * local]);
126
+ }
127
+
128
+ /**
129
+ * "3m42s" while you are watching it, "2d 13h" once you are not.
130
+ *
131
+ * Each tier drops the one below as it stops mattering: seconds are worth watching in the first
132
+ * minute and meaningless after an hour, and "61h48m" is a number nobody converts in their head.
133
+ */
134
+ export function preciseDuration(ms) {
135
+ const total = Math.max(0, Math.floor(ms / 1000));
136
+ const s = total % 60;
137
+ const m = Math.floor(total / 60) % 60;
138
+ const h = Math.floor(total / 3600) % 24;
139
+ const d = Math.floor(total / 86_400);
140
+ if (d > 0) return h === 0 ? `${d}d` : `${d}d ${h}h`;
141
+ if (total >= 3600) return `${Math.floor(total / 3600)}h${String(m).padStart(2, "0")}m`;
142
+ if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
143
+ return `${s}s`;
144
+ }
145
+
146
+ /**
147
+ * Filling `{name}` placeholders from a segment's own values.
148
+ *
149
+ * This is what a `format` setting runs on. A segment that draws several figures should not also
150
+ * decide the words between them — "3f +12 -4" suits one line and "+12/-4" another, and neither is
151
+ * ours to insist on. An unknown placeholder is left as written, so a typo is visible rather than
152
+ * silently blank.
153
+ */
154
+ export function template(text, values) {
155
+ return text.replace(/\{(\w+)\}/g, (whole, name) => name in values ? String(values[name]) : whole);
156
+ }
@@ -0,0 +1,88 @@
1
+ import { cutSegment, segmentWidth } from "./segments.js";
2
+
3
+ /**
4
+ * Fitting the line to the terminal.
5
+ *
6
+ * Every statusline eventually meets a narrow window. Wrapping turns it into noise and a hard cut
7
+ * loses whichever segments happen to sit on the right, so instead the lowest-priority segments are
8
+ * dropped until what is left fits — the things you actually need (how full the context is, whether
9
+ * something is retrying) survive a 60-column terminal, and the decorations do not.
10
+ */
11
+
12
+ export function lineWidth(segments, separator) {
13
+ if (segments.length === 0) return 0;
14
+ const text = segments.reduce((sum, s) => sum + segmentWidth(s), 0);
15
+ return text + separator.length * (segments.length - 1);
16
+ }
17
+ export function fit(segments, width, separator) {
18
+ if (width <= 0) return {
19
+ segments: [],
20
+ dropped: segments.length
21
+ };
22
+ const kept = [...segments];
23
+ if (lineWidth(kept, separator) <= width) return {
24
+ segments: kept,
25
+ dropped: 0
26
+ };
27
+
28
+ // Drop the least important first; ties break towards the end of the line, so the order a person
29
+ // wrote their segments in still decides what goes.
30
+ const order = kept.map((segment, index) => ({
31
+ segment,
32
+ index
33
+ })).sort((a, b) => a.segment.priority - b.segment.priority || b.index - a.index);
34
+ const doomed = new Set();
35
+ for (const {
36
+ segment
37
+ } of order) {
38
+ if (lineWidth(kept.filter(s => !doomed.has(s.id)), separator) <= width) break;
39
+ doomed.add(segment.id);
40
+ }
41
+ const survivors = kept.filter(s => !doomed.has(s.id));
42
+ // Everything was dropped and it still does not fit: keep the most important one, cut to width.
43
+ if (survivors.length === 0) {
44
+ const best = order[order.length - 1]?.segment;
45
+ if (!best) return {
46
+ segments: [],
47
+ dropped: segments.length
48
+ };
49
+ return {
50
+ segments: [cutSegment(best, width)],
51
+ dropped: segments.length - 1
52
+ };
53
+ }
54
+ // A single survivor may still be wider than the terminal.
55
+ const last = survivors[survivors.length - 1];
56
+ if (survivors.length === 1 && segmentWidth(last) > width) {
57
+ return {
58
+ segments: [cutSegment(last, width)],
59
+ dropped: doomed.size
60
+ };
61
+ }
62
+ return {
63
+ segments: survivors,
64
+ dropped: doomed.size
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Fitting a column. Height is the constraint rather than width, so segments are not merged onto a
70
+ * row: each takes one, cut to the column's width, and the lowest-priority ones go when there are
71
+ * more segments than rows.
72
+ */
73
+ export function fitColumn(segments, width, maxRows) {
74
+ if (width <= 0 || maxRows <= 0) return {
75
+ segments: [],
76
+ dropped: segments.length
77
+ };
78
+ const keep = new Set([...segments].map((segment, index) => ({
79
+ segment,
80
+ index
81
+ })).sort((a, b) => b.segment.priority - a.segment.priority || a.index - b.index).slice(0, maxRows).map(({
82
+ segment
83
+ }) => segment.id));
84
+ return {
85
+ segments: segments.filter(segment => keep.has(segment.id)).map(segment => cutSegment(segment, width)),
86
+ dropped: segments.length - keep.size
87
+ };
88
+ }
@@ -0,0 +1,148 @@
1
+ import { BUILTINS } from "./builtins/index.js";
2
+
3
+ /**
4
+ * Turning a line's configuration into the segments a surface draws. The segment model itself lives
5
+ * in `types.ts` and the built-ins in `builtins/`; this file is only the assembly.
6
+ */
7
+
8
+ export { BUILTINS };
9
+ export function runsOf(piece) {
10
+ return "runs" in piece ? piece.runs : [{
11
+ text: piece.text,
12
+ tone: piece.tone,
13
+ color: piece.color
14
+ }];
15
+ }
16
+ export function segmentText(segment) {
17
+ return segment.runs.map(run => run.text).join("");
18
+ }
19
+ export function segmentWidth(segment) {
20
+ let width = 0;
21
+ for (const run of segment.runs) width += run.text.length;
22
+ return width;
23
+ }
24
+
25
+ /** Cuts a segment to `max` cells, keeping each run's styling up to the cut. */
26
+ export function cutSegment(segment, max) {
27
+ if (max <= 0) return {
28
+ ...segment,
29
+ runs: []
30
+ };
31
+ const runs = [];
32
+ let width = 0;
33
+ for (const run of segment.runs) {
34
+ if (width >= max) break;
35
+ const room = max - width;
36
+ if (run.text.length <= room) {
37
+ runs.push(run);
38
+ width += run.text.length;
39
+ continue;
40
+ }
41
+ // The cut lands inside this run: keep what fits, minus a cell for the ellipsis.
42
+ const text = room <= 1 ? "…" : `${run.text.slice(0, room - 1)}…`;
43
+ runs.push({
44
+ ...run,
45
+ text
46
+ });
47
+ width += text.length;
48
+ break;
49
+ }
50
+ return {
51
+ ...segment,
52
+ runs
53
+ };
54
+ }
55
+ const BY_NAME = new Map(BUILTINS.map(def => [def.name, def]));
56
+
57
+ /**
58
+ * Names that changed, kept working. `git.diff` read as "what git would tell me", when it has
59
+ * always been what this session changed.
60
+ */
61
+ const ALIASES = {
62
+ "git.diff": "session.diff"
63
+ };
64
+ for (const [from, to] of Object.entries(ALIASES)) {
65
+ const def = BY_NAME.get(to);
66
+ if (def) BY_NAME.set(from, def);
67
+ }
68
+ export function findSegment(type) {
69
+ return BY_NAME.get(type);
70
+ }
71
+
72
+ /**
73
+ * Builds the line's segments in order, dropping the ones with nothing to say. An unknown type is
74
+ * dropped too rather than drawn as an error: a stale config should cost you a segment, not a line.
75
+ */
76
+
77
+ export function buildSegments(ctx, configs, options = {}) {
78
+ // A bare map is accepted so the common case reads as `buildSegments(ctx, configs, custom)`.
79
+ const {
80
+ custom,
81
+ icons = true
82
+ } = options instanceof Map ? {
83
+ custom: options
84
+ } : options;
85
+ const out = [];
86
+ const seen = new Map();
87
+ for (const config of configs) {
88
+ // Your own segments are looked up first, so a module can replace a built-in by name.
89
+ const def = custom?.get(config.type) ?? findSegment(config.type);
90
+ if (!def) continue;
91
+ let piece;
92
+ try {
93
+ piece = def.render(ctx, config);
94
+ } catch {
95
+ continue; // a segment that throws costs its own place on the line and nothing else
96
+ }
97
+ if (!piece) continue;
98
+ const wanted = typeof config.color === "string" ? config.color : undefined;
99
+ const forcedTone = wanted ? toTone(wanted) : undefined;
100
+ 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
+
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
+ });
139
+ }
140
+ return out;
141
+ }
142
+ const TONES = new Set(["text", "muted", "accent", "success", "warning", "error", "info"]);
143
+ function toTone(value) {
144
+ return TONES.has(value) ? value : undefined;
145
+ }
146
+ function isLiteralColor(value) {
147
+ return /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value);
148
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,135 @@
1
+ import { insertNode as _$insertNode } from "@opentui/solid";
2
+ import { memo as _$memo } from "@opentui/solid";
3
+ import { effect as _$effect } from "@opentui/solid";
4
+ import { insert as _$insert } from "@opentui/solid";
5
+ import { createComponent as _$createComponent } from "@opentui/solid";
6
+ import { setProp as _$setProp } from "@opentui/solid";
7
+ import { createElement as _$createElement } from "@opentui/solid";
8
+ /** @jsxImportSource @opentui/solid */
9
+
10
+ import { For, Show } from "solid-js";
11
+
12
+ /**
13
+ * One line of segments, each segment a list of styled runs. Colour comes from the running theme
14
+ * rather than literals, so the line belongs to whatever theme the user has chosen — a statusline
15
+ * in someone else's palette is the first thing that makes a plugin look bolted on.
16
+ */
17
+
18
+ /**
19
+ * Theme colours are RGBA objects, and OpenTUI wants the object. Stringifying one yields garbage
20
+ * that the renderer falls back to magenta on, which is how the whole line once came out pink.
21
+ */
22
+
23
+ export function toneColour(theme, tone) {
24
+ switch (tone) {
25
+ case "accent":
26
+ return theme.accent;
27
+ case "success":
28
+ return theme.success;
29
+ case "warning":
30
+ return theme.warning;
31
+ case "error":
32
+ return theme.error;
33
+ case "info":
34
+ return theme.info;
35
+ case "muted":
36
+ return theme.textMuted;
37
+ case "background":
38
+ return theme.background;
39
+ case "panel":
40
+ return theme.backgroundPanel;
41
+ case "border":
42
+ return theme.borderSubtle;
43
+ default:
44
+ return theme.text;
45
+ }
46
+ }
47
+
48
+ /** A run's own colour wins over its tone; a dim run falls back to the muted colour. */
49
+ function runStyle(theme, run) {
50
+ const fg = run.color ?? toneColour(theme, run.dim ? "muted" : run.tone);
51
+ const bg = run.bg ?? (run.bgTone ? toneColour(theme, run.bgTone) : undefined);
52
+ return bg ? {
53
+ fg,
54
+ bg
55
+ } : {
56
+ fg
57
+ };
58
+ }
59
+ export function StatusLine(props) {
60
+ const theme = () => props.api.theme.current;
61
+ const down = () => props.stack === "vertical";
62
+ return (() => {
63
+ var _el$ = _$createElement("box");
64
+ _$setProp(_el$, "flexShrink", 0);
65
+ _$insert(_el$, _$createComponent(For, {
66
+ get each() {
67
+ return props.segments();
68
+ },
69
+ children: (segment, index) => [_$createComponent(Show, {
70
+ get when() {
71
+ return _$memo(() => !!(index() > 0 && !down()))() && props.separator.length > 0;
72
+ },
73
+ 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;
80
+ }
81
+ }), (() => {
82
+ var _el$3 = _$createElement("text");
83
+ _$setProp(_el$3, "wrapMode", "none");
84
+ _$setProp(_el$3, "flexShrink", 0);
85
+ _$insert(_el$3, _$createComponent(For, {
86
+ get each() {
87
+ return segment.runs;
88
+ },
89
+ children: run => _$createComponent(Show, {
90
+ get when() {
91
+ return run.bold;
92
+ },
93
+ get fallback() {
94
+ 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;
99
+ })();
100
+ },
101
+ 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;
108
+ }
109
+ })
110
+ }));
111
+ return _el$3;
112
+ })()]
113
+ }));
114
+ _$effect(_p$ => {
115
+ var _v$ = down() ? "column" : "row",
116
+ _v$2 = props.paddingLeft ?? 1,
117
+ _v$3 = props.paddingRight ?? 1,
118
+ _v$4 = props.paddingTop ?? 0,
119
+ _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));
125
+ return _p$;
126
+ }, {
127
+ e: undefined,
128
+ t: undefined,
129
+ a: undefined,
130
+ o: undefined,
131
+ i: undefined
132
+ });
133
+ return _el$;
134
+ })();
135
+ }