opencode-clm 0.1.1 → 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.
@@ -0,0 +1,466 @@
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, overridesValues, resetSettings, sessionValues, showSetting } from "../overrides.ts";
10
+ import { COMMAND_EVENT, commandOf, createChannelClient, type ChannelOperation, type ChannelReply } from "../channel.ts";
11
+ import { createLocator, resetSession } from "./locator.ts";
12
+ import { outsideMessage, resolveSource, sourceOverrides, type FileApi, type SessionSource } from "./remote.ts";
13
+ import type { SettingsValues } from "../settings-table.ts";
14
+ import { interceptTab } from "./complete.ts";
15
+ import { interceptEnter } from "./intercept.ts";
16
+ import { buildPanelModel, type PanelModel } from "../panel/model.ts";
17
+ import { formatTokenCount } from "../panel/timeline.ts";
18
+ import { readSessionDirectory, sessionDirectory, type SessionReader } from "../session-files.ts";
19
+ import { resolveSettings, type ClmSettings } from "../settings.ts";
20
+ import { fallbackBudget, footerText, latestUsage, modelLimits, overridesNewer, RESET_NOW, RESET_ROW, resetRow, serverBase, serverPluginOptions, settingsView } from "./data.ts";
21
+ import type { SessionFiles } from "../panel/files.ts";
22
+ import { createFooter, createPanel, type PanelController } from "./panel.ts";
23
+
24
+ export const PANEL_ROUTE = "opencode-clm.panel";
25
+ const OPEN_COMMAND = "opencode-clm.panel.open";
26
+ const RELOAD_DEBOUNCE_MS = 250;
27
+ /** The package's server entry, to recognise a `file://` spec in the server plugin list. */
28
+ const PACKAGE_INDEX_URL = new URL("../../index.ts", import.meta.url).href;
29
+
30
+ const PAGES = new Set<Page>(["overview", "input", "edits", "settings"]);
31
+ /** `/clm path` names how this TUI reads the files. */
32
+ const SOURCE_LABEL: Record<SessionSource["kind"], string> = {
33
+ local: "local disk",
34
+ remote: "server file API",
35
+ channel: "server plugin channel",
36
+ outside: "unreadable from this TUI",
37
+ };
38
+ /** Reads nothing: a session directory the attached TUI cannot reach. */
39
+ const NO_READER: SessionReader = { name: "none", isDirectory: async () => false, readText: async () => undefined, list: async () => [], stamp: async () => undefined };
40
+
41
+ function describe(error: unknown): string {
42
+ return error instanceof Error ? error.message : String(error);
43
+ }
44
+
45
+ export const tui: TuiPlugin = async (api, tuiOptions) => {
46
+ let active: { controller: PanelController; sessionID: string } | undefined;
47
+ let reloadTimer: ReturnType<typeof setTimeout> | undefined;
48
+
49
+ /** Settings as the server resolves them: its plugin entry's options, else this plugin's. */
50
+ const settings = (): ClmSettings => {
51
+ const fromServer = serverPluginOptions(api.state.config.plugin, PACKAGE_INDEX_URL);
52
+ const options = fromServer ?? ((tuiOptions ?? {}) as Record<string, unknown>);
53
+ return resolveSettings(options, process.env, api.state.path.directory);
54
+ };
55
+
56
+ // ---- the channel to the server plugin (src/channel.ts) --------------------------------
57
+ const channel = createChannelClient({
58
+ publish: async (command) => {
59
+ const result = await api.client.tui.publish({ body: { type: COMMAND_EVENT, properties: { command } } });
60
+ if (result.error !== undefined) throw new Error(`publishing to the server failed: ${JSON.stringify(result.error)}`);
61
+ },
62
+ subscribe: (handler) => api.event.on(COMMAND_EVENT, (event) => {
63
+ const command = commandOf(event);
64
+ if (command !== undefined) handler(command);
65
+ }),
66
+ });
67
+
68
+ /** Sends one operation; throws the server's error text, or a timeout. */
69
+ const ask = async (sessionID: string, operation: ChannelOperation, timeoutMs?: number): Promise<ChannelReply> => {
70
+ const reply = await channel.request(sessionID, operation, timeoutMs);
71
+ if (!reply.ok) throw new Error(reply.text);
72
+ return reply;
73
+ };
74
+
75
+ /** Where the server keeps each session's files (`locate`), cached; see locator.ts. */
76
+ const locator = createLocator({ ask });
77
+
78
+ /** When this TUI last changed a session's overrides through the channel (no mtime remotely). */
79
+ const channelWrites = new Map<string, number>();
80
+
81
+ /** `write`: the source a setting change goes to (never the read-only same-disk guess). */
82
+ const source = (sessionID: string, options: { write?: boolean } = {}): Promise<SessionSource> => resolveSource({
83
+ ownDirectory: sessionDirectory(settings().mirrorDir, sessionID),
84
+ serverRoot: api.state.path.directory,
85
+ file: api.client.file as unknown as FileApi,
86
+ locate: () => locator.locate(sessionID),
87
+ located: () => locator.fresh(sessionID),
88
+ relocate: () => {
89
+ const own = sessionDirectory(settings().mirrorDir, sessionID);
90
+ // The server keeps this session's files elsewhere: show them.
91
+ locator.relocate(sessionID, (value) => {
92
+ if (value.directory !== own) refreshPanel(sessionID);
93
+ });
94
+ },
95
+ ask: (operation) => ask(sessionID, operation),
96
+ }, options);
97
+
98
+ const readFiles = async (sessionID: string, from: SessionSource): Promise<SessionFiles> => {
99
+ if (from.kind === "outside") {
100
+ const files = await readSessionDirectory(from.directory, sessionID, NO_READER);
101
+ files.warnings.push(outsideMessage(from));
102
+ return files;
103
+ }
104
+ const files = await readSessionDirectory(from.directory, sessionID, from.reader);
105
+ // A read through the server failed: ask where the files are again next time.
106
+ if (from.kind !== "local" && files.warnings.some((warning) => warning.startsWith("Could not"))) locator.forget(sessionID);
107
+ return files;
108
+ };
109
+
110
+ /**
111
+ * Base and effective settings of a session. The server is the authority on the base:
112
+ * once it wrote snapshot.json, the base is the server's (`serverBase`), so a TUI whose
113
+ * environment resolves differently still merges and validates as the server would.
114
+ * Before the first request, the TUI's own resolution of the server's options stands in.
115
+ * Editing base = state.json's `enabled`. Effective = base + the session's overrides.json.
116
+ */
117
+ const sessionSettings = async (sessionID: string, from: SessionSource, files: Pick<SessionFiles, "state" | "snapshot">) => {
118
+ const own = settings();
119
+ const { base, source: baseSource } = serverBase(own, files.snapshot);
120
+ const stored = files.state && typeof files.state === "object" ? (files.state as { enabled?: unknown }).enabled : undefined;
121
+ const baseEditing = typeof stored === "boolean" ? stored : true;
122
+ const read = from.kind === "local"
123
+ ? await sessionValues(from.directory, base, baseEditing)
124
+ : overridesValues(await sourceOverrides(from), base, baseEditing, channelWrites.get(sessionID));
125
+ const values: { base: SettingsValues; effective: SettingsValues } = { base: { editing: baseEditing, settings: base }, effective: read.values };
126
+ return { base, baseSource, baseEditing, directory: from.directory, values, ...(read.warning ? { warning: read.warning } : {}), ...(read.mtimeMs !== undefined ? { overridesAt: read.mtimeMs } : {}) };
127
+ };
128
+
129
+ const loadModel = async (sessionID: string): Promise<PanelModel> => {
130
+ const from = await source(sessionID);
131
+ const files = await readFiles(sessionID, from);
132
+ const messages = api.state.session.messages(sessionID);
133
+ const latest = latestUsage(messages);
134
+ const limits = modelLimits(messages, api.state.provider);
135
+ const current = await sessionSettings(sessionID, from, files);
136
+ const budget = fallbackBudget(current.values.effective.settings, limits);
137
+ // overrides.json written after the last request: its budget is in force from the next
138
+ // request, so the panel shows it rather than the snapshot's.
139
+ const preferSettings = overridesNewer(current.overridesAt, files.snapshot);
140
+ const model = buildPanelModel(files, { ...(latest ? { latest } : {}), ...(budget ? { budget } : {}), ...(preferSettings ? { preferSettings } : {}) });
141
+ model.enabled = current.values.effective.editing;
142
+ model.settings = settingsView(current.values, model, {
143
+ format: { ...(limits.context ? { modelWindow: limits.context } : {}) },
144
+ ...(current.warning ? { warning: current.warning } : {}),
145
+ });
146
+ model.settings.rows.push(resetRow(model.settings.changed.length));
147
+ return model;
148
+ };
149
+
150
+ /** Drops the session's settings changes, locally or through the server. */
151
+ const resetSessionSettings = async (sessionID: string): Promise<{ viaServer: boolean }> => {
152
+ const from = await source(sessionID, { write: true });
153
+ if (from.kind === "local") {
154
+ await resetSettings(from.directory);
155
+ return { viaServer: false };
156
+ }
157
+ await ask(sessionID, { op: "settings-reset" });
158
+ channelWrites.set(sessionID, Date.now());
159
+ return { viaServer: true };
160
+ };
161
+
162
+ /**
163
+ * Validates and saves one setting; returns `Label: value` and whether the server
164
+ * confirmed it with its own toast. Local files: written here (overrides.json, which the
165
+ * server reads before each request). Otherwise the server applies it over the channel.
166
+ */
167
+ const applySetting = async (sessionID: string, name: string, value: string): Promise<{ text: string; viaServer: boolean }> => {
168
+ const from = await source(sessionID, { write: true });
169
+ if (from.kind !== "local") {
170
+ const reply = await ask(sessionID, { op: "set", setting: name, value });
171
+ channelWrites.set(sessionID, Date.now());
172
+ return { text: reply.text, viaServer: true };
173
+ }
174
+ const files = await readFiles(sessionID, from);
175
+ const current = await sessionSettings(sessionID, from, files);
176
+ const result = await changeSetting({
177
+ sessionDirectory: current.directory,
178
+ base: current.base,
179
+ baseEditing: current.baseEditing,
180
+ projectDirectory: api.state.path.directory,
181
+ }, name, value);
182
+ return { text: result.text, viaServer: false };
183
+ };
184
+
185
+ /** False when `/clm` belongs to someone else: `commands: false`, or a user-defined command. */
186
+ const owned = (): boolean => {
187
+ if (!settings().commands) return false;
188
+ return !userOwnsStatusCommand((api.state.config as { command?: unknown }).command);
189
+ };
190
+
191
+ const currentSessionID = (): string | undefined => {
192
+ const route = api.route.current;
193
+ if (route.name === "session" && route.params && typeof route.params.sessionID === "string") return route.params.sessionID;
194
+ if (route.name === PANEL_ROUTE) return active?.sessionID;
195
+ return undefined;
196
+ };
197
+
198
+ const open = (page: Page) => {
199
+ const sessionID = currentSessionID();
200
+ if (!sessionID) {
201
+ api.ui.toast({ variant: "warning", title: "CLM", message: "Open a session first." });
202
+ return;
203
+ }
204
+ if (active && active.sessionID === sessionID && api.route.current.name === PANEL_ROUTE) {
205
+ active.controller.setPage(page);
206
+ return;
207
+ }
208
+ api.route.navigate(PANEL_ROUTE, { sessionID, page });
209
+ };
210
+
211
+ api.route.register([{
212
+ name: PANEL_ROUTE,
213
+ render: ({ params }) => {
214
+ const sessionID = typeof params?.sessionID === "string" ? params.sessionID : "";
215
+ const page = typeof params?.page === "string" && PAGES.has(params.page as Page) ? (params.page as Page) : "overview";
216
+ active?.controller.dispose();
217
+ const controller = createPanel(api, {
218
+ page,
219
+ // The footer of this session takes the panel's model instead of reading the files again.
220
+ load: () => loadModel(sessionID).then(
221
+ (model) => {
222
+ showFooter(sessionID, model);
223
+ return model;
224
+ },
225
+ (error: unknown) => {
226
+ showFooter(sessionID, undefined);
227
+ return errorModel(sessionID, error);
228
+ },
229
+ ),
230
+ apply: async (setting, value) => {
231
+ try {
232
+ if (setting === RESET_ROW) {
233
+ if (value === RESET_NOW) await resetSessionSettings(sessionID);
234
+ refreshFooter(sessionID);
235
+ return undefined;
236
+ }
237
+ await applySetting(sessionID, setting, value);
238
+ refreshFooter(sessionID);
239
+ return undefined;
240
+ } catch (error) {
241
+ return describe(error);
242
+ }
243
+ },
244
+ onDispose: () => {
245
+ if (active?.controller === controller) active = undefined;
246
+ },
247
+ onClose: () => {
248
+ if (active?.controller === controller) active = undefined;
249
+ if (sessionID) api.route.navigate("session", { sessionID });
250
+ else api.route.navigate("home");
251
+ },
252
+ });
253
+ active = { controller, sessionID };
254
+ // A raw renderable is accepted by the host's Solid insert (verified with 1.18.34).
255
+ return controller.renderable as unknown as ReturnType<Parameters<TuiPluginApi["route"]["register"]>[0][number]["render"]>;
256
+ },
257
+ }]);
258
+
259
+ const disposeLayer = api.keymap.registerLayer({
260
+ commands: [{
261
+ name: OPEN_COMMAND,
262
+ title: "CLM panel",
263
+ desc: "Context size, input, edits and settings of CLM",
264
+ namespace: "palette",
265
+ slashName: "clm",
266
+ run() {
267
+ open("overview");
268
+ },
269
+ }],
270
+ });
271
+
272
+ const toast = (message: string, variant: "info" | "warning" = "info") =>
273
+ api.ui.toast({ variant, title: "CLM", message });
274
+
275
+ /** After a change: refresh an open panel so it shows the new value. */
276
+ const refreshPanel = (sessionID: string) => {
277
+ if (active?.sessionID === sessionID) void active.controller.reload();
278
+ refreshFooter(sessionID);
279
+ };
280
+
281
+ /** Every `/clm …` line the intercept consumes (`/clm reset` goes to the server over the channel). */
282
+ const handle = async (command: ClmCommand): Promise<void> => {
283
+ if (command.kind === "open") {
284
+ open(command.page);
285
+ return;
286
+ }
287
+ if (command.kind === "usage") {
288
+ toast(command.text, "warning");
289
+ return;
290
+ }
291
+ const sessionID = currentSessionID();
292
+ if (!sessionID) {
293
+ toast("Open a session first.", "warning");
294
+ return;
295
+ }
296
+ switch (command.kind) {
297
+ case "path": {
298
+ const from = await source(sessionID);
299
+ toast(`${from.directory} (${SOURCE_LABEL[from.kind]})`);
300
+ return;
301
+ }
302
+ case "status":
303
+ toast(statusSummary(await loadModel(sessionID)));
304
+ return;
305
+ case "config-show": {
306
+ const from = await source(sessionID);
307
+ const current = await sessionSettings(sessionID, from, await readFiles(sessionID, from));
308
+ toast(showSetting(command.setting, current.values.effective));
309
+ return;
310
+ }
311
+ case "config-set":
312
+ try {
313
+ const result = await applySetting(sessionID, command.setting, command.value);
314
+ if (!result.viaServer) toast(`CLM ${result.text}.`);
315
+ } catch (error) {
316
+ toast(describe(error), "warning");
317
+ }
318
+ refreshPanel(sessionID);
319
+ return;
320
+ case "config-reset": {
321
+ if (!(await resetSessionSettings(sessionID)).viaServer) toast("CLM settings reset to the defaults.");
322
+ refreshPanel(sessionID);
323
+ return;
324
+ }
325
+ case "enable": {
326
+ // The same override as the server's `/clm on|off` and `/clm config editing`.
327
+ const result = await applySetting(sessionID, "editing", command.enabled ? "on" : "off");
328
+ if (!result.viaServer) toast(`CLM ${command.enabled ? "on" : "off"} for this session`);
329
+ refreshPanel(sessionID);
330
+ return;
331
+ }
332
+ case "server":
333
+ // `/clm reset`: the server owns state.json; it drops the revision and toasts.
334
+ await resetSession({
335
+ sessionID,
336
+ ask,
337
+ command: (parameters) => api.client.session.command(parameters),
338
+ toast,
339
+ });
340
+ refreshPanel(sessionID);
341
+ return;
342
+ }
343
+ };
344
+
345
+ const disposeIntercept = api.keymap.intercept("key", (context) => {
346
+ // Tab on a `/clm …` line completes it inline (OpenCode has no argument completion).
347
+ if (interceptTab(context as never, {
348
+ focused: () => api.renderer.currentFocusedRenderable as never,
349
+ owned,
350
+ show: (items) => toast(items.map((item) => `/clm ${item}`).join(" · ")),
351
+ })) return;
352
+ interceptEnter(context as never, {
353
+ focused: () => api.renderer.currentFocusedRenderable as never,
354
+ owned,
355
+ handle,
356
+ serverChannel: () => true,
357
+ report: (message) => toast(message, "warning"),
358
+ });
359
+ }, { priority: 100 });
360
+
361
+ // ---- footer: `clm 12k / 32k · r2` right of the session prompt (pi's status line) -----
362
+ /**
363
+ * One footer per session, reused while it is alive: the host's Solid slot calls the
364
+ * renderer again whenever its props change, and returning the same renderable keeps it
365
+ * from piling up new ones. Leaving the session view destroys it; the next render makes a
366
+ * new one.
367
+ */
368
+ const footers = new Map<string, ReturnType<typeof createFooter>>();
369
+ const footerTimers = new Map<string, ReturnType<typeof setTimeout>>();
370
+ /** The session's footer while it can still be drawn; the host destroys one it unmounts. */
371
+ const liveFooter = (sessionID: string) => {
372
+ const footer = footers.get(sessionID);
373
+ if (footer?.renderable.isDestroyed) footers.delete(sessionID);
374
+ return footer?.renderable.isDestroyed ? undefined : footer;
375
+ };
376
+ const showFooter = (sessionID: string, model: PanelModel | undefined) =>
377
+ liveFooter(sessionID)?.set(model ? footerText(model) : undefined);
378
+ const refreshFooter = (sessionID: string) => {
379
+ if (!liveFooter(sessionID)) return;
380
+ // An open panel for this session reloads its model and feeds the footer (above).
381
+ if (active?.sessionID === sessionID) return;
382
+ clearTimeout(footerTimers.get(sessionID));
383
+ footerTimers.set(sessionID, setTimeout(() => {
384
+ footerTimers.delete(sessionID);
385
+ void loadModel(sessionID).then(
386
+ (model) => showFooter(sessionID, model),
387
+ () => showFooter(sessionID, undefined),
388
+ );
389
+ }, RELOAD_DEBOUNCE_MS));
390
+ };
391
+ api.slots.register({
392
+ order: 50,
393
+ slots: {
394
+ session_prompt_right(_context, props) {
395
+ const sessionID = (props as { session_id?: unknown }).session_id;
396
+ if (typeof sessionID !== "string" || !settings().enabled) return null as never;
397
+ let footer = liveFooter(sessionID);
398
+ if (!footer) {
399
+ footer = createFooter(api, `opencode-clm-footer-${sessionID}`);
400
+ footers.set(sessionID, footer);
401
+ }
402
+ refreshFooter(sessionID);
403
+ // A raw renderable, as the panel route returns (accepted by the host's Solid insert).
404
+ return footer.renderable as never;
405
+ },
406
+ },
407
+ });
408
+
409
+ const scheduleReload = (sessionID: unknown) => {
410
+ if (typeof sessionID === "string") refreshFooter(sessionID);
411
+ if (!active || (typeof sessionID === "string" && sessionID !== active.sessionID)) return;
412
+ clearTimeout(reloadTimer);
413
+ reloadTimer = setTimeout(() => void active?.controller.reload(), RELOAD_DEBOUNCE_MS);
414
+ };
415
+ const offIdle = api.event.on("session.idle", (event) => scheduleReload((event.properties as { sessionID?: unknown }).sessionID));
416
+ // Sizes change when a reply completes; streaming updates in between are skipped.
417
+ const offMessage = api.event.on("message.updated", (event) => {
418
+ const info = (event.properties as { info?: { sessionID?: unknown; role?: unknown; time?: { completed?: unknown } } }).info;
419
+ if (info?.role === "assistant" && info.time?.completed === undefined) return;
420
+ scheduleReload(info?.sessionID);
421
+ });
422
+
423
+ api.lifecycle.onDispose(() => {
424
+ clearTimeout(reloadTimer);
425
+ for (const timer of footerTimers.values()) clearTimeout(timer);
426
+ for (const footer of footers.values()) if (!footer.renderable.isDestroyed) footer.renderable.destroy();
427
+ footers.clear();
428
+ active?.controller.dispose();
429
+ active = undefined;
430
+ offIdle();
431
+ offMessage();
432
+ channel.dispose();
433
+ disposeIntercept();
434
+ disposeLayer();
435
+ });
436
+ };
437
+
438
+ /** A model that shows the read error on the overview instead of a broken panel. */
439
+ function errorModel(sessionID: string, error: unknown): PanelModel {
440
+ return {
441
+ sessionID,
442
+ directory: "(unknown)",
443
+ found: false,
444
+ enabled: true,
445
+ revision: 0,
446
+ timeline: { points: [], markers: [], peakTokens: 0, turnStarts: [] },
447
+ revisions: [],
448
+ settings: { rows: [], summary: [], changed: [] },
449
+ mirrorPath: "",
450
+ warnings: [`Could not load the CLM files: ${describe(error)}`],
451
+ };
452
+ }
453
+
454
+ /** Toast text for `/clm status` typed in the TUI. */
455
+ export function statusSummary(model: PanelModel): string {
456
+ if (!model.found) return `No CLM data for this session (${model.directory}).`;
457
+ const latest = model.timeline.points.at(-1);
458
+ const parts = [
459
+ `CLM ${model.enabled ? "on" : "off"} · revision ${model.revision}`,
460
+ latest ? `last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}` : "no requests yet",
461
+ model.budget !== undefined ? `budget ${formatTokenCount(model.budget)}` : undefined,
462
+ model.budgetInfo?.overhead !== undefined ? `fixed overhead ~${formatTokenCount(model.budgetInfo.overhead)}` : undefined,
463
+ model.lastOutcome ? `last: ${model.lastOutcome.kind}` : undefined,
464
+ ];
465
+ return parts.filter(Boolean).join(" · ");
466
+ }
@@ -0,0 +1,199 @@
1
+ // Where the TUI reads a session's CLM files from. Local disk first (the TUI and the server
2
+ // share a file system); otherwise the server's own copy through OpenCode's file API
3
+ // (`GET /file`, `GET /file/content`), which serves paths inside the server's instance
4
+ // directory only. A session directory outside it cannot be read from an attached TUI.
5
+ // Written for this package. Verified against opencode 1.18.34: `GET /file/content`
6
+ // server/routes/instance/httpapi/handlers/file.ts:96-124 (missing file → empty text, text
7
+ // trimmed, path must stay inside the instance directory); `GET /file` file.ts:66-92 and
8
+ // packages/core/src/filesystem.ts:66-99 (a missing directory fails).
9
+
10
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
11
+
12
+ import { OVERRIDES_FILE, parseOverridesText, readOverrides, type OverridesRead } from "../overrides.ts";
13
+ import { localReader, type SessionReader } from "../session-files.ts";
14
+
15
+ /** The part of the TUI api's SDK client (`api.client.file`) this uses. */
16
+ export interface FileApi {
17
+ read(parameters: { path: string }): Promise<{ data?: unknown; error?: unknown }>;
18
+ list(parameters: { path: string }): Promise<{ data?: unknown; error?: unknown }>;
19
+ }
20
+
21
+ export type SessionSource =
22
+ | { kind: "local"; directory: string; reader: SessionReader }
23
+ /** Read through the server; `root` is the server's instance directory. */
24
+ | { kind: "remote"; directory: string; root: string; reader: SessionReader }
25
+ /** On the server, outside its instance directory: the server plugin reads it and answers over the channel. */
26
+ | { kind: "channel"; directory: string; root: string; reader: SessionReader }
27
+ /** Outside the server directory and no server plugin answering: unreadable from here. */
28
+ | { kind: "outside"; directory: string; root: string };
29
+
30
+ /** `path` relative to `root`, or undefined when it lies outside. */
31
+ export function insideRoot(root: string, path: string): string | undefined {
32
+ const rel = relative(root, path);
33
+ if (rel === "") return ".";
34
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) return undefined;
35
+ return rel;
36
+ }
37
+
38
+ function failure(error: unknown, path: string): Error {
39
+ if (error instanceof Error) return error;
40
+ const message = error && typeof error === "object" && "data" in error
41
+ ? JSON.stringify((error as { data?: unknown }).data)
42
+ : JSON.stringify(error);
43
+ return new Error(`server file API refused ${path}: ${message}`);
44
+ }
45
+
46
+ /**
47
+ * A reader over the server's file API. `GET /file/content` answers a missing file with
48
+ * empty text and trims what it returns (whitespace only matters at the ends of the JSON and
49
+ * JSONL files read here); `GET /file` fails for a missing directory.
50
+ */
51
+ export function remoteReader(file: FileApi, root: string): SessionReader {
52
+ const rel = (path: string): string => {
53
+ const inside = insideRoot(root, path);
54
+ if (inside === undefined) throw new Error(`${path} lies outside the server directory ${root}`);
55
+ return inside;
56
+ };
57
+ const list = async (path: string): Promise<string[] | undefined> => {
58
+ const result = await file.list({ path: rel(path) });
59
+ if (result.error !== undefined || !Array.isArray(result.data)) return undefined;
60
+ return result.data.flatMap((item) => (item && typeof (item as { name?: unknown }).name === "string" ? [(item as { name: string }).name] : []));
61
+ };
62
+ return {
63
+ name: `remote:${root}`,
64
+ async isDirectory(path) {
65
+ return (await list(path)) !== undefined;
66
+ },
67
+ async readText(path) {
68
+ const result = await file.read({ path: rel(path) });
69
+ if (result.error !== undefined) throw failure(result.error, path);
70
+ const data = result.data as { type?: unknown; content?: unknown } | undefined;
71
+ if (data?.type !== "text" || typeof data.content !== "string" || data.content === "") return undefined;
72
+ return data.content;
73
+ },
74
+ async list(path) {
75
+ return (await list(path)) ?? [];
76
+ },
77
+ // No mtimes over the API; revisions/rN files are written once, so a fixed stamp is safe.
78
+ async stamp() {
79
+ return "remote";
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Asks the server plugin (src/channel.ts) for one operation on this session; throws on refusal. */
85
+ export type ChannelAsk = (operation: { op: "read"; path: string } | { op: "list"; path: string }) => Promise<{ content?: string; names?: string[] }>;
86
+
87
+ /**
88
+ * A reader that asks the server plugin over the channel: for a session directory the file
89
+ * API cannot reach (outside the server's instance directory). Paths go relative to the
90
+ * session directory; the server refuses anything outside it.
91
+ */
92
+ export function channelReader(ask: ChannelAsk, directory: string): SessionReader {
93
+ const rel = (path: string): string => {
94
+ const inside = insideRoot(directory, path);
95
+ if (inside === undefined) throw new Error(`${path} lies outside the session directory ${directory}`);
96
+ return inside === "." ? "" : inside;
97
+ };
98
+ const list = async (path: string) => (await ask({ op: "list", path: rel(path) })).names;
99
+ return {
100
+ name: `channel:${directory}`,
101
+ async isDirectory(path) {
102
+ return (await list(path)) !== undefined;
103
+ },
104
+ async readText(path) {
105
+ return (await ask({ op: "read", path: rel(path) })).content;
106
+ },
107
+ async list(path) {
108
+ return (await list(path)) ?? [];
109
+ },
110
+ async stamp() {
111
+ return "channel";
112
+ },
113
+ };
114
+ }
115
+
116
+ export interface SourceDeps {
117
+ /** The session directory as this TUI resolves it (its own settings and environment). */
118
+ ownDirectory: string;
119
+ /** The server's instance directory (`api.state.path.directory`). */
120
+ serverRoot: string;
121
+ file: FileApi;
122
+ /** The server's answer to `locate`, if it gave one. */
123
+ locate(): Promise<{ directory: string; root: string } | undefined>;
124
+ /**
125
+ * A fresh cached `locate` answer, without asking. With `relocate`, a TUI that shares the
126
+ * server's disk skips the wait: see `resolveSource`.
127
+ */
128
+ located?(): { directory: string; root: string } | undefined;
129
+ /** Starts a `locate` in the background (its answer serves the next resolve). */
130
+ relocate?(): void;
131
+ /** The server plugin, for a located directory outside the server root. */
132
+ ask?: ChannelAsk;
133
+ /** Local check; defaults to the disk. */
134
+ isLocalDirectory?(path: string): Promise<boolean>;
135
+ }
136
+
137
+ /**
138
+ * Local when this TUI's own session directory exists on its disk. Otherwise the server says
139
+ * where the directory is (`locate`), falling back to this TUI's resolution. Inside the
140
+ * server directory it is read through the file API; outside it, through the server plugin
141
+ * over the channel (only when `locate` answered, i.e. the plugin is there).
142
+ */
143
+ export async function resolveSource(deps: SourceDeps, options: { write?: boolean } = {}): Promise<SessionSource> {
144
+ const isLocal = deps.isLocalDirectory ?? ((path: string) => localReader.isDirectory(path).catch(() => false));
145
+ if (await isLocal(deps.ownDirectory)) return { kind: "local", directory: deps.ownDirectory, reader: localReader };
146
+ const sameDisk = async () => (await isLocal(dirname(deps.ownDirectory))) && (await isLocal(deps.serverRoot));
147
+ // A write creates the directory it writes to, and the server must read it: no guessing.
148
+ // Locally only when the server names this TUI's own directory, or does not answer (CLM
149
+ // disabled there) on the server's own disk; otherwise wherever the server keeps the files.
150
+ if (options.write) {
151
+ const answer = await deps.locate().catch(() => undefined);
152
+ if ((answer === undefined || answer.directory === deps.ownDirectory) && (await sameDisk())) {
153
+ return { kind: "local", directory: deps.ownDirectory, reader: localReader };
154
+ }
155
+ return placed(deps, answer);
156
+ }
157
+ // Same disk as the server (its directory and this TUI's mirror parent both exist here), and
158
+ // no answer cached yet: most likely a session before its first request, or a server with
159
+ // CLM disabled. Read the (empty) local directory now instead of waiting for `locate`,
160
+ // which runs in the background and corrects the source on the next load if the server
161
+ // keeps the files elsewhere.
162
+ if (deps.relocate && !deps.located?.() && (await sameDisk())) {
163
+ deps.relocate();
164
+ return { kind: "local", directory: deps.ownDirectory, reader: localReader };
165
+ }
166
+ return placed(deps, await deps.locate().catch(() => undefined));
167
+ }
168
+
169
+ /** The source for the server's answer to `locate` (or this TUI's own path without one). */
170
+ function placed(deps: SourceDeps, located: { directory: string; root: string } | undefined): SessionSource {
171
+ const directory = located?.directory ?? deps.ownDirectory;
172
+ const root = located?.root ?? deps.serverRoot;
173
+ if (insideRoot(root, directory) === undefined) {
174
+ if (located && deps.ask) return { kind: "channel", directory, root, reader: channelReader(deps.ask, directory) };
175
+ return { kind: "outside", directory, root };
176
+ }
177
+ return { kind: "remote", directory, root, reader: remoteReader(deps.file, root) };
178
+ }
179
+
180
+ /** overrides.json of a source. */
181
+ export async function sourceOverrides(source: SessionSource): Promise<OverridesRead> {
182
+ if (source.kind === "local") return readOverrides(source.directory);
183
+ if (source.kind === "outside") return { overrides: {} };
184
+ const path = join(source.directory, OVERRIDES_FILE);
185
+ let text: string | undefined;
186
+ try {
187
+ text = await source.reader.readText(path);
188
+ } catch (error) {
189
+ return { overrides: {}, warning: `could not read ${path}: ${error instanceof Error ? error.message : String(error)}` };
190
+ }
191
+ return text === undefined ? { overrides: {} } : parseOverridesText(text, path);
192
+ }
193
+
194
+ /** Panel text for a session directory the attached TUI cannot read. */
195
+ export function outsideMessage(source: { directory: string; root: string }): string {
196
+ return `The CLM files of this session would be at ${source.directory} on the server, outside the server's directory ${source.root}. ` +
197
+ "OpenCode's file API reads only inside that directory, and the opencode-clm server plugin did not answer to read them instead. " +
198
+ "Check that the server loads opencode-clm at this version, or set mirrorDir inside the project (the default .opencode/clm is).";
199
+ }
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 };