opencode-clm 0.2.0 → 0.4.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/CHANGELOG.md +1 -1
- package/README.md +87 -66
- package/index.ts +753 -40
- package/package.json +6 -6
- package/src/budget.ts +166 -22
- package/src/channel.ts +187 -0
- package/src/clm.ts +478 -57
- package/src/compaction.ts +251 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +40 -2
- package/src/opencode.ts +59 -0
- package/src/overflow.ts +6 -3
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/keys.ts +11 -1
- package/src/panel/model.ts +26 -2
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +39 -10
- package/src/presentation.ts +8 -2
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +131 -17
- package/src/settings.ts +100 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +89 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +14 -2
- package/src/tui/plugin.ts +222 -35
- package/src/tui/remote.ts +199 -0
package/src/tui/plugin.ts
CHANGED
|
@@ -6,16 +6,20 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
|
6
6
|
|
|
7
7
|
import { userOwnsStatusCommand } from "../commands.ts";
|
|
8
8
|
import { type ClmCommand, type Page } from "../panel/command.ts";
|
|
9
|
-
import { changeSetting, resetSettings, sessionValues, showSetting } from "../overrides.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";
|
|
10
13
|
import type { SettingsValues } from "../settings-table.ts";
|
|
14
|
+
import { interceptTab } from "./complete.ts";
|
|
11
15
|
import { interceptEnter } from "./intercept.ts";
|
|
12
16
|
import { buildPanelModel, type PanelModel } from "../panel/model.ts";
|
|
13
17
|
import { formatTokenCount } from "../panel/timeline.ts";
|
|
14
|
-
import {
|
|
18
|
+
import { readSessionDirectory, sessionDirectory, type SessionReader } from "../session-files.ts";
|
|
15
19
|
import { resolveSettings, type ClmSettings } from "../settings.ts";
|
|
16
|
-
import { fallbackBudget, latestUsage, modelLimits, overridesNewer, serverBase, serverPluginOptions, settingsView } from "./data.ts";
|
|
20
|
+
import { fallbackBudget, footerText, latestUsage, modelLimits, overridesNewer, RESET_NOW, RESET_ROW, resetRow, serverBase, serverPluginOptions, settingsView } from "./data.ts";
|
|
17
21
|
import type { SessionFiles } from "../panel/files.ts";
|
|
18
|
-
import { createPanel, type PanelController } from "./panel.ts";
|
|
22
|
+
import { createFooter, createPanel, type PanelController } from "./panel.ts";
|
|
19
23
|
|
|
20
24
|
export const PANEL_ROUTE = "opencode-clm.panel";
|
|
21
25
|
const OPEN_COMMAND = "opencode-clm.panel.open";
|
|
@@ -24,6 +28,15 @@ const RELOAD_DEBOUNCE_MS = 250;
|
|
|
24
28
|
const PACKAGE_INDEX_URL = new URL("../../index.ts", import.meta.url).href;
|
|
25
29
|
|
|
26
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 };
|
|
27
40
|
|
|
28
41
|
function describe(error: unknown): string {
|
|
29
42
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -40,6 +53,60 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
|
|
|
40
53
|
return resolveSettings(options, process.env, api.state.path.directory);
|
|
41
54
|
};
|
|
42
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
|
+
|
|
43
110
|
/**
|
|
44
111
|
* Base and effective settings of a session. The server is the authority on the base:
|
|
45
112
|
* once it wrote snapshot.json, the base is the server's (`serverBase`), so a TUI whose
|
|
@@ -47,48 +114,73 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
|
|
|
47
114
|
* Before the first request, the TUI's own resolution of the server's options stands in.
|
|
48
115
|
* Editing base = state.json's `enabled`. Effective = base + the session's overrides.json.
|
|
49
116
|
*/
|
|
50
|
-
const sessionSettings = async (sessionID: string, files: Pick<SessionFiles, "state" | "snapshot">) => {
|
|
117
|
+
const sessionSettings = async (sessionID: string, from: SessionSource, files: Pick<SessionFiles, "state" | "snapshot">) => {
|
|
51
118
|
const own = settings();
|
|
52
|
-
const { base, source } = serverBase(own, files.snapshot);
|
|
53
|
-
const directory = sessionDirectory(own.mirrorDir, sessionID);
|
|
119
|
+
const { base, source: baseSource } = serverBase(own, files.snapshot);
|
|
54
120
|
const stored = files.state && typeof files.state === "object" ? (files.state as { enabled?: unknown }).enabled : undefined;
|
|
55
121
|
const baseEditing = typeof stored === "boolean" ? stored : true;
|
|
56
|
-
const read =
|
|
122
|
+
const read = from.kind === "local"
|
|
123
|
+
? await sessionValues(from.directory, base, baseEditing)
|
|
124
|
+
: overridesValues(await sourceOverrides(from), base, baseEditing, channelWrites.get(sessionID));
|
|
57
125
|
const values: { base: SettingsValues; effective: SettingsValues } = { base: { editing: baseEditing, settings: base }, effective: read.values };
|
|
58
|
-
return { base, baseSource
|
|
126
|
+
return { base, baseSource, baseEditing, directory: from.directory, values, ...(read.warning ? { warning: read.warning } : {}), ...(read.mtimeMs !== undefined ? { overridesAt: read.mtimeMs } : {}) };
|
|
59
127
|
};
|
|
60
128
|
|
|
61
129
|
const loadModel = async (sessionID: string): Promise<PanelModel> => {
|
|
62
|
-
const
|
|
63
|
-
const files = await
|
|
130
|
+
const from = await source(sessionID);
|
|
131
|
+
const files = await readFiles(sessionID, from);
|
|
64
132
|
const messages = api.state.session.messages(sessionID);
|
|
65
133
|
const latest = latestUsage(messages);
|
|
66
134
|
const limits = modelLimits(messages, api.state.provider);
|
|
67
|
-
const current = await sessionSettings(sessionID, files);
|
|
135
|
+
const current = await sessionSettings(sessionID, from, files);
|
|
68
136
|
const budget = fallbackBudget(current.values.effective.settings, limits);
|
|
69
137
|
// overrides.json written after the last request: its budget is in force from the next
|
|
70
138
|
// request, so the panel shows it rather than the snapshot's.
|
|
71
139
|
const preferSettings = overridesNewer(current.overridesAt, files.snapshot);
|
|
72
140
|
const model = buildPanelModel(files, { ...(latest ? { latest } : {}), ...(budget ? { budget } : {}), ...(preferSettings ? { preferSettings } : {}) });
|
|
73
141
|
model.enabled = current.values.effective.editing;
|
|
142
|
+
if (current.values.effective.settings.mode === "notices-only") model.noticesOnly = true;
|
|
74
143
|
model.settings = settingsView(current.values, model, {
|
|
75
|
-
format: { ...(limits.context ? { modelWindow: limits.context } : {}) },
|
|
144
|
+
format: { ...(limits.context ? { modelWindow: limits.context } : {}), ...(limits.output ? { modelOutput: limits.output } : {}) },
|
|
76
145
|
...(current.warning ? { warning: current.warning } : {}),
|
|
77
146
|
});
|
|
147
|
+
model.settings.rows.push(resetRow(model.settings.changed.length));
|
|
78
148
|
return model;
|
|
79
149
|
};
|
|
80
150
|
|
|
81
|
-
/**
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
151
|
+
/** Drops the session's settings changes, locally or through the server. */
|
|
152
|
+
const resetSessionSettings = async (sessionID: string): Promise<{ viaServer: boolean }> => {
|
|
153
|
+
const from = await source(sessionID, { write: true });
|
|
154
|
+
if (from.kind === "local") {
|
|
155
|
+
await resetSettings(from.directory);
|
|
156
|
+
return { viaServer: false };
|
|
157
|
+
}
|
|
158
|
+
await ask(sessionID, { op: "settings-reset" });
|
|
159
|
+
channelWrites.set(sessionID, Date.now());
|
|
160
|
+
return { viaServer: true };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validates and saves one setting; returns `Label: value` and whether the server
|
|
165
|
+
* confirmed it with its own toast. Local files: written here (overrides.json, which the
|
|
166
|
+
* server reads before each request). Otherwise the server applies it over the channel.
|
|
167
|
+
*/
|
|
168
|
+
const applySetting = async (sessionID: string, name: string, value: string): Promise<{ text: string; viaServer: boolean }> => {
|
|
169
|
+
const from = await source(sessionID, { write: true });
|
|
170
|
+
if (from.kind !== "local") {
|
|
171
|
+
const reply = await ask(sessionID, { op: "set", setting: name, value });
|
|
172
|
+
channelWrites.set(sessionID, Date.now());
|
|
173
|
+
return { text: reply.text, viaServer: true };
|
|
174
|
+
}
|
|
175
|
+
const files = await readFiles(sessionID, from);
|
|
176
|
+
const current = await sessionSettings(sessionID, from, files);
|
|
85
177
|
const result = await changeSetting({
|
|
86
178
|
sessionDirectory: current.directory,
|
|
87
179
|
base: current.base,
|
|
88
180
|
baseEditing: current.baseEditing,
|
|
89
181
|
projectDirectory: api.state.path.directory,
|
|
90
182
|
}, name, value);
|
|
91
|
-
return result.text;
|
|
183
|
+
return { text: result.text, viaServer: false };
|
|
92
184
|
};
|
|
93
185
|
|
|
94
186
|
/** False when `/clm` belongs to someone else: `commands: false`, or a user-defined command. */
|
|
@@ -125,10 +217,26 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
|
|
|
125
217
|
active?.controller.dispose();
|
|
126
218
|
const controller = createPanel(api, {
|
|
127
219
|
page,
|
|
128
|
-
|
|
220
|
+
// The footer of this session takes the panel's model instead of reading the files again.
|
|
221
|
+
load: () => loadModel(sessionID).then(
|
|
222
|
+
(model) => {
|
|
223
|
+
showFooter(sessionID, model);
|
|
224
|
+
return model;
|
|
225
|
+
},
|
|
226
|
+
(error: unknown) => {
|
|
227
|
+
showFooter(sessionID, undefined);
|
|
228
|
+
return errorModel(sessionID, error);
|
|
229
|
+
},
|
|
230
|
+
),
|
|
129
231
|
apply: async (setting, value) => {
|
|
130
232
|
try {
|
|
233
|
+
if (setting === RESET_ROW) {
|
|
234
|
+
if (value === RESET_NOW) await resetSessionSettings(sessionID);
|
|
235
|
+
refreshFooter(sessionID);
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
131
238
|
await applySetting(sessionID, setting, value);
|
|
239
|
+
refreshFooter(sessionID);
|
|
132
240
|
return undefined;
|
|
133
241
|
} catch (error) {
|
|
134
242
|
return describe(error);
|
|
@@ -168,10 +276,11 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
|
|
|
168
276
|
/** After a change: refresh an open panel so it shows the new value. */
|
|
169
277
|
const refreshPanel = (sessionID: string) => {
|
|
170
278
|
if (active?.sessionID === sessionID) void active.controller.reload();
|
|
279
|
+
refreshFooter(sessionID);
|
|
171
280
|
};
|
|
172
281
|
|
|
173
|
-
/** Every `/clm …` line the intercept consumes (`/clm reset` goes to the server). */
|
|
174
|
-
const handle = async (command:
|
|
282
|
+
/** Every `/clm …` line the intercept consumes (`/clm reset` goes to the server over the channel). */
|
|
283
|
+
const handle = async (command: ClmCommand): Promise<void> => {
|
|
175
284
|
if (command.kind === "open") {
|
|
176
285
|
open(command.page);
|
|
177
286
|
return;
|
|
@@ -186,64 +295,142 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
|
|
|
186
295
|
return;
|
|
187
296
|
}
|
|
188
297
|
switch (command.kind) {
|
|
189
|
-
case "path":
|
|
190
|
-
|
|
298
|
+
case "path": {
|
|
299
|
+
const from = await source(sessionID);
|
|
300
|
+
toast(`${from.directory} (${SOURCE_LABEL[from.kind]})`);
|
|
191
301
|
return;
|
|
302
|
+
}
|
|
192
303
|
case "status":
|
|
193
304
|
toast(statusSummary(await loadModel(sessionID)));
|
|
194
305
|
return;
|
|
195
306
|
case "config-show": {
|
|
196
|
-
const
|
|
197
|
-
const current = await sessionSettings(sessionID,
|
|
307
|
+
const from = await source(sessionID);
|
|
308
|
+
const current = await sessionSettings(sessionID, from, await readFiles(sessionID, from));
|
|
198
309
|
toast(showSetting(command.setting, current.values.effective));
|
|
199
310
|
return;
|
|
200
311
|
}
|
|
201
312
|
case "config-set":
|
|
202
313
|
try {
|
|
203
|
-
|
|
314
|
+
const result = await applySetting(sessionID, command.setting, command.value);
|
|
315
|
+
if (!result.viaServer) toast(`CLM ${result.text}.`);
|
|
204
316
|
} catch (error) {
|
|
205
317
|
toast(describe(error), "warning");
|
|
206
318
|
}
|
|
207
319
|
refreshPanel(sessionID);
|
|
208
320
|
return;
|
|
209
|
-
case "config-reset":
|
|
210
|
-
|
|
211
|
-
toast("CLM settings reset to the defaults.");
|
|
321
|
+
case "config-reset": {
|
|
322
|
+
if (!(await resetSessionSettings(sessionID)).viaServer) toast("CLM settings reset to the defaults.");
|
|
212
323
|
refreshPanel(sessionID);
|
|
213
324
|
return;
|
|
214
|
-
|
|
325
|
+
}
|
|
326
|
+
case "enable": {
|
|
215
327
|
// 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`);
|
|
328
|
+
const result = await applySetting(sessionID, "editing", command.enabled ? "on" : "off");
|
|
329
|
+
if (!result.viaServer) toast(`CLM ${command.enabled ? "on" : "off"} for this session`);
|
|
330
|
+
refreshPanel(sessionID);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
case "server":
|
|
334
|
+
// `/clm reset`: the server owns state.json; it drops the revision and toasts.
|
|
335
|
+
await resetSession({
|
|
336
|
+
sessionID,
|
|
337
|
+
ask,
|
|
338
|
+
command: (parameters) => api.client.session.command(parameters),
|
|
339
|
+
toast,
|
|
340
|
+
});
|
|
218
341
|
refreshPanel(sessionID);
|
|
219
342
|
return;
|
|
220
343
|
}
|
|
221
344
|
};
|
|
222
345
|
|
|
223
346
|
const disposeIntercept = api.keymap.intercept("key", (context) => {
|
|
347
|
+
// Tab on a `/clm …` line completes it inline (OpenCode has no argument completion).
|
|
348
|
+
if (interceptTab(context as never, {
|
|
349
|
+
focused: () => api.renderer.currentFocusedRenderable as never,
|
|
350
|
+
owned,
|
|
351
|
+
show: (items) => toast(items.map((item) => `/clm ${item}`).join(" · ")),
|
|
352
|
+
})) return;
|
|
224
353
|
interceptEnter(context as never, {
|
|
225
354
|
focused: () => api.renderer.currentFocusedRenderable as never,
|
|
226
355
|
owned,
|
|
227
356
|
handle,
|
|
357
|
+
serverChannel: () => true,
|
|
228
358
|
report: (message) => toast(message, "warning"),
|
|
229
359
|
});
|
|
230
360
|
}, { priority: 100 });
|
|
231
361
|
|
|
362
|
+
// ---- footer: `clm 12k / 32k · r2` right of the session prompt (pi's status line) -----
|
|
363
|
+
/**
|
|
364
|
+
* One footer per session, reused while it is alive: the host's Solid slot calls the
|
|
365
|
+
* renderer again whenever its props change, and returning the same renderable keeps it
|
|
366
|
+
* from piling up new ones. Leaving the session view destroys it; the next render makes a
|
|
367
|
+
* new one.
|
|
368
|
+
*/
|
|
369
|
+
const footers = new Map<string, ReturnType<typeof createFooter>>();
|
|
370
|
+
const footerTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
371
|
+
/** The session's footer while it can still be drawn; the host destroys one it unmounts. */
|
|
372
|
+
const liveFooter = (sessionID: string) => {
|
|
373
|
+
const footer = footers.get(sessionID);
|
|
374
|
+
if (footer?.renderable.isDestroyed) footers.delete(sessionID);
|
|
375
|
+
return footer?.renderable.isDestroyed ? undefined : footer;
|
|
376
|
+
};
|
|
377
|
+
const showFooter = (sessionID: string, model: PanelModel | undefined) =>
|
|
378
|
+
liveFooter(sessionID)?.set(model ? footerText(model) : undefined);
|
|
379
|
+
const refreshFooter = (sessionID: string) => {
|
|
380
|
+
if (!liveFooter(sessionID)) return;
|
|
381
|
+
// An open panel for this session reloads its model and feeds the footer (above).
|
|
382
|
+
if (active?.sessionID === sessionID) return;
|
|
383
|
+
clearTimeout(footerTimers.get(sessionID));
|
|
384
|
+
footerTimers.set(sessionID, setTimeout(() => {
|
|
385
|
+
footerTimers.delete(sessionID);
|
|
386
|
+
void loadModel(sessionID).then(
|
|
387
|
+
(model) => showFooter(sessionID, model),
|
|
388
|
+
() => showFooter(sessionID, undefined),
|
|
389
|
+
);
|
|
390
|
+
}, RELOAD_DEBOUNCE_MS));
|
|
391
|
+
};
|
|
392
|
+
api.slots.register({
|
|
393
|
+
order: 50,
|
|
394
|
+
slots: {
|
|
395
|
+
session_prompt_right(_context, props) {
|
|
396
|
+
const sessionID = (props as { session_id?: unknown }).session_id;
|
|
397
|
+
if (typeof sessionID !== "string" || !settings().enabled) return null as never;
|
|
398
|
+
let footer = liveFooter(sessionID);
|
|
399
|
+
if (!footer) {
|
|
400
|
+
footer = createFooter(api, `opencode-clm-footer-${sessionID}`);
|
|
401
|
+
footers.set(sessionID, footer);
|
|
402
|
+
}
|
|
403
|
+
refreshFooter(sessionID);
|
|
404
|
+
// A raw renderable, as the panel route returns (accepted by the host's Solid insert).
|
|
405
|
+
return footer.renderable as never;
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
232
410
|
const scheduleReload = (sessionID: unknown) => {
|
|
411
|
+
if (typeof sessionID === "string") refreshFooter(sessionID);
|
|
233
412
|
if (!active || (typeof sessionID === "string" && sessionID !== active.sessionID)) return;
|
|
234
413
|
clearTimeout(reloadTimer);
|
|
235
414
|
reloadTimer = setTimeout(() => void active?.controller.reload(), RELOAD_DEBOUNCE_MS);
|
|
236
415
|
};
|
|
237
416
|
const offIdle = api.event.on("session.idle", (event) => scheduleReload((event.properties as { sessionID?: unknown }).sessionID));
|
|
238
|
-
|
|
239
|
-
|
|
417
|
+
// Sizes change when a reply completes; streaming updates in between are skipped.
|
|
418
|
+
const offMessage = api.event.on("message.updated", (event) => {
|
|
419
|
+
const info = (event.properties as { info?: { sessionID?: unknown; role?: unknown; time?: { completed?: unknown } } }).info;
|
|
420
|
+
if (info?.role === "assistant" && info.time?.completed === undefined) return;
|
|
421
|
+
scheduleReload(info?.sessionID);
|
|
422
|
+
});
|
|
240
423
|
|
|
241
424
|
api.lifecycle.onDispose(() => {
|
|
242
425
|
clearTimeout(reloadTimer);
|
|
426
|
+
for (const timer of footerTimers.values()) clearTimeout(timer);
|
|
427
|
+
for (const footer of footers.values()) if (!footer.renderable.isDestroyed) footer.renderable.destroy();
|
|
428
|
+
footers.clear();
|
|
243
429
|
active?.controller.dispose();
|
|
244
430
|
active = undefined;
|
|
245
431
|
offIdle();
|
|
246
432
|
offMessage();
|
|
433
|
+
channel.dispose();
|
|
247
434
|
disposeIntercept();
|
|
248
435
|
disposeLayer();
|
|
249
436
|
});
|
|
@@ -270,7 +457,7 @@ export function statusSummary(model: PanelModel): string {
|
|
|
270
457
|
if (!model.found) return `No CLM data for this session (${model.directory}).`;
|
|
271
458
|
const latest = model.timeline.points.at(-1);
|
|
272
459
|
const parts = [
|
|
273
|
-
`CLM ${model.enabled ? "on" : "off"} · revision ${model.revision}`,
|
|
460
|
+
`CLM ${model.enabled ? "on" : "off"}${model.enabled && model.noticesOnly ? " · mode notices-only" : ""} · revision ${model.revision}`,
|
|
274
461
|
latest ? `last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}` : "no requests yet",
|
|
275
462
|
model.budget !== undefined ? `budget ${formatTokenCount(model.budget)}` : undefined,
|
|
276
463
|
model.budgetInfo?.overhead !== undefined ? `fixed overhead ~${formatTokenCount(model.budgetInfo.overhead)}` : undefined,
|
|
@@ -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
|
+
}
|