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,241 @@
1
+ // Host data the TUI panel needs, extracted from plain values so it is testable without
2
+ // opentui: the server plugin's options, the newest provider count, the model limits and
3
+ // the read-only settings rows. Written for this package.
4
+
5
+ import { resolveBudget } from "../budget.ts";
6
+ import type { LatestUsage, PanelModel, PanelSettings, SettingRow } from "../panel/model.ts";
7
+ import { formatTokenCount, type TimelinePoint } from "../panel/timeline.ts";
8
+ import type { ClmSettings } from "../settings.ts";
9
+ import { applyOverrides, changedSettings, sanitizeOverrides, SETTINGS_TABLE, type FormatContext, type SettingsValues } from "../settings-table.ts";
10
+
11
+ /**
12
+ * The package by name, with any version, tag or source after `@` (`opencode-clm`,
13
+ * `opencode-clm@0.2.0`, `opencode-clm@latest`, `opencode-clm@file:/x/opencode-clm-0.2.0.tgz`
14
+ * as `opencode plugin <tarball>` writes it), optionally behind `npm:`.
15
+ */
16
+ const NPM_SPEC = /^(?:npm:)?opencode-clm(?:@.*)?$/;
17
+ /** A path or URL whose last segment names the package (a checkout, an unpacked or packed copy). */
18
+ const PATH_NAME = /(?:^|\/)opencode-clm(?:@[^/]*|-\d[^/]*\.tgz)?\/?$/;
19
+
20
+ /** True when a server plugin spec names this package. */
21
+ export function isPackageSpec(spec: string, packageIndexUrl: string): boolean {
22
+ if (NPM_SPEC.test(spec)) return true;
23
+ // Another package's `name@version-or-source`: the source may be a path, but the name decides.
24
+ if (/^(?:npm:)?(?:@[^/@]+\/)?[^@/.~][^@/]*@/.test(spec)) return false;
25
+ const target = normalizeUrl(packageIndexUrl);
26
+ const packageDirectory = target.replace(/\/index\.ts$/, "");
27
+ const path = spec.startsWith("file:") ? normalizeUrl(spec) : spec.includes("/") ? spec.replace(/\/+$/, "") : undefined;
28
+ if (path === undefined) return false;
29
+ if (path === target || path === packageDirectory) return true;
30
+ return PATH_NAME.test(path) || /(?:^|\/)opencode-clm\/index\.ts$/.test(path);
31
+ }
32
+
33
+ /**
34
+ * Options of the server plugin entry in `api.state.config.plugin` (see `isPackageSpec`).
35
+ * Undefined when no entry matches (the caller falls back to the TUI plugin's options).
36
+ */
37
+ export function serverPluginOptions(entries: unknown, packageIndexUrl: string): Record<string, unknown> | undefined {
38
+ if (!Array.isArray(entries)) return undefined;
39
+ for (const entry of entries) {
40
+ const [spec, options] = Array.isArray(entry) ? entry : [entry, undefined];
41
+ if (typeof spec !== "string") continue;
42
+ if (!isPackageSpec(spec, packageIndexUrl)) continue;
43
+ return options && typeof options === "object" && !Array.isArray(options) ? (options as Record<string, unknown>) : {};
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ function normalizeUrl(value: string): string {
49
+ try {
50
+ return decodeURIComponent(new URL(value).pathname).replace(/\/+$/, "");
51
+ } catch {
52
+ return value;
53
+ }
54
+ }
55
+
56
+ interface MessageLike {
57
+ role?: string;
58
+ time?: { created?: number; completed?: number };
59
+ error?: unknown;
60
+ summary?: boolean;
61
+ tokens?: { input?: number; cache?: { read?: number; write?: number } };
62
+ providerID?: string;
63
+ modelID?: string;
64
+ }
65
+
66
+ /**
67
+ * The newest assistant reply with usage, measured as clm.ts measures `observedPrevious`
68
+ * (input + cache read + cache write). Errored and summary messages are skipped.
69
+ */
70
+ export function latestUsage(messages: ReadonlyArray<unknown>): LatestUsage | undefined {
71
+ for (let index = messages.length - 1; index >= 0; index--) {
72
+ const message = messages[index] as MessageLike;
73
+ if (message?.role !== "assistant" || message.error || message.summary || !message.tokens) continue;
74
+ const tokens = Number(message.tokens.input ?? 0) + Number(message.tokens.cache?.read ?? 0) + Number(message.tokens.cache?.write ?? 0);
75
+ if (!Number.isFinite(tokens) || tokens <= 0) continue;
76
+ return { tokens, ...(message.time?.completed !== undefined ? { completedAt: message.time.completed } : {}) };
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** Context and output limits of the model of the newest assistant message. */
82
+ export function modelLimits(messages: ReadonlyArray<unknown>, providers: ReadonlyArray<unknown>): { context?: number; output?: number } {
83
+ for (let index = messages.length - 1; index >= 0; index--) {
84
+ const message = messages[index] as MessageLike;
85
+ if (message?.role !== "assistant" || !message.providerID || !message.modelID) continue;
86
+ const provider = providers.find((item) => (item as { id?: string })?.id === message.providerID) as
87
+ | { models?: Record<string, { limit?: { context?: number; output?: number } }> }
88
+ | undefined;
89
+ const limit = provider?.models?.[message.modelID]?.limit;
90
+ return { context: limit?.context, output: limit?.output };
91
+ }
92
+ return {};
93
+ }
94
+
95
+ /** Budget for sessions without snapshot.json, resolved as the server resolves it. */
96
+ export function fallbackBudget(settings: ClmSettings, limits: { context?: number; output?: number }) {
97
+ const resolved = resolveBudget(settings.budget, limits.context, limits.output);
98
+ if (!resolved) return undefined;
99
+ const cap = limits.context !== undefined
100
+ ? limits.context - (limits.output !== undefined && limits.output > 0 && limits.output < limits.context ? limits.output : 0)
101
+ : undefined;
102
+ return { budget: resolved.budget, reserve: resolved.reserve, source: resolved.source, ...(cap !== undefined ? { cap } : {}) };
103
+ }
104
+
105
+ /**
106
+ * Settings page rows from the settings table: the values in force (base + this session's
107
+ * overrides.json), marked when changed, with choices or a placeholder so Enter cycles or
108
+ * prompts. `base` is what the session would use without overrides.
109
+ */
110
+ export function settingsView(
111
+ values: { base: SettingsValues; effective: SettingsValues },
112
+ model: Pick<PanelModel, "budgetInfo" | "timeline" | "mirrorPath"> & Partial<Pick<PanelModel, "calibration" | "steering" | "annotations">>,
113
+ options: { format?: FormatContext; warning?: string } = {},
114
+ ): PanelSettings {
115
+ const changed = new Set<string>(changedSettings(values.base, values.effective));
116
+ const rows: SettingRow[] = SETTINGS_TABLE.map((item) => ({
117
+ key: item.name,
118
+ label: item.label,
119
+ value: item.format(values.effective, options.format),
120
+ description: `${item.description} Default: ${item.format(values.base, options.format)}.`,
121
+ ...(changed.has(item.key) ? { changed: true } : {}),
122
+ ...(item.choices ? { choices: item.choices } : {}),
123
+ ...(item.placeholder ? { placeholder: item.placeholder } : {}),
124
+ }));
125
+ const latest = model.timeline.points.at(-1);
126
+ const summary: string[] = [];
127
+ if (latest) summary.push(sizeLine(latest, model.budgetInfo?.budget));
128
+ const info = model.budgetInfo;
129
+ if (info?.overhead !== undefined) summary.push(`Fixed overhead ~${formatTokenCount(info.overhead)} · usable ${formatTokenCount(info.usable ?? 0)}`);
130
+ summary.push(...detailLines(values.effective, model));
131
+ summary.push(`Files mirror ${model.mirrorPath}`);
132
+ return {
133
+ rows,
134
+ summary,
135
+ changed: SETTINGS_TABLE.filter((item) => changed.has(item.key)).map((item) => item.name),
136
+ ...(options.warning ? { warning: options.warning } : {}),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * pi's settings-page Size line ("next request ~E of B · last request M (provider count)").
142
+ * pi's "next request" is the estimate of the request built last, here the newest request.
143
+ * The line shows its provider count and the logged estimate side by side. The budget is
144
+ * today's, so after a budget change it is off until the next request.
145
+ */
146
+ export function sizeLine(latest: Pick<TimelinePoint, "tokens" | "measured" | "estimated">, budget?: number): string {
147
+ const of = budget !== undefined ? ` of ${formatTokenCount(budget)}` : "";
148
+ if (!latest.measured) return `Size last request ~${formatTokenCount(latest.tokens)}${of} (estimate)`;
149
+ const estimate = latest.estimated !== undefined ? ` · estimated ~${formatTokenCount(latest.estimated)}` : "";
150
+ return `Size last request ${formatTokenCount(latest.tokens)}${of} (provider count)${estimate}`;
151
+ }
152
+
153
+ /**
154
+ * Footer text in the session prompt's right slot, as pi's status line:
155
+ * `clm 12k / 32k · r2` (newest size against the budget in force), `clm off · r2`, or
156
+ * undefined before the session has CLM data.
157
+ */
158
+ export function footerText(model: Pick<PanelModel, "found" | "enabled" | "revision" | "timeline" | "budget">): string | undefined {
159
+ if (!model.found) return undefined;
160
+ if (!model.enabled) return `clm off · r${model.revision}`;
161
+ const latest = model.timeline.points.at(-1);
162
+ const size = latest === undefined
163
+ ? ""
164
+ : ` ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}${model.budget !== undefined ? ` / ${formatTokenCount(model.budget)}` : ""}`;
165
+ return `clm${size} · r${model.revision}`;
166
+ }
167
+
168
+ /** Key of the settings page's "Reset to defaults" row, and the choice that resets. */
169
+ export const RESET_ROW = "reset";
170
+ export const RESET_NOW = "reset now";
171
+
172
+ /**
173
+ * pi's "Reset to defaults" row: Enter cycles to "reset now", which drops this session's
174
+ * changes (the same as `/clm config reset`). With nothing changed Enter does nothing.
175
+ */
176
+ export function resetRow(changed: number): SettingRow {
177
+ const value = changed === 0 ? "nothing changed" : `${changed} changed`;
178
+ return {
179
+ key: RESET_ROW,
180
+ label: "Reset to defaults",
181
+ value,
182
+ description: "Drop this session's changes; the plugin options, environment and defaults apply again from the next request. Same as /clm config reset. Enter on \"reset now\" resets at once.",
183
+ choices: changed === 0 ? [value] : [value, RESET_NOW],
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Settings-page details after the size lines (pi's settings summary): calibration, the
189
+ * overflow guard's limit, the steering document with its hash, annotation counts.
190
+ */
191
+ export function detailLines(
192
+ effective: SettingsValues,
193
+ model: Pick<PanelModel, "budgetInfo"> & Partial<Pick<PanelModel, "calibration" | "steering" | "annotations">>,
194
+ ): string[] {
195
+ const lines: string[] = [];
196
+ const calibration = model.calibration;
197
+ if (calibration) {
198
+ lines.push(calibration.samples === 0
199
+ ? "Estimate not calibrated yet (characters ÷ 4 until the provider reports a size)"
200
+ : `Estimate ×${calibration.factor.toFixed(2)}, calibrated from ${calibration.samples} provider count${calibration.samples === 1 ? "" : "s"}`);
201
+ }
202
+ const limit = model.budgetInfo?.limit;
203
+ lines.push(effective.settings.guard === "off"
204
+ ? "Guard off"
205
+ : `Guard withholds the oldest tool results above ${limit === undefined ? "budget − reserve" : formatTokenCount(limit)}`);
206
+ // pi appends the native-compaction state; `auto` and `on` leave OpenCode's own config.
207
+ if (effective.settings.compaction === "off") lines[lines.length - 1] += " · OpenCode's automatic compaction off";
208
+ // The hash prefix identifies an experiment arm (compare with sha256sum).
209
+ if (model.steering) lines.push(`Steering ${model.steering.name} (sha256 ${model.steering.hash}…)`);
210
+ const counts = model.annotations;
211
+ if (counts) {
212
+ lines.push(counts.total === 0
213
+ ? "Annotations none"
214
+ : `Annotations ${counts.active} continuity/pin · ${counts.archived} archive · ${counts.total} total`);
215
+ }
216
+ return lines;
217
+ }
218
+
219
+ /**
220
+ * The server's base settings, from snapshot.json `base` (the server writes it each
221
+ * request), applied over the TUI's own resolution so non-table settings (mirrorDir…) stay.
222
+ * `tui` when there is no usable snapshot base yet.
223
+ */
224
+ export function serverBase(own: ClmSettings, snapshot: unknown): { base: ClmSettings; source: "server" | "tui" } {
225
+ const raw = snapshot && typeof snapshot === "object" ? (snapshot as { base?: unknown }).base : undefined;
226
+ if (!raw || typeof raw !== "object") return { base: own, source: "tui" };
227
+ const { overrides } = sanitizeOverrides(raw);
228
+ try {
229
+ return { base: applyOverrides(own, overrides), source: "server" };
230
+ } catch {
231
+ return { base: own, source: "tui" };
232
+ }
233
+ }
234
+
235
+ /** True when overrides.json (mtime, ms) is newer than snapshot.json's `at`, or there is no snapshot. */
236
+ export function overridesNewer(overridesAt: number | undefined, snapshot: unknown): boolean {
237
+ if (overridesAt === undefined) return false;
238
+ const at = snapshot && typeof snapshot === "object" ? (snapshot as { at?: unknown }).at : undefined;
239
+ const snapshotAt = typeof at === "string" ? Date.parse(at) : Number.NaN;
240
+ return !Number.isFinite(snapshotAt) || overridesAt > snapshotAt;
241
+ }
@@ -0,0 +1,60 @@
1
+ // The TUI's Enter intercept for typed `/clm …` lines: handled in the TUI with no model turn.
2
+ // `/clm reset` (the server owns state.json) is consumed only when `serverChannel` says the
3
+ // TUI can hand it to the server plugin without a turn (src/channel.ts). Host-free so it can be unit-tested with
4
+ // a fake key context and focused prompt. Written for this package.
5
+
6
+ import { parseClmCommand, type ClmCommand } from "../panel/command.ts";
7
+
8
+ /** The parts of the host's key-intercept context this uses. */
9
+ export interface InterceptContext {
10
+ event: { name?: string; shift?: boolean; ctrl?: boolean; meta?: boolean };
11
+ consume(): void;
12
+ }
13
+
14
+ /** The focused prompt: host internals (textarea `plainText` / `setText`), checked at runtime. */
15
+ export interface FocusedPrompt {
16
+ plainText?: unknown;
17
+ setText?: (text: string) => void;
18
+ }
19
+
20
+ export interface InterceptDeps {
21
+ focused(): FocusedPrompt | null | undefined;
22
+ /** False when `/clm` is not this package's to handle (`commands: false`, or a user-defined `/clm`). */
23
+ owned(): boolean;
24
+ /** Runs a command the TUI handles (a `server` one only when `serverChannel()` is true). */
25
+ handle(command: ClmCommand): Promise<void>;
26
+ /** True when `server` commands (`/clm reset`) go over the turn-free channel; absent = pass through. */
27
+ serverChannel?(): boolean;
28
+ /** Shows an error from `handle`. */
29
+ report(message: string): void;
30
+ }
31
+
32
+ /**
33
+ * Called for every key. Consumes Enter (no modifiers) on a `/clm …` prompt line the TUI
34
+ * handles; everything else passes through untouched: other text, `/clm-compact`, `/clm
35
+ * reset`, modified Enter, a prompt without the expected internals, and every `/clm` line
36
+ * when the command belongs to someone else. A usage error keeps the typed text so it can be
37
+ * fixed. With `serverChannel`, `/clm reset` is consumed as well. Returns the command handled, for tests.
38
+ */
39
+ export function interceptEnter(context: InterceptContext, deps: InterceptDeps): ClmCommand | undefined {
40
+ const event = context.event;
41
+ if (event.name !== "return" || event.shift || event.ctrl || event.meta) return undefined;
42
+ const focused = deps.focused();
43
+ if (!focused || typeof focused.plainText !== "string" || typeof focused.setText !== "function") return undefined;
44
+ const command = parseClmCommand(focused.plainText);
45
+ if (!command) return undefined;
46
+ if (command.kind === "server" && !deps.serverChannel?.()) return undefined;
47
+ // A throw here would land in the host's key handling: fail open, let the line through.
48
+ let owned: boolean;
49
+ try {
50
+ owned = deps.owned();
51
+ } catch (error) {
52
+ deps.report(`CLM: ${error instanceof Error ? error.message : String(error)}`);
53
+ return undefined;
54
+ }
55
+ if (!owned) return undefined;
56
+ context.consume();
57
+ if (command.kind !== "usage") focused.setText("");
58
+ void Promise.resolve().then(() => deps.handle(command)).catch((error: unknown) => deps.report(`CLM: ${error instanceof Error ? error.message : String(error)}`));
59
+ return command;
60
+ }
@@ -0,0 +1,101 @@
1
+ // TUI-side helpers around the channel (src/channel.ts) that need no opentui: the per-session
2
+ // `locate` cache and `/clm reset` with its fallback. Written for this package.
3
+
4
+ import { ChannelTimeout, LOCATE_TIMEOUT_MS, type ChannelOperation, type ChannelReply } from "../channel.ts";
5
+
6
+ export interface Location {
7
+ directory: string;
8
+ root: string;
9
+ }
10
+
11
+ /** How long a failed `locate` (no answering server plugin) is remembered. */
12
+ export const LOCATE_RETRY_MS = 60_000;
13
+ /** How long a `locate` answer is trusted (a restarted server may use another mirrorDir). */
14
+ export const LOCATE_TTL_MS = 5 * 60_000;
15
+
16
+ export interface Locator {
17
+ /** The cached answer, or a new `locate` (short timeout); undefined when the server did not answer. */
18
+ locate(sessionID: string): Promise<Location | undefined>;
19
+ /** The cached answer if still fresh, without asking. */
20
+ fresh(sessionID: string): Location | undefined;
21
+ /** Drop the cached answer (reading through it failed). */
22
+ forget(sessionID: string): void;
23
+ /** `locate` in the background, once at a time per session; `onAnswer` gets a new answer. */
24
+ relocate(sessionID: string, onAnswer: (location: Location) => void): void;
25
+ }
26
+
27
+ export function createLocator(deps: {
28
+ ask(sessionID: string, operation: ChannelOperation, timeoutMs?: number): Promise<ChannelReply>;
29
+ now?: () => number;
30
+ ttlMs?: number;
31
+ retryMs?: number;
32
+ }): Locator {
33
+ const now = deps.now ?? Date.now;
34
+ const ttl = deps.ttlMs ?? LOCATE_TTL_MS;
35
+ const retry = deps.retryMs ?? LOCATE_RETRY_MS;
36
+ const cache = new Map<string, { at: number; value?: Location }>();
37
+ const running = new Set<string>();
38
+ const entry = (sessionID: string) => {
39
+ const known = cache.get(sessionID);
40
+ if (!known) return undefined;
41
+ return now() - known.at < (known.value ? ttl : retry) ? known : undefined;
42
+ };
43
+ const locate = async (sessionID: string): Promise<Location | undefined> => {
44
+ const known = entry(sessionID);
45
+ if (known) return known.value;
46
+ let value: Location | undefined;
47
+ try {
48
+ const reply = await deps.ask(sessionID, { op: "locate" }, LOCATE_TIMEOUT_MS);
49
+ if (reply.directory && reply.root) value = { directory: reply.directory, root: reply.root };
50
+ } catch {
51
+ value = undefined;
52
+ }
53
+ cache.set(sessionID, { at: now(), ...(value ? { value } : {}) });
54
+ return value;
55
+ };
56
+ return {
57
+ locate,
58
+ fresh: (sessionID) => entry(sessionID)?.value,
59
+ forget: (sessionID) => {
60
+ cache.delete(sessionID);
61
+ },
62
+ relocate(sessionID, onAnswer) {
63
+ if (running.has(sessionID)) return;
64
+ running.add(sessionID);
65
+ void locate(sessionID)
66
+ .then((value) => {
67
+ if (value) onAnswer(value);
68
+ })
69
+ .finally(() => running.delete(sessionID));
70
+ },
71
+ };
72
+ }
73
+
74
+ /**
75
+ * `/clm reset` from the TUI: over the channel (no model turn). When the server plugin does not
76
+ * answer (not loaded there, an older version, or CLM disabled), fall back to the server
77
+ * command, as before the channel: it costs one model turn, and the toast says so.
78
+ */
79
+ export async function resetSession(deps: {
80
+ sessionID: string;
81
+ ask(sessionID: string, operation: ChannelOperation): Promise<ChannelReply>;
82
+ /** `api.client.session.command`, flat v2 parameters. */
83
+ command(parameters: { sessionID: string; command: string; arguments: string }): Promise<{ error?: unknown } | undefined>;
84
+ toast(message: string, variant?: "info" | "warning"): void;
85
+ }): Promise<"channel" | "command"> {
86
+ try {
87
+ await deps.ask(deps.sessionID, { op: "reset" });
88
+ return "channel";
89
+ } catch (error) {
90
+ if (!(error instanceof ChannelTimeout)) throw error;
91
+ deps.toast(`${error.message}. Sent /clm reset as a command instead; it costs one model turn.`, "warning");
92
+ // The command runs a model turn; do not wait for it.
93
+ void Promise.resolve()
94
+ .then(() => deps.command({ sessionID: deps.sessionID, command: "clm", arguments: "reset" }))
95
+ .then((result) => {
96
+ if (result?.error !== undefined) deps.toast(`/clm reset failed: ${JSON.stringify(result.error)}`, "warning");
97
+ })
98
+ .catch((failure: unknown) => deps.toast(`/clm reset failed: ${failure instanceof Error ? failure.message : String(failure)}`, "warning"));
99
+ return "command";
100
+ }
101
+ }
@@ -0,0 +1,205 @@
1
+ // opentui adapter for the `/clm` panel: one Text renderable holding the frame as
2
+ // StyledText, a key layer in its own mode, and the effects of the pure reducer (close,
3
+ // reload, settings prompt/apply). Everything else is in src/panel/*. Written for this package.
4
+ //
5
+ // `@opentui/core` must stay a static ESM import: the host rewrites it to its own instance.
6
+
7
+ import { BoxRenderable, RGBA, StyledText, TextAttributes, TextRenderable } from "@opentui/core";
8
+ import type { TextChunk } from "@opentui/core";
9
+ import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
10
+
11
+ import type { Page } from "../panel/command.ts";
12
+ import { reduce, type Effect, type Key } from "../panel/keys.ts";
13
+ import type { Line, Tone } from "../panel/lines.ts";
14
+ import type { PanelModel } from "../panel/model.ts";
15
+ import { initialPanelState, renderPanel, type PanelSize, type PanelState } from "../panel/view.ts";
16
+
17
+ export const PANEL_MODE = "opencode-clm.panel";
18
+
19
+ /** Host key names (opentui keymap) → panel keys. */
20
+ export const KEY_BINDINGS: ReadonlyArray<readonly [string, Key]> = [
21
+ ["1", "1"], ["2", "2"], ["3", "3"], ["4", "4"],
22
+ ["tab", "tab"], ["shift+tab", "shift+tab"],
23
+ ["left", "left"], ["right", "right"], ["up", "up"], ["down", "down"],
24
+ ["pageup", "pageup"], ["pagedown", "pagedown"],
25
+ ["home", "home"], ["end", "end"], ["g", "g"], ["shift+g", "G"],
26
+ ["return", "enter"], ["space", "space"],
27
+ ["a", "a"], ["z", "z"], ["r", "r"], ["q", "q"], ["escape", "escape"], ["ctrl+c", "q"],
28
+ ["j", "j"], ["k", "k"], ["[", "["], ["]", "]"],
29
+ ];
30
+
31
+ type ToneStyle = { color: keyof TuiThemeCurrent; attributes?: number };
32
+
33
+ const TONES: Record<Tone, ToneStyle> = {
34
+ text: { color: "text" },
35
+ muted: { color: "textMuted" },
36
+ dim: { color: "textMuted", attributes: TextAttributes.DIM },
37
+ accent: { color: "accent" },
38
+ edit: { color: "markdownLink" },
39
+ warning: { color: "warning" },
40
+ error: { color: "error" },
41
+ success: { color: "success" },
42
+ diffAdd: { color: "diffAdded" },
43
+ diffDel: { color: "diffRemoved" },
44
+ diffAddEm: { color: "diffHighlightAdded", attributes: TextAttributes.BOLD },
45
+ diffDelEm: { color: "diffHighlightRemoved", attributes: TextAttributes.BOLD },
46
+ border: { color: "border" },
47
+ };
48
+
49
+ /** Lines → one StyledText, tones resolved against the current theme. */
50
+ export function toStyledText(lines: readonly Line[], theme: TuiThemeCurrent): StyledText {
51
+ const chunks: TextChunk[] = [];
52
+ lines.forEach((line, index) => {
53
+ if (index > 0) chunks.push({ __isChunk: true, text: "\n" });
54
+ for (const part of line) {
55
+ const style = TONES[part.tone ?? "text"];
56
+ const color = theme[style.color];
57
+ const attributes = (style.attributes ?? 0) | (part.bold ? TextAttributes.BOLD : 0);
58
+ chunks.push({
59
+ __isChunk: true,
60
+ text: part.text,
61
+ ...(color instanceof RGBA ? { fg: color } : {}),
62
+ ...(attributes ? { attributes } : {}),
63
+ });
64
+ }
65
+ });
66
+ return new StyledText(chunks);
67
+ }
68
+
69
+ /** A one-line text renderable for a host slot (the footer), restyled on each `set`. */
70
+ export function createFooter(api: Pick<TuiPluginApi, "renderer" | "theme">, id: string): { renderable: TextRenderable; set(text: string | undefined): void } {
71
+ const renderable = new TextRenderable(api.renderer, { id, content: "", wrapMode: "none" });
72
+ return {
73
+ renderable,
74
+ set(text) {
75
+ renderable.content = toStyledText(text ? [[{ text, tone: "muted" }]] : [[]], api.theme.current);
76
+ },
77
+ };
78
+ }
79
+
80
+ export interface PanelOptions {
81
+ page: Page;
82
+ /** Reads the session files and builds the model. Never rejects with a broken panel: errors become a model. */
83
+ load: () => Promise<PanelModel>;
84
+ /** Applies a setting; resolves to an error message, or undefined on success. */
85
+ apply: (setting: string, value: string) => Promise<string | undefined>;
86
+ /** Called once after the panel released its key layer and mode (q, Esc, ctrl+c). */
87
+ onClose: () => void;
88
+ /** Called once when the panel is disposed for any reason, including the host unmounting the route. */
89
+ onDispose?: () => void;
90
+ }
91
+
92
+ export interface PanelController {
93
+ readonly renderable: BoxRenderable;
94
+ dispatch(key: Key): void;
95
+ reload(): Promise<void>;
96
+ setPage(page: Page): void;
97
+ /** Current state and model, for tests. */
98
+ snapshot(): { state: PanelState; model: PanelModel | undefined };
99
+ dispose(): void;
100
+ }
101
+
102
+ type PanelApi = Pick<TuiPluginApi, "renderer" | "theme" | "keymap" | "mode" | "ui">;
103
+
104
+ export function createPanel(api: PanelApi, options: PanelOptions): PanelController {
105
+ let state = initialPanelState(options.page);
106
+ let model: PanelModel | undefined;
107
+ let disposed = false;
108
+ const box = new BoxRenderable(api.renderer, { id: "opencode-clm-panel", width: "100%", height: "100%", flexDirection: "column" });
109
+ const text = new TextRenderable(api.renderer, { id: "opencode-clm-panel-text", content: "Loading CLM panel…", wrapMode: "none", width: "100%", height: "100%" });
110
+ box.add(text);
111
+
112
+ const size = (): PanelSize => ({
113
+ width: Math.max(20, box.width || api.renderer.width),
114
+ height: Math.max(10, box.height || api.renderer.height),
115
+ });
116
+ const render = () => {
117
+ if (disposed || !model) return;
118
+ text.content = toStyledText(renderPanel(model, state, size()), api.theme.current);
119
+ };
120
+ box.onSizeChange = render;
121
+ // Leaving the route any other way (another plugin's navigate, a session event) removes or
122
+ // destroys the box: release the key mode then, or the prompt's base-mode keys stay dead.
123
+ const unmounted = () => controller.dispose();
124
+ (box as unknown as { onRemove: () => void }).onRemove = unmounted;
125
+ (box as unknown as { on?: (event: string, fn: () => void) => void }).on?.("destroyed", unmounted);
126
+
127
+ const popMode = api.mode.push(PANEL_MODE);
128
+ const dropLayer = api.keymap.registerLayer({
129
+ mode: PANEL_MODE,
130
+ bindings: KEY_BINDINGS.map(([key, panelKey]) => ({ key, desc: `CLM panel: ${panelKey}`, cmd: () => controller.dispatch(panelKey) })),
131
+ });
132
+
133
+ const perform = (effect: Effect | undefined) => {
134
+ if (!effect) return;
135
+ switch (effect.kind) {
136
+ case "close":
137
+ controller.dispose();
138
+ options.onClose();
139
+ return;
140
+ case "reload":
141
+ void controller.reload();
142
+ return;
143
+ case "apply":
144
+ void applySetting(effect.setting, effect.value);
145
+ return;
146
+ case "prompt":
147
+ api.ui.dialog.replace(() =>
148
+ api.ui.DialogPrompt({
149
+ title: `${effect.setting} · currently ${effect.current}`,
150
+ ...(effect.placeholder ? { placeholder: effect.placeholder } : {}),
151
+ value: effect.current,
152
+ onConfirm: (value: string) => {
153
+ api.ui.dialog.clear();
154
+ if (value.trim() !== "") void applySetting(effect.setting, value.trim());
155
+ },
156
+ onCancel: () => api.ui.dialog.clear(),
157
+ }),
158
+ );
159
+ return;
160
+ }
161
+ };
162
+
163
+ const applySetting = async (setting: string, value: string) => {
164
+ const error = await options.apply(setting, value).catch((cause: unknown) => (cause instanceof Error ? cause.message : String(cause)));
165
+ await controller.reload();
166
+ const row = model?.settings.rows.find((candidate) => candidate.key === setting);
167
+ const shown = `${row?.label ?? setting}: ${row?.value ?? value}`;
168
+ state = { ...state, message: error ? { text: error, warning: true } : { text: shown, warning: false } };
169
+ render();
170
+ };
171
+
172
+ const controller: PanelController = {
173
+ renderable: box,
174
+ dispatch(key) {
175
+ if (disposed || !model) {
176
+ if (key === "q" || key === "escape") perform({ kind: "close" });
177
+ return;
178
+ }
179
+ const result = reduce(state, key, model, size());
180
+ state = result.state;
181
+ render();
182
+ perform(result.effect);
183
+ },
184
+ async reload() {
185
+ const next = await options.load();
186
+ if (disposed) return;
187
+ model = next;
188
+ render();
189
+ },
190
+ setPage(page) {
191
+ state = { ...state, page, scroll: 0 };
192
+ render();
193
+ },
194
+ snapshot: () => ({ state, model }),
195
+ dispose() {
196
+ if (disposed) return;
197
+ disposed = true;
198
+ dropLayer();
199
+ popMode();
200
+ options.onDispose?.();
201
+ },
202
+ };
203
+ void controller.reload();
204
+ return controller;
205
+ }