@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,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
|
+
}
|