@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,110 @@
|
|
|
1
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
2
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
3
|
+
/** @jsxImportSource @opentui/solid */
|
|
4
|
+
|
|
5
|
+
import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature";
|
|
6
|
+
import { createMemo } from "solid-js";
|
|
7
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
8
|
+
import { asSegmentConfig, loadStatusConfig, resolveLines } from "../core/config.js";
|
|
9
|
+
import { loadCustomSegments } from "../core/custom.js";
|
|
10
|
+
import { fit, fitColumn } from "../core/render.js";
|
|
11
|
+
import { buildSegments } from "../core/segments.js";
|
|
12
|
+
import { StatusLine } from "./components/statusline.js";
|
|
13
|
+
import { buildContext } from "./state/snapshot.js";
|
|
14
|
+
import { createStatusStore } from "./state/store.js";
|
|
15
|
+
const STATUS_PACKAGE = "@opencode-cockpit/status";
|
|
16
|
+
|
|
17
|
+
/** Status' TUI half as a factory, so bundles such as `opencode-cockpit` can include it. */
|
|
18
|
+
export function createStatusTui({
|
|
19
|
+
source = STATUS_PACKAGE
|
|
20
|
+
} = {}) {
|
|
21
|
+
return async (api, rawOptions) => {
|
|
22
|
+
// The renderer is shared by every TUI plugin in this OpenCode window.
|
|
23
|
+
const claim = claimFeature(api.renderer, "status", source);
|
|
24
|
+
if (!claim.active) {
|
|
25
|
+
api.ui.toast({
|
|
26
|
+
variant: "warning",
|
|
27
|
+
title: "opencode-cockpit",
|
|
28
|
+
message: duplicateFeatureMessage("Statusline", claim.owner, source),
|
|
29
|
+
duration: 10_000
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
api.lifecycle.onDispose(() => claim.release());
|
|
34
|
+
const directory = api.state.path.directory;
|
|
35
|
+
const config = loadStatusConfig(directory, rawOptions);
|
|
36
|
+
if (config.enabled === false) return;
|
|
37
|
+
|
|
38
|
+
// Your own segments, loaded before the first draw so they are never missing from frame one.
|
|
39
|
+
let custom = new Map();
|
|
40
|
+
if (config.modules?.length) {
|
|
41
|
+
const loaded = await loadCustomSegments(config.modules, directory);
|
|
42
|
+
custom = loaded.segments;
|
|
43
|
+
// A module that will not load is worth saying out loud: its segments silently vanish.
|
|
44
|
+
for (const error of loaded.errors) {
|
|
45
|
+
api.ui.toast({
|
|
46
|
+
variant: "error",
|
|
47
|
+
title: "Statusline",
|
|
48
|
+
message: error,
|
|
49
|
+
duration: 10_000
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const lines = resolveLines(config);
|
|
54
|
+
const store = createStatusStore(api, config, {
|
|
55
|
+
version: pkg.version,
|
|
56
|
+
build: buildContext
|
|
57
|
+
});
|
|
58
|
+
api.lifecycle.onDispose(() => store.dispose());
|
|
59
|
+
|
|
60
|
+
/** A line, fitted to the room its surface actually has. */
|
|
61
|
+
const line = (spec, width) => {
|
|
62
|
+
const segments = createMemo(() => {
|
|
63
|
+
const built = buildSegments(store.context(), spec.segments.map(asSegmentConfig), {
|
|
64
|
+
custom,
|
|
65
|
+
icons: spec.icons
|
|
66
|
+
});
|
|
67
|
+
return spec.stack === "vertical" ? fitColumn(built, width(), spec.maxRows).segments : fit(built, width(), spec.separator).segments;
|
|
68
|
+
});
|
|
69
|
+
return _$createComponent(StatusLine, {
|
|
70
|
+
api: api,
|
|
71
|
+
segments: segments,
|
|
72
|
+
get separator() {
|
|
73
|
+
return spec.separator;
|
|
74
|
+
},
|
|
75
|
+
get stack() {
|
|
76
|
+
return spec.stack;
|
|
77
|
+
},
|
|
78
|
+
get paddingLeft() {
|
|
79
|
+
return spec.paddingLeft;
|
|
80
|
+
},
|
|
81
|
+
get paddingRight() {
|
|
82
|
+
return spec.paddingRight;
|
|
83
|
+
},
|
|
84
|
+
get paddingTop() {
|
|
85
|
+
return spec.paddingTop;
|
|
86
|
+
},
|
|
87
|
+
get paddingBottom() {
|
|
88
|
+
return spec.paddingBottom;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
const on = surface => lines.filter(spec => spec.surface === surface);
|
|
93
|
+
const bottom = on("bottom");
|
|
94
|
+
const sidebar = on("sidebar");
|
|
95
|
+
api.slots.register({
|
|
96
|
+
// After the shell dock (150), so the line sits at the very bottom of the window.
|
|
97
|
+
order: 200,
|
|
98
|
+
slots: {
|
|
99
|
+
app_bottom: () => _$memo(() => bottom.map(spec => line(spec, () => api.renderer.width - spec.paddingLeft - spec.paddingRight))),
|
|
100
|
+
// sidebar_content, not sidebar_footer: the host does not draw plugin content in the footer.
|
|
101
|
+
sidebar_content: () => _$memo(() => sidebar.map(spec => line(spec, () => Math.max(10, Math.floor(api.renderer.width / 4)))))
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const plugin = {
|
|
107
|
+
id: STATUS_PACKAGE,
|
|
108
|
+
tui: createStatusTui()
|
|
109
|
+
};
|
|
110
|
+
export default plugin;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
/**
|
|
3
|
+
* Turns OpenCode's live state into the plain snapshot the segments read. Everything that touches
|
|
4
|
+
* the plugin api lives here, so every built-in stays a pure function of its input.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Everything a usage reading accounts for; zero means the message has not reported yet. */
|
|
8
|
+
function counted(tokens) {
|
|
9
|
+
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The session the interface is showing, when it is showing one. */
|
|
13
|
+
export function currentSession(api) {
|
|
14
|
+
const route = api.route.current;
|
|
15
|
+
return route.name === "session" ? route.params.sessionID : undefined;
|
|
16
|
+
}
|
|
17
|
+
export function sessionSnapshot(api, id, now) {
|
|
18
|
+
const messages = api.state.session.messages(id);
|
|
19
|
+
const status = api.state.session.status(id);
|
|
20
|
+
const session = api.state.session.get(id);
|
|
21
|
+
let summed = 0;
|
|
22
|
+
let tokens;
|
|
23
|
+
let modelID;
|
|
24
|
+
let providerID;
|
|
25
|
+
for (const message of messages) {
|
|
26
|
+
if (message.role !== "assistant") continue;
|
|
27
|
+
summed += message.cost ?? 0;
|
|
28
|
+
/**
|
|
29
|
+
* The newest assistant message is what currently occupies the window -- but only once it has
|
|
30
|
+
* reported its usage. A message that is still streaming carries zeroes, and taking those would
|
|
31
|
+
* blank every token-based segment for the length of the turn, which reads as the line breaking
|
|
32
|
+
* exactly when you are watching it.
|
|
33
|
+
*/
|
|
34
|
+
if (message.tokens && counted(message.tokens) > 0) tokens = message.tokens;
|
|
35
|
+
modelID = message.modelID;
|
|
36
|
+
providerID = message.providerID;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* OpenCode keeps a running total on the session record, and its own sidebar reads that. Summing
|
|
41
|
+
* the messages we can see under-reports it twice over: a turn still streaming has not booked its
|
|
42
|
+
* cost yet, and revert or compaction takes spent history out of the list entirely. Observed live
|
|
43
|
+
* against a proxy as $0.30 here against $0.56 in the sidebar.
|
|
44
|
+
*
|
|
45
|
+
* The field is not in the published `Session` type, so it is read defensively and the sum stands
|
|
46
|
+
* in when it is absent.
|
|
47
|
+
*/
|
|
48
|
+
const accumulated = session?.cost;
|
|
49
|
+
const cost = typeof accumulated === "number" && Number.isFinite(accumulated) ? accumulated : summed;
|
|
50
|
+
const model = modelID && providerID ? describeModel(api, providerID, modelID) : undefined;
|
|
51
|
+
let additions = 0;
|
|
52
|
+
let deletions = 0;
|
|
53
|
+
const files = api.state.session.diff(id);
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
additions += file.additions;
|
|
56
|
+
deletions += file.deletions;
|
|
57
|
+
}
|
|
58
|
+
const todos = api.state.session.todo(id);
|
|
59
|
+
const completed = todos.filter(todo => todo.status === "completed").length;
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
title: session?.title,
|
|
63
|
+
status: status?.type === "busy" ? "busy" : status?.type === "retry" ? "retry" : "idle",
|
|
64
|
+
...(status?.type === "retry" ? {
|
|
65
|
+
retry: {
|
|
66
|
+
attempt: status.attempt,
|
|
67
|
+
message: status.message,
|
|
68
|
+
next: status.next
|
|
69
|
+
}
|
|
70
|
+
} : {}),
|
|
71
|
+
...(model ? {
|
|
72
|
+
model
|
|
73
|
+
} : {}),
|
|
74
|
+
...(tokens ? {
|
|
75
|
+
tokens
|
|
76
|
+
} : {}),
|
|
77
|
+
cost,
|
|
78
|
+
priced: model?.priced ?? false,
|
|
79
|
+
messages: messages.length,
|
|
80
|
+
...(session?.time.created ? {
|
|
81
|
+
startedAt: session.time.created
|
|
82
|
+
} : {
|
|
83
|
+
startedAt: now
|
|
84
|
+
}),
|
|
85
|
+
diff: {
|
|
86
|
+
files: files.length,
|
|
87
|
+
additions,
|
|
88
|
+
deletions
|
|
89
|
+
},
|
|
90
|
+
todo: {
|
|
91
|
+
total: todos.length,
|
|
92
|
+
completed
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A model's context window and whether anyone declared prices for it. Both come from the provider
|
|
99
|
+
* catalogue or from the user's own `provider.<id>.models` config — which is the only place they
|
|
100
|
+
* come from behind a proxy such as LiteLLM, where the catalogue knows nothing.
|
|
101
|
+
*/
|
|
102
|
+
function describeModel(api, providerID, modelID) {
|
|
103
|
+
const provider = api.state.provider.find(p => p.id === providerID);
|
|
104
|
+
const model = provider?.models[modelID];
|
|
105
|
+
const limit = model?.limit?.context;
|
|
106
|
+
const cost = model?.cost;
|
|
107
|
+
return {
|
|
108
|
+
providerID,
|
|
109
|
+
modelID,
|
|
110
|
+
...(limit && limit > 0 ? {
|
|
111
|
+
contextLimit: limit
|
|
112
|
+
} : {}),
|
|
113
|
+
priced: Boolean(cost && (cost.input > 0 || cost.output > 0))
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function buildContext(api, options) {
|
|
117
|
+
const sessionID = currentSession(api);
|
|
118
|
+
return {
|
|
119
|
+
now: options.now,
|
|
120
|
+
directory: api.state.path.directory,
|
|
121
|
+
worktree: api.state.path.worktree,
|
|
122
|
+
home: process.env.HOME ?? homedir(),
|
|
123
|
+
...(api.state.vcs?.branch ? {
|
|
124
|
+
branch: api.state.vcs.branch
|
|
125
|
+
} : {}),
|
|
126
|
+
...(api.state.vcs?.default_branch ? {
|
|
127
|
+
defaultBranch: api.state.vcs.default_branch
|
|
128
|
+
} : {}),
|
|
129
|
+
version: options.version,
|
|
130
|
+
...(sessionID ? {
|
|
131
|
+
session: sessionSnapshot(api, sessionID, options.now)
|
|
132
|
+
} : {}),
|
|
133
|
+
lsp: api.state.lsp().map(item => ({
|
|
134
|
+
name: item.id,
|
|
135
|
+
status: String(item.status)
|
|
136
|
+
})),
|
|
137
|
+
mcp: api.state.mcp().map(item => ({
|
|
138
|
+
name: item.name,
|
|
139
|
+
status: String(item.status)
|
|
140
|
+
})),
|
|
141
|
+
commands: options.commands,
|
|
142
|
+
width: options.width
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createMemo, createRoot, createSignal } from "solid-js";
|
|
2
|
+
import { createRunner, execShell } from "../../core/command.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keeps one snapshot of OpenCode's state for every line to read. One memo rather than one per
|
|
6
|
+
* segment: the whole snapshot is a handful of reads of state already in memory, and recomputing it
|
|
7
|
+
* once a second costs less than the bookkeeping to avoid it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function createStatusStore(api, config, options) {
|
|
11
|
+
return createRoot(dispose => {
|
|
12
|
+
const clock = options.now ?? (() => Date.now());
|
|
13
|
+
const [now, setNow] = createSignal(clock());
|
|
14
|
+
const tick = setInterval(() => setNow(clock()), options.tickMs ?? 1000);
|
|
15
|
+
|
|
16
|
+
// A finished command run is a repaint: `equals: false` so an identical count still notifies.
|
|
17
|
+
const [commandTick, bump] = createSignal(0, {
|
|
18
|
+
equals: false
|
|
19
|
+
});
|
|
20
|
+
const runners = new Map();
|
|
21
|
+
for (const [name, command] of Object.entries(config.commands ?? {})) {
|
|
22
|
+
runners.set(name, createRunner(command, {
|
|
23
|
+
exec: options.exec ?? execShell,
|
|
24
|
+
now: clock,
|
|
25
|
+
onValue: () => bump(n => n + 1)
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
const context = createMemo(() => {
|
|
29
|
+
commandTick();
|
|
30
|
+
const commands = {};
|
|
31
|
+
for (const [name, runner] of runners) commands[name] = runner.value();
|
|
32
|
+
const ctx = options.build(api, {
|
|
33
|
+
now: now(),
|
|
34
|
+
width: api.renderer.width,
|
|
35
|
+
version: options.version,
|
|
36
|
+
commands
|
|
37
|
+
});
|
|
38
|
+
// Asking here keeps the schedule tied to what is actually drawn: a hidden line costs nothing.
|
|
39
|
+
for (const runner of runners.values()) runner.maybeRun(ctx);
|
|
40
|
+
return ctx;
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
context,
|
|
44
|
+
dispose() {
|
|
45
|
+
clearInterval(tick);
|
|
46
|
+
for (const runner of runners.values()) runner.dispose();
|
|
47
|
+
dispose();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opencode-cockpit/status",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A statusline for OpenCode you can actually configure: declarative segments, a typed module, or your existing Claude Code statusline command",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Codestz",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Codestz/opencode-cockpit.git",
|
|
11
|
+
"directory": "packages/status"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/Codestz/opencode-cockpit#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Codestz/opencode-cockpit/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"opencode",
|
|
19
|
+
"opencode-plugin",
|
|
20
|
+
"statusline",
|
|
21
|
+
"status-line",
|
|
22
|
+
"tui",
|
|
23
|
+
"terminal",
|
|
24
|
+
"prompt"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
"./tui": {
|
|
28
|
+
"types": "./types/tui/index.d.ts",
|
|
29
|
+
"default": "./dist/tui/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./config": {
|
|
32
|
+
"types": "./types/core/config.d.ts",
|
|
33
|
+
"default": "./dist/core/config.js"
|
|
34
|
+
},
|
|
35
|
+
"./segment": {
|
|
36
|
+
"types": "./types/core/authoring.d.ts",
|
|
37
|
+
"default": "./dist/core/authoring.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"opencode": ">=1.18.0",
|
|
42
|
+
"bun": ">=1.3.5"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"types",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
],
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@opencode-cockpit/client": "0.3.0",
|
|
55
|
+
"@opencode-ai/plugin": "1.18.31"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@opentui/core": "0.4.5",
|
|
59
|
+
"@opentui/keymap": "0.4.5",
|
|
60
|
+
"@opentui/solid": "0.4.5",
|
|
61
|
+
"solid-js": "1.9.12"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Run } from "./segments.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Parses one line of a script's output into runs. Text with no escapes comes back as a single
|
|
4
|
+
* muted run, which is what a plain script should look like.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseAnsi(line: string): Run[];
|
|
7
|
+
/** Every line of a script's output, parsed. Claude Code statuslines may print several rows. */
|
|
8
|
+
export declare function parseAnsiLines(stdout: string): Run[][];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public surface a statusline module writes against, published as
|
|
3
|
+
* `@opencode-cockpit/status/segment`.
|
|
4
|
+
*
|
|
5
|
+
* import type { StatusContext, CustomModule } from "@opencode-cockpit/status/segment"
|
|
6
|
+
*
|
|
7
|
+
* Everything here is a type or a pure helper: a module never touches OpenCode's plugin api, only
|
|
8
|
+
* the snapshot it is handed. That is what makes a custom segment as testable as a built-in.
|
|
9
|
+
*/
|
|
10
|
+
export type { ClaudeCodeStatusInput } from "./claude-code.ts";
|
|
11
|
+
export type { SegmentConfig } from "./config.ts";
|
|
12
|
+
export type { ServiceSnapshot, SessionSnapshot, StatusContext, TokenCounts, } from "./context.ts";
|
|
13
|
+
export { contextRatio, contextUsed, todoRemaining, unhealthy } from "./context.ts";
|
|
14
|
+
export type { CustomModule, CustomRender } from "./custom.ts";
|
|
15
|
+
export { bar, basename, compact, duration, gradient, money, percent, preciseDuration, shortModel, shortPath, truncate, truncateStart, } from "./format.ts";
|
|
16
|
+
export type { Piece, Run, Segment, SegmentDef, Tone } from "./segments.ts";
|
|
17
|
+
export { cutSegment, runsOf, segmentText, segmentWidth } from "./segments.ts";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SegmentDef } from "../types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Every built-in, grouped by what it talks about rather than listed in one file: adding a segment
|
|
4
|
+
* should mean opening the twenty lines it belongs with, not four hundred.
|
|
5
|
+
*/
|
|
6
|
+
export declare const BUILTINS: SegmentDef[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Reading a segment's own settings out of its config entry, safely. */
|
|
2
|
+
import type { SegmentConfig } from "../config.ts";
|
|
3
|
+
import type { Piece, Tone } from "../types.ts";
|
|
4
|
+
export declare function num(config: SegmentConfig, key: string, fallback: number): number;
|
|
5
|
+
export declare function str(config: SegmentConfig, key: string): string | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* A segment's own shape, when the config asked for one.
|
|
8
|
+
*
|
|
9
|
+
* Returns `undefined` when no `format` was written, so the segment draws its default — which is
|
|
10
|
+
* usually several runs in several colours. A format is one run in one tone: full control of the
|
|
11
|
+
* words, at the cost of the colouring, which is the honest trade and worth saying out loud.
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatted(config: SegmentConfig, values: Record<string, string | number>, tone?: Tone): Piece | undefined;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type StatusContext } from "./context.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The escape hatch: a shell command whose stdout becomes a segment.
|
|
4
|
+
*
|
|
5
|
+
* It is fed the same JSON on stdin that Claude Code's statusLine hook sends, so a statusline
|
|
6
|
+
* script someone already wrote works here unchanged. That matters more than elegance — nobody
|
|
7
|
+
* rewrites a working statusline to try a new editor.
|
|
8
|
+
*
|
|
9
|
+
* Unlike Claude Code's, this is not on the draw path: the command runs on its own interval and the
|
|
10
|
+
* line renders whatever it last returned, so a slow script makes the value stale rather than making
|
|
11
|
+
* the interface stutter.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Claude Code's statusLine stdin payload, as close as our data allows.
|
|
15
|
+
*
|
|
16
|
+
* The fields real statuslines actually read are the context-window ones — a script that draws a
|
|
17
|
+
* capacity bar wants `context_window.used_percentage`, not a token total it has to divide itself.
|
|
18
|
+
* `rate_limits` is deliberately absent: it describes an Anthropic plan's quota, which has no
|
|
19
|
+
* meaning behind a proxy or another provider, and inventing a number there would be worse than
|
|
20
|
+
* the field being missing.
|
|
21
|
+
*/
|
|
22
|
+
export interface ClaudeCodeStatusInput {
|
|
23
|
+
hook_event_name: "Status";
|
|
24
|
+
session_id: string;
|
|
25
|
+
session_name?: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
model: {
|
|
28
|
+
id: string;
|
|
29
|
+
display_name: string;
|
|
30
|
+
};
|
|
31
|
+
workspace: {
|
|
32
|
+
current_dir: string;
|
|
33
|
+
project_dir: string;
|
|
34
|
+
git_worktree?: string;
|
|
35
|
+
};
|
|
36
|
+
version: string;
|
|
37
|
+
output_style: {
|
|
38
|
+
name: string;
|
|
39
|
+
};
|
|
40
|
+
cost: {
|
|
41
|
+
total_cost_usd: number;
|
|
42
|
+
total_duration_ms: number;
|
|
43
|
+
total_lines_added: number;
|
|
44
|
+
total_lines_removed: number;
|
|
45
|
+
};
|
|
46
|
+
context_window?: {
|
|
47
|
+
used_percentage: number;
|
|
48
|
+
remaining_percentage: number;
|
|
49
|
+
context_window_size: number;
|
|
50
|
+
total_input_tokens: number;
|
|
51
|
+
total_output_tokens: number;
|
|
52
|
+
};
|
|
53
|
+
current_usage?: {
|
|
54
|
+
input_tokens: number;
|
|
55
|
+
output_tokens: number;
|
|
56
|
+
cache_creation_tokens: number;
|
|
57
|
+
cache_read_tokens: number;
|
|
58
|
+
};
|
|
59
|
+
exceeds_200k_tokens: boolean;
|
|
60
|
+
}
|
|
61
|
+
export declare function claudeCodeInput(ctx: StatusContext): ClaudeCodeStatusInput;
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import type { CommandConfig } from "./config.ts";
|
|
9
|
+
import type { StatusContext } from "./context.ts";
|
|
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 declare function cleanOutput(stdout: string): string;
|
|
18
|
+
/** The rows of a command's last output. */
|
|
19
|
+
export declare function outputRows(value: string): string[];
|
|
20
|
+
export interface CommandRunner {
|
|
21
|
+
/** The last successful output, or "" until one arrives. */
|
|
22
|
+
value(): string;
|
|
23
|
+
/** Runs if the interval has elapsed and no run is in flight. */
|
|
24
|
+
maybeRun(ctx: StatusContext): void;
|
|
25
|
+
dispose(): void;
|
|
26
|
+
}
|
|
27
|
+
export interface RunnerHost {
|
|
28
|
+
/** Injected so tests do not need a shell. Resolves to stdout, or rejects. */
|
|
29
|
+
exec(command: string, stdin: string, timeoutMs: number): Promise<string>;
|
|
30
|
+
now(): number;
|
|
31
|
+
onValue(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function createRunner(config: CommandConfig, host: RunnerHost): CommandRunner;
|
|
34
|
+
/** The real shell, used outside tests. */
|
|
35
|
+
export declare function execShell(command: string, stdin: string, timeoutMs: number): Promise<string>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline settings, read from the same two files every cockpit bay uses:
|
|
3
|
+
*
|
|
4
|
+
* ~/.config/opencode-cockpit/config.json → <project>/.cockpit.json → plugin-entry options
|
|
5
|
+
*
|
|
6
|
+
* Only the `statusline` section is read here. An unreadable or invalid file is ignored rather than
|
|
7
|
+
* fatal — a typo in a config should never cost you the interface.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CONFIG_FILE = "config.json";
|
|
10
|
+
export declare const PROJECT_FILE = ".cockpit.json";
|
|
11
|
+
/**
|
|
12
|
+
* Where a line is drawn.
|
|
13
|
+
*
|
|
14
|
+
* Two surfaces, deliberately. A third sat inside the prompt box, which is both the narrowest place
|
|
15
|
+
* in the window and the one OpenCode already fills with the agent, the model and the elapsed time:
|
|
16
|
+
* a line there had almost no room and almost nothing left to say.
|
|
17
|
+
*/
|
|
18
|
+
export type Surface = "bottom" | "sidebar";
|
|
19
|
+
/**
|
|
20
|
+
* A segment is either a built-in named by string ("cwd"), or that name with settings. `when` and
|
|
21
|
+
* `priority` are what make a line survive a narrow terminal instead of wrapping into noise.
|
|
22
|
+
*/
|
|
23
|
+
export interface SegmentConfig {
|
|
24
|
+
type: string;
|
|
25
|
+
/** Text placed before the value, e.g. "on ". */
|
|
26
|
+
prefix?: string;
|
|
27
|
+
suffix?: string;
|
|
28
|
+
/** Higher survives when the line has to be shortened. Defaults per built-in. */
|
|
29
|
+
priority?: number;
|
|
30
|
+
/** Theme colour name (`success`, `warning`, `textMuted`…) or a literal `#rrggbb`. */
|
|
31
|
+
color?: string;
|
|
32
|
+
/** Built-in specific settings, e.g. `{ "style": "bar" }` for context. */
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
export interface CommandConfig {
|
|
36
|
+
/** Shell command whose stdout becomes the segment's text. */
|
|
37
|
+
run: string;
|
|
38
|
+
/** How often it may run. Defaults to 2000ms; it never runs more than once at a time. */
|
|
39
|
+
intervalMs?: number;
|
|
40
|
+
/** Kill and ignore the output after this long. Defaults to 1000ms. */
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Feed the command Claude Code's statusline JSON on stdin, so an existing statusline script
|
|
44
|
+
* works unchanged. On by default.
|
|
45
|
+
*/
|
|
46
|
+
claudeCodeCompat?: boolean;
|
|
47
|
+
priority?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How a line lays its segments out. A wide line under the prompt reads across; a sidebar four
|
|
51
|
+
* columns wide reads down.
|
|
52
|
+
*/
|
|
53
|
+
export type Stack = "horizontal" | "vertical";
|
|
54
|
+
export interface LineConfig {
|
|
55
|
+
surface?: Surface;
|
|
56
|
+
segments?: (string | SegmentConfig)[];
|
|
57
|
+
/** Drawn between segments. Defaults to " · " across, and nothing down. */
|
|
58
|
+
separator?: string;
|
|
59
|
+
/** Defaults to vertical in the sidebar, horizontal everywhere else. */
|
|
60
|
+
stack?: Stack;
|
|
61
|
+
/** Built-in icons. On by default; switch off for a terminal missing the glyphs. */
|
|
62
|
+
icons?: boolean;
|
|
63
|
+
/** Vertical only: rows to draw at most. Lowest priority goes first. Defaults to 8. */
|
|
64
|
+
maxRows?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Columns of space either side. The defaults line each surface up with OpenCode's own
|
|
67
|
+
* furniture -- its footer indents three, its prompt keeps two clear on the right -- so the line
|
|
68
|
+
* reads as part of the interface rather than as something bolted underneath it.
|
|
69
|
+
*/
|
|
70
|
+
paddingLeft?: number;
|
|
71
|
+
paddingRight?: number;
|
|
72
|
+
paddingTop?: number;
|
|
73
|
+
paddingBottom?: number;
|
|
74
|
+
}
|
|
75
|
+
export interface StatusConfig {
|
|
76
|
+
enabled?: boolean;
|
|
77
|
+
/** One line, for the common case. Use `lines` for more than one surface. */
|
|
78
|
+
surface?: Surface;
|
|
79
|
+
segments?: (string | SegmentConfig)[];
|
|
80
|
+
separator?: string;
|
|
81
|
+
stack?: Stack;
|
|
82
|
+
/** Built-in icons. On by default; switch off for a terminal missing the glyphs. */
|
|
83
|
+
icons?: boolean;
|
|
84
|
+
lines?: LineConfig[];
|
|
85
|
+
/** Named commands usable as segments: `{"type": "command", "name": "budget"}`. */
|
|
86
|
+
commands?: Record<string, CommandConfig>;
|
|
87
|
+
/**
|
|
88
|
+
* Your own segments: paths to modules that export them by name, usable in `segments` exactly
|
|
89
|
+
* like the built-ins. `~` and a path relative to the project both work.
|
|
90
|
+
*/
|
|
91
|
+
modules?: string[];
|
|
92
|
+
}
|
|
93
|
+
export interface CockpitStatusConfig {
|
|
94
|
+
statusline?: StatusConfig;
|
|
95
|
+
}
|
|
96
|
+
export declare function globalConfigPath(env?: Record<string, string | undefined>): string;
|
|
97
|
+
/** Reads and merges every source. `options` is the plugin entry's own options object. */
|
|
98
|
+
export declare function loadStatusConfig(directory: string, options?: unknown, env?: Record<string, string | undefined>): StatusConfig;
|
|
99
|
+
export declare function readStatusFile(path: string): StatusConfig;
|
|
100
|
+
/**
|
|
101
|
+
* Section-wise merge. `segments` is replaced rather than concatenated: a project that lists its
|
|
102
|
+
* own segments means "this line", not "these as well as the global ones".
|
|
103
|
+
*/
|
|
104
|
+
export declare function mergeStatus(base: StatusConfig, over: StatusConfig): StatusConfig;
|
|
105
|
+
/**
|
|
106
|
+
* Accepts either a whole cockpit config (`{ statusline: {...} }`) or the statusline section on its
|
|
107
|
+
* own, because plugin-entry options are written straight onto the `tui.json` entry.
|
|
108
|
+
*/
|
|
109
|
+
export declare function asStatusConfig(input: unknown): StatusConfig;
|
|
110
|
+
/**
|
|
111
|
+
* The default line: what someone who writes nothing at all should see.
|
|
112
|
+
*
|
|
113
|
+
* It took a long walk to arrive here, and the shape is the point. A capacity bar that means
|
|
114
|
+
* something at a glance, the total beside the three quantities that make it up, what changed, how
|
|
115
|
+
* long it has been. Colour carries which is which; the separators carry the grouping.
|
|
116
|
+
*
|
|
117
|
+
* It does repeat one thing OpenCode already shows -- the token count and the percentage, which its
|
|
118
|
+
* footer carries in a corner. That is deliberate. The rule is not to avoid every fact the host
|
|
119
|
+
* mentions, it is to avoid saying it no better than the host does: a bar you can read without
|
|
120
|
+
* looking, with the breakdown beside it, is a different instrument from "78.5K (39%)" in the
|
|
121
|
+
* corner. What stays out are the facts a second copy adds nothing to -- the path, the branch, the
|
|
122
|
+
* model, the spend.
|
|
123
|
+
*/
|
|
124
|
+
export declare const DEFAULT_SEGMENTS: (string | SegmentConfig)[];
|
|
125
|
+
export declare const DEFAULT_SEPARATOR = " \u2502 ";
|
|
126
|
+
export interface ResolvedLine {
|
|
127
|
+
surface: Surface;
|
|
128
|
+
segments: (string | SegmentConfig)[];
|
|
129
|
+
separator: string;
|
|
130
|
+
stack: Stack;
|
|
131
|
+
maxRows: number;
|
|
132
|
+
icons: boolean;
|
|
133
|
+
paddingLeft: number;
|
|
134
|
+
paddingRight: number;
|
|
135
|
+
paddingTop: number;
|
|
136
|
+
paddingBottom: number;
|
|
137
|
+
}
|
|
138
|
+
/** Normalises whatever the config said into the lines the renderer draws. */
|
|
139
|
+
export declare function resolveLines(config: StatusConfig): ResolvedLine[];
|
|
140
|
+
/** A segment written as a bare string is that built-in with no settings. */
|
|
141
|
+
export declare function asSegmentConfig(entry: string | SegmentConfig): SegmentConfig;
|