@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.
- package/LICENSE +21 -0
- package/README.md +254 -0
- package/dist/core/ansi.js +145 -0
- package/dist/core/authoring.js +13 -0
- package/dist/core/builtins/index.js +10 -0
- package/dist/core/builtins/model.js +204 -0
- package/dist/core/builtins/place.js +66 -0
- package/dist/core/builtins/session.js +72 -0
- package/dist/core/builtins/settings.js +28 -0
- package/dist/core/builtins/system.js +58 -0
- package/dist/core/claude-code.js +79 -0
- package/dist/core/command.js +77 -0
- package/dist/core/config.js +187 -0
- package/dist/core/context.js +24 -0
- package/dist/core/custom.js +125 -0
- package/dist/core/format.js +156 -0
- package/dist/core/render.js +88 -0
- package/dist/core/segments.js +148 -0
- package/dist/core/types.js +1 -0
- package/dist/tui/components/statusline.js +135 -0
- package/dist/tui/index.js +110 -0
- package/dist/tui/state/snapshot.js +144 -0
- package/dist/tui/state/store.js +51 -0
- package/package.json +63 -0
- package/types/core/ansi.d.ts +8 -0
- package/types/core/authoring.d.ts +17 -0
- package/types/core/builtins/index.d.ts +6 -0
- package/types/core/builtins/model.d.ts +3 -0
- package/types/core/builtins/place.d.ts +3 -0
- package/types/core/builtins/session.d.ts +3 -0
- package/types/core/builtins/settings.d.ts +13 -0
- package/types/core/builtins/system.d.ts +3 -0
- package/types/core/claude-code.d.ts +61 -0
- package/types/core/command.d.ts +35 -0
- package/types/core/config.d.ts +141 -0
- package/types/core/context.d.ts +78 -0
- package/types/core/custom.d.ts +49 -0
- package/types/core/format.d.ts +56 -0
- package/types/core/render.d.ts +22 -0
- package/types/core/segments.d.ts +26 -0
- package/types/core/types.d.ts +52 -0
- package/types/tui/components/statusline.d.ts +26 -0
- package/types/tui/index.d.ts +10 -0
- package/types/tui/state/snapshot.d.ts +11 -0
- 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
|
+
}
|