opencode-clm 0.1.0 → 0.2.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.
@@ -0,0 +1,280 @@
1
+ // TUI side of opencode-clm: the `/clm` panel route, its slash row and palette command,
2
+ // the Enter intercept that handles typed `/clm …` lines without a model turn, and live
3
+ // reloads while the panel is open. Written for this package (v0.2.0 design §2–§3).
4
+
5
+ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui";
6
+
7
+ import { userOwnsStatusCommand } from "../commands.ts";
8
+ import { type ClmCommand, type Page } from "../panel/command.ts";
9
+ import { changeSetting, resetSettings, sessionValues, showSetting } from "../overrides.ts";
10
+ import type { SettingsValues } from "../settings-table.ts";
11
+ import { interceptEnter } from "./intercept.ts";
12
+ import { buildPanelModel, type PanelModel } from "../panel/model.ts";
13
+ import { formatTokenCount } from "../panel/timeline.ts";
14
+ import { readSessionFiles, sessionDirectory } from "../session-files.ts";
15
+ import { resolveSettings, type ClmSettings } from "../settings.ts";
16
+ import { fallbackBudget, latestUsage, modelLimits, overridesNewer, serverBase, serverPluginOptions, settingsView } from "./data.ts";
17
+ import type { SessionFiles } from "../panel/files.ts";
18
+ import { createPanel, type PanelController } from "./panel.ts";
19
+
20
+ export const PANEL_ROUTE = "opencode-clm.panel";
21
+ const OPEN_COMMAND = "opencode-clm.panel.open";
22
+ const RELOAD_DEBOUNCE_MS = 250;
23
+ /** The package's server entry, to recognise a `file://` spec in the server plugin list. */
24
+ const PACKAGE_INDEX_URL = new URL("../../index.ts", import.meta.url).href;
25
+
26
+ const PAGES = new Set<Page>(["overview", "input", "edits", "settings"]);
27
+
28
+ function describe(error: unknown): string {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+
32
+ export const tui: TuiPlugin = async (api, tuiOptions) => {
33
+ let active: { controller: PanelController; sessionID: string } | undefined;
34
+ let reloadTimer: ReturnType<typeof setTimeout> | undefined;
35
+
36
+ /** Settings as the server resolves them: its plugin entry's options, else this plugin's. */
37
+ const settings = (): ClmSettings => {
38
+ const fromServer = serverPluginOptions(api.state.config.plugin, PACKAGE_INDEX_URL);
39
+ const options = fromServer ?? ((tuiOptions ?? {}) as Record<string, unknown>);
40
+ return resolveSettings(options, process.env, api.state.path.directory);
41
+ };
42
+
43
+ /**
44
+ * Base and effective settings of a session. The server is the authority on the base:
45
+ * once it wrote snapshot.json, the base is the server's (`serverBase`), so a TUI whose
46
+ * environment resolves differently still merges and validates as the server would.
47
+ * Before the first request, the TUI's own resolution of the server's options stands in.
48
+ * Editing base = state.json's `enabled`. Effective = base + the session's overrides.json.
49
+ */
50
+ const sessionSettings = async (sessionID: string, files: Pick<SessionFiles, "state" | "snapshot">) => {
51
+ const own = settings();
52
+ const { base, source } = serverBase(own, files.snapshot);
53
+ const directory = sessionDirectory(own.mirrorDir, sessionID);
54
+ const stored = files.state && typeof files.state === "object" ? (files.state as { enabled?: unknown }).enabled : undefined;
55
+ const baseEditing = typeof stored === "boolean" ? stored : true;
56
+ const read = await sessionValues(directory, base, baseEditing);
57
+ const values: { base: SettingsValues; effective: SettingsValues } = { base: { editing: baseEditing, settings: base }, effective: read.values };
58
+ return { base, baseSource: source, baseEditing, directory, values, ...(read.warning ? { warning: read.warning } : {}), ...(read.mtimeMs !== undefined ? { overridesAt: read.mtimeMs } : {}) };
59
+ };
60
+
61
+ const loadModel = async (sessionID: string): Promise<PanelModel> => {
62
+ const resolved = settings();
63
+ const files = await readSessionFiles(resolved.mirrorDir, sessionID);
64
+ const messages = api.state.session.messages(sessionID);
65
+ const latest = latestUsage(messages);
66
+ const limits = modelLimits(messages, api.state.provider);
67
+ const current = await sessionSettings(sessionID, files);
68
+ const budget = fallbackBudget(current.values.effective.settings, limits);
69
+ // overrides.json written after the last request: its budget is in force from the next
70
+ // request, so the panel shows it rather than the snapshot's.
71
+ const preferSettings = overridesNewer(current.overridesAt, files.snapshot);
72
+ const model = buildPanelModel(files, { ...(latest ? { latest } : {}), ...(budget ? { budget } : {}), ...(preferSettings ? { preferSettings } : {}) });
73
+ model.enabled = current.values.effective.editing;
74
+ model.settings = settingsView(current.values, model, {
75
+ format: { ...(limits.context ? { modelWindow: limits.context } : {}) },
76
+ ...(current.warning ? { warning: current.warning } : {}),
77
+ });
78
+ return model;
79
+ };
80
+
81
+ /** Validates and writes one setting to the session's overrides.json; returns `Label: value`. */
82
+ const applySetting = async (sessionID: string, name: string, value: string): Promise<string> => {
83
+ const files = await readSessionFiles(settings().mirrorDir, sessionID);
84
+ const current = await sessionSettings(sessionID, files);
85
+ const result = await changeSetting({
86
+ sessionDirectory: current.directory,
87
+ base: current.base,
88
+ baseEditing: current.baseEditing,
89
+ projectDirectory: api.state.path.directory,
90
+ }, name, value);
91
+ return result.text;
92
+ };
93
+
94
+ /** False when `/clm` belongs to someone else: `commands: false`, or a user-defined command. */
95
+ const owned = (): boolean => {
96
+ if (!settings().commands) return false;
97
+ return !userOwnsStatusCommand((api.state.config as { command?: unknown }).command);
98
+ };
99
+
100
+ const currentSessionID = (): string | undefined => {
101
+ const route = api.route.current;
102
+ if (route.name === "session" && route.params && typeof route.params.sessionID === "string") return route.params.sessionID;
103
+ if (route.name === PANEL_ROUTE) return active?.sessionID;
104
+ return undefined;
105
+ };
106
+
107
+ const open = (page: Page) => {
108
+ const sessionID = currentSessionID();
109
+ if (!sessionID) {
110
+ api.ui.toast({ variant: "warning", title: "CLM", message: "Open a session first." });
111
+ return;
112
+ }
113
+ if (active && active.sessionID === sessionID && api.route.current.name === PANEL_ROUTE) {
114
+ active.controller.setPage(page);
115
+ return;
116
+ }
117
+ api.route.navigate(PANEL_ROUTE, { sessionID, page });
118
+ };
119
+
120
+ api.route.register([{
121
+ name: PANEL_ROUTE,
122
+ render: ({ params }) => {
123
+ const sessionID = typeof params?.sessionID === "string" ? params.sessionID : "";
124
+ const page = typeof params?.page === "string" && PAGES.has(params.page as Page) ? (params.page as Page) : "overview";
125
+ active?.controller.dispose();
126
+ const controller = createPanel(api, {
127
+ page,
128
+ load: () => loadModel(sessionID).catch((error: unknown) => errorModel(sessionID, error)),
129
+ apply: async (setting, value) => {
130
+ try {
131
+ await applySetting(sessionID, setting, value);
132
+ return undefined;
133
+ } catch (error) {
134
+ return describe(error);
135
+ }
136
+ },
137
+ onDispose: () => {
138
+ if (active?.controller === controller) active = undefined;
139
+ },
140
+ onClose: () => {
141
+ if (active?.controller === controller) active = undefined;
142
+ if (sessionID) api.route.navigate("session", { sessionID });
143
+ else api.route.navigate("home");
144
+ },
145
+ });
146
+ active = { controller, sessionID };
147
+ // A raw renderable is accepted by the host's Solid insert (verified with 1.18.34).
148
+ return controller.renderable as unknown as ReturnType<Parameters<TuiPluginApi["route"]["register"]>[0][number]["render"]>;
149
+ },
150
+ }]);
151
+
152
+ const disposeLayer = api.keymap.registerLayer({
153
+ commands: [{
154
+ name: OPEN_COMMAND,
155
+ title: "CLM panel",
156
+ desc: "Context size, input, edits and settings of CLM",
157
+ namespace: "palette",
158
+ slashName: "clm",
159
+ run() {
160
+ open("overview");
161
+ },
162
+ }],
163
+ });
164
+
165
+ const toast = (message: string, variant: "info" | "warning" = "info") =>
166
+ api.ui.toast({ variant, title: "CLM", message });
167
+
168
+ /** After a change: refresh an open panel so it shows the new value. */
169
+ const refreshPanel = (sessionID: string) => {
170
+ if (active?.sessionID === sessionID) void active.controller.reload();
171
+ };
172
+
173
+ /** Every `/clm …` line the intercept consumes (`/clm reset` goes to the server). */
174
+ const handle = async (command: Exclude<ClmCommand, { kind: "server" }>): Promise<void> => {
175
+ if (command.kind === "open") {
176
+ open(command.page);
177
+ return;
178
+ }
179
+ if (command.kind === "usage") {
180
+ toast(command.text, "warning");
181
+ return;
182
+ }
183
+ const sessionID = currentSessionID();
184
+ if (!sessionID) {
185
+ toast("Open a session first.", "warning");
186
+ return;
187
+ }
188
+ switch (command.kind) {
189
+ case "path":
190
+ toast(sessionDirectory(settings().mirrorDir, sessionID));
191
+ return;
192
+ case "status":
193
+ toast(statusSummary(await loadModel(sessionID)));
194
+ return;
195
+ case "config-show": {
196
+ const files = await readSessionFiles(settings().mirrorDir, sessionID);
197
+ const current = await sessionSettings(sessionID, files);
198
+ toast(showSetting(command.setting, current.values.effective));
199
+ return;
200
+ }
201
+ case "config-set":
202
+ try {
203
+ toast(`CLM ${await applySetting(sessionID, command.setting, command.value)}.`);
204
+ } catch (error) {
205
+ toast(describe(error), "warning");
206
+ }
207
+ refreshPanel(sessionID);
208
+ return;
209
+ case "config-reset":
210
+ await resetSettings(sessionDirectory(settings().mirrorDir, sessionID));
211
+ toast("CLM settings reset to the defaults.");
212
+ refreshPanel(sessionID);
213
+ return;
214
+ case "enable":
215
+ // The same override as the server's `/clm on|off` and `/clm config editing`.
216
+ await applySetting(sessionID, "editing", command.enabled ? "on" : "off");
217
+ toast(`CLM ${command.enabled ? "on" : "off"} for this session`);
218
+ refreshPanel(sessionID);
219
+ return;
220
+ }
221
+ };
222
+
223
+ const disposeIntercept = api.keymap.intercept("key", (context) => {
224
+ interceptEnter(context as never, {
225
+ focused: () => api.renderer.currentFocusedRenderable as never,
226
+ owned,
227
+ handle,
228
+ report: (message) => toast(message, "warning"),
229
+ });
230
+ }, { priority: 100 });
231
+
232
+ const scheduleReload = (sessionID: unknown) => {
233
+ if (!active || (typeof sessionID === "string" && sessionID !== active.sessionID)) return;
234
+ clearTimeout(reloadTimer);
235
+ reloadTimer = setTimeout(() => void active?.controller.reload(), RELOAD_DEBOUNCE_MS);
236
+ };
237
+ const offIdle = api.event.on("session.idle", (event) => scheduleReload((event.properties as { sessionID?: unknown }).sessionID));
238
+ const offMessage = api.event.on("message.updated", (event) =>
239
+ scheduleReload((event.properties as { info?: { sessionID?: unknown } }).info?.sessionID));
240
+
241
+ api.lifecycle.onDispose(() => {
242
+ clearTimeout(reloadTimer);
243
+ active?.controller.dispose();
244
+ active = undefined;
245
+ offIdle();
246
+ offMessage();
247
+ disposeIntercept();
248
+ disposeLayer();
249
+ });
250
+ };
251
+
252
+ /** A model that shows the read error on the overview instead of a broken panel. */
253
+ function errorModel(sessionID: string, error: unknown): PanelModel {
254
+ return {
255
+ sessionID,
256
+ directory: "(unknown)",
257
+ found: false,
258
+ enabled: true,
259
+ revision: 0,
260
+ timeline: { points: [], markers: [], peakTokens: 0, turnStarts: [] },
261
+ revisions: [],
262
+ settings: { rows: [], summary: [], changed: [] },
263
+ mirrorPath: "",
264
+ warnings: [`Could not load the CLM files: ${describe(error)}`],
265
+ };
266
+ }
267
+
268
+ /** Toast text for `/clm status` typed in the TUI. */
269
+ export function statusSummary(model: PanelModel): string {
270
+ if (!model.found) return `No CLM data for this session (${model.directory}).`;
271
+ const latest = model.timeline.points.at(-1);
272
+ const parts = [
273
+ `CLM ${model.enabled ? "on" : "off"} · revision ${model.revision}`,
274
+ latest ? `last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}` : "no requests yet",
275
+ model.budget !== undefined ? `budget ${formatTokenCount(model.budget)}` : undefined,
276
+ model.budgetInfo?.overhead !== undefined ? `fixed overhead ~${formatTokenCount(model.budgetInfo.overhead)}` : undefined,
277
+ model.lastOutcome ? `last: ${model.lastOutcome.kind}` : undefined,
278
+ ];
279
+ return parts.filter(Boolean).join(" · ");
280
+ }
package/tui.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * opencode-clm TUI plugin: the `/clm` panel (context size, input, edits, settings).
3
+ * Loaded from `tui.json` (`exports["./tui"]`); the server plugin is index.ts.
4
+ */
5
+ import { tui } from "./src/tui/plugin.ts";
6
+
7
+ export default { id: "opencode-clm", tui };