opencode-clm 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +9 -1
- package/README.md +85 -258
- package/index.ts +68 -14
- package/package.json +54 -9
- package/src/atomic.ts +27 -0
- package/src/budget.ts +129 -5
- package/src/clm.ts +374 -20
- package/src/commands.ts +18 -0
- package/src/context-document.ts +31 -4
- package/src/mirror-store.ts +1 -1
- package/src/overrides.ts +193 -0
- package/src/panel/command.ts +69 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +103 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +547 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +454 -0
- package/src/panel/view.ts +545 -0
- package/src/presentation.ts +10 -1
- package/src/session-files.ts +184 -0
- package/src/settings-table.ts +406 -0
- package/src/settings.ts +2 -2
- package/src/state.ts +49 -2
- package/src/tui/data.ts +161 -0
- package/src/tui/intercept.ts +56 -0
- package/src/tui/panel.ts +194 -0
- package/src/tui/plugin.ts +280 -0
- package/tui.ts +7 -0
package/src/state.ts
CHANGED
|
@@ -24,6 +24,24 @@ export interface LiveContextOutcome {
|
|
|
24
24
|
at: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The once-per-session measurement of OpenCode's fixed overhead (system prompt and tool
|
|
29
|
+
* schemas). Its presence means the budget-too-small check has run; the session then derives
|
|
30
|
+
* its effective budget from `overhead` (clm.ts), so a raise survives a restart.
|
|
31
|
+
*/
|
|
32
|
+
export interface BudgetCheck {
|
|
33
|
+
/** Fixed overhead per request, tokens. */
|
|
34
|
+
overhead: number;
|
|
35
|
+
/** `provider`: the provider's count minus the conversation estimate; `estimate`: chars/4 sizes. */
|
|
36
|
+
source: "provider" | "estimate";
|
|
37
|
+
/** Budget and reserve in force when the check ran, and what it decided. */
|
|
38
|
+
configured: number;
|
|
39
|
+
reserve: number;
|
|
40
|
+
effective: number;
|
|
41
|
+
raised: boolean;
|
|
42
|
+
at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
export interface LiveContextState {
|
|
28
46
|
version: 1;
|
|
29
47
|
enabled: boolean;
|
|
@@ -31,12 +49,15 @@ export interface LiveContextState {
|
|
|
31
49
|
revision: number;
|
|
32
50
|
checkpoint?: ProjectionCheckpoint;
|
|
33
51
|
lastOutcome?: LiveContextOutcome;
|
|
52
|
+
budgetCheck?: BudgetCheck;
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
export interface LoadedState {
|
|
37
56
|
state: LiveContextState;
|
|
38
57
|
/** Set when a state file existed but could not be used; the state is then initial. */
|
|
39
58
|
warning?: string;
|
|
59
|
+
/** Set when an invalid optional field was dropped and the rest of the state kept. */
|
|
60
|
+
repaired?: string;
|
|
40
61
|
}
|
|
41
62
|
|
|
42
63
|
const SOURCE_KINDS = new Set(["kept", "edited", "removed", "restored", "normalized"]);
|
|
@@ -137,6 +158,24 @@ function isOutcome(value: unknown): value is LiveContextOutcome {
|
|
|
137
158
|
);
|
|
138
159
|
}
|
|
139
160
|
|
|
161
|
+
function isBudgetCheck(value: unknown): value is BudgetCheck {
|
|
162
|
+
if (!isObject(value)) return false;
|
|
163
|
+
return (
|
|
164
|
+
isCount(value.overhead) &&
|
|
165
|
+
(value.source === "provider" || value.source === "estimate") &&
|
|
166
|
+
isCount(value.configured) &&
|
|
167
|
+
isCount(value.reserve) &&
|
|
168
|
+
isCount(value.effective) &&
|
|
169
|
+
typeof value.raised === "boolean" &&
|
|
170
|
+
typeof value.at === "string"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The fields every state rewrite carries over (revision changes keep the budget check). */
|
|
175
|
+
export function keptFields(state: LiveContextState): Pick<LiveContextState, "budgetCheck"> {
|
|
176
|
+
return state.budgetCheck ? { budgetCheck: state.budgetCheck } : {};
|
|
177
|
+
}
|
|
178
|
+
|
|
140
179
|
export function isLiveContextState(value: unknown): value is LiveContextState {
|
|
141
180
|
if (!isObject(value)) return false;
|
|
142
181
|
const state = value as Partial<LiveContextState>;
|
|
@@ -147,7 +186,8 @@ export function isLiveContextState(value: unknown): value is LiveContextState {
|
|
|
147
186
|
// Accept writes the checkpoint and the state with the same revision; reset moves past it.
|
|
148
187
|
(state.checkpoint === undefined ||
|
|
149
188
|
(isProjectionCheckpoint(state.checkpoint) && state.checkpoint.revision === state.revision)) &&
|
|
150
|
-
(state.lastOutcome === undefined || isOutcome(state.lastOutcome))
|
|
189
|
+
(state.lastOutcome === undefined || isOutcome(state.lastOutcome)) &&
|
|
190
|
+
(state.budgetCheck === undefined || isBudgetCheck(state.budgetCheck))
|
|
151
191
|
);
|
|
152
192
|
}
|
|
153
193
|
|
|
@@ -162,6 +202,7 @@ export function resetProjectionState(
|
|
|
162
202
|
enabled: state.enabled,
|
|
163
203
|
revision: state.revision + 1,
|
|
164
204
|
lastOutcome: { kind: "reset", message, at },
|
|
205
|
+
...keptFields(state),
|
|
165
206
|
};
|
|
166
207
|
}
|
|
167
208
|
|
|
@@ -191,10 +232,16 @@ export async function loadLiveContextState(directory: string): Promise<LoadedSta
|
|
|
191
232
|
warning: `${path} has unsupported version ${JSON.stringify(parsed.version)}; starting clean.`,
|
|
192
233
|
};
|
|
193
234
|
}
|
|
235
|
+
// A malformed budget check costs only itself: the session re-measures the overhead.
|
|
236
|
+
let repaired: string | undefined;
|
|
237
|
+
if (isObject(parsed) && parsed.budgetCheck !== undefined && !isBudgetCheck(parsed.budgetCheck)) {
|
|
238
|
+
delete parsed.budgetCheck;
|
|
239
|
+
repaired = `${path} had an invalid budgetCheck; dropped it, the fixed overhead is measured again.`;
|
|
240
|
+
}
|
|
194
241
|
if (!isLiveContextState(parsed)) {
|
|
195
242
|
return { state: initialLiveContextState(), warning: `${path} has an invalid shape; starting clean.` };
|
|
196
243
|
}
|
|
197
|
-
return { state: parsed };
|
|
244
|
+
return repaired ? { state: parsed, repaired } : { state: parsed };
|
|
198
245
|
}
|
|
199
246
|
|
|
200
247
|
/**
|
package/src/tui/data.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
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 } 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">,
|
|
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(`Size last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}${latest.measured ? " (provider count)" : " (estimate)"}`);
|
|
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(`Files mirror ${model.mirrorPath}`);
|
|
131
|
+
return {
|
|
132
|
+
rows,
|
|
133
|
+
summary,
|
|
134
|
+
changed: SETTINGS_TABLE.filter((item) => changed.has(item.key)).map((item) => item.name),
|
|
135
|
+
...(options.warning ? { warning: options.warning } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The server's base settings, from snapshot.json `base` (the server writes it each
|
|
141
|
+
* request), applied over the TUI's own resolution so non-table settings (mirrorDir…) stay.
|
|
142
|
+
* `tui` when there is no usable snapshot base yet.
|
|
143
|
+
*/
|
|
144
|
+
export function serverBase(own: ClmSettings, snapshot: unknown): { base: ClmSettings; source: "server" | "tui" } {
|
|
145
|
+
const raw = snapshot && typeof snapshot === "object" ? (snapshot as { base?: unknown }).base : undefined;
|
|
146
|
+
if (!raw || typeof raw !== "object") return { base: own, source: "tui" };
|
|
147
|
+
const { overrides } = sanitizeOverrides(raw);
|
|
148
|
+
try {
|
|
149
|
+
return { base: applyOverrides(own, overrides), source: "server" };
|
|
150
|
+
} catch {
|
|
151
|
+
return { base: own, source: "tui" };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** True when overrides.json (mtime, ms) is newer than snapshot.json's `at`, or there is no snapshot. */
|
|
156
|
+
export function overridesNewer(overridesAt: number | undefined, snapshot: unknown): boolean {
|
|
157
|
+
if (overridesAt === undefined) return false;
|
|
158
|
+
const at = snapshot && typeof snapshot === "object" ? (snapshot as { at?: unknown }).at : undefined;
|
|
159
|
+
const snapshotAt = typeof at === "string" ? Date.parse(at) : Number.NaN;
|
|
160
|
+
return !Number.isFinite(snapshotAt) || overridesAt > snapshotAt;
|
|
161
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The TUI's Enter intercept for typed `/clm …` lines: handled in the TUI with no model turn,
|
|
2
|
+
// except `/clm reset` (the server owns state.json). Host-free so it can be unit-tested with
|
|
3
|
+
// a fake key context and focused prompt. Written for this package.
|
|
4
|
+
|
|
5
|
+
import { parseClmCommand, type ClmCommand } from "../panel/command.ts";
|
|
6
|
+
|
|
7
|
+
/** The parts of the host's key-intercept context this uses. */
|
|
8
|
+
export interface InterceptContext {
|
|
9
|
+
event: { name?: string; shift?: boolean; ctrl?: boolean; meta?: boolean };
|
|
10
|
+
consume(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The focused prompt: host internals (textarea `plainText` / `setText`), checked at runtime. */
|
|
14
|
+
export interface FocusedPrompt {
|
|
15
|
+
plainText?: unknown;
|
|
16
|
+
setText?: (text: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InterceptDeps {
|
|
20
|
+
focused(): FocusedPrompt | null | undefined;
|
|
21
|
+
/** False when `/clm` is not this package's to handle (`commands: false`, or a user-defined `/clm`). */
|
|
22
|
+
owned(): boolean;
|
|
23
|
+
/** Runs a command the TUI handles. */
|
|
24
|
+
handle(command: Exclude<ClmCommand, { kind: "server" }>): Promise<void>;
|
|
25
|
+
/** Shows an error from `handle`. */
|
|
26
|
+
report(message: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Called for every key. Consumes Enter (no modifiers) on a `/clm …` prompt line the TUI
|
|
31
|
+
* handles; everything else passes through untouched: other text, `/clm-compact`, `/clm
|
|
32
|
+
* reset`, modified Enter, a prompt without the expected internals, and every `/clm` line
|
|
33
|
+
* when the command belongs to someone else. A usage error keeps the typed text so it can be
|
|
34
|
+
* fixed. Returns the command handled, for tests.
|
|
35
|
+
*/
|
|
36
|
+
export function interceptEnter(context: InterceptContext, deps: InterceptDeps): ClmCommand | undefined {
|
|
37
|
+
const event = context.event;
|
|
38
|
+
if (event.name !== "return" || event.shift || event.ctrl || event.meta) return undefined;
|
|
39
|
+
const focused = deps.focused();
|
|
40
|
+
if (!focused || typeof focused.plainText !== "string" || typeof focused.setText !== "function") return undefined;
|
|
41
|
+
const command = parseClmCommand(focused.plainText);
|
|
42
|
+
if (!command || command.kind === "server") return undefined;
|
|
43
|
+
// A throw here would land in the host's key handling: fail open, let the line through.
|
|
44
|
+
let owned: boolean;
|
|
45
|
+
try {
|
|
46
|
+
owned = deps.owned();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
deps.report(`CLM: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (!owned) return undefined;
|
|
52
|
+
context.consume();
|
|
53
|
+
if (command.kind !== "usage") focused.setText("");
|
|
54
|
+
void Promise.resolve().then(() => deps.handle(command)).catch((error: unknown) => deps.report(`CLM: ${error instanceof Error ? error.message : String(error)}`));
|
|
55
|
+
return command;
|
|
56
|
+
}
|
package/src/tui/panel.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
export interface PanelOptions {
|
|
70
|
+
page: Page;
|
|
71
|
+
/** Reads the session files and builds the model. Never rejects with a broken panel: errors become a model. */
|
|
72
|
+
load: () => Promise<PanelModel>;
|
|
73
|
+
/** Applies a setting; resolves to an error message, or undefined on success. */
|
|
74
|
+
apply: (setting: string, value: string) => Promise<string | undefined>;
|
|
75
|
+
/** Called once after the panel released its key layer and mode (q, Esc, ctrl+c). */
|
|
76
|
+
onClose: () => void;
|
|
77
|
+
/** Called once when the panel is disposed for any reason, including the host unmounting the route. */
|
|
78
|
+
onDispose?: () => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PanelController {
|
|
82
|
+
readonly renderable: BoxRenderable;
|
|
83
|
+
dispatch(key: Key): void;
|
|
84
|
+
reload(): Promise<void>;
|
|
85
|
+
setPage(page: Page): void;
|
|
86
|
+
/** Current state and model, for tests. */
|
|
87
|
+
snapshot(): { state: PanelState; model: PanelModel | undefined };
|
|
88
|
+
dispose(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type PanelApi = Pick<TuiPluginApi, "renderer" | "theme" | "keymap" | "mode" | "ui">;
|
|
92
|
+
|
|
93
|
+
export function createPanel(api: PanelApi, options: PanelOptions): PanelController {
|
|
94
|
+
let state = initialPanelState(options.page);
|
|
95
|
+
let model: PanelModel | undefined;
|
|
96
|
+
let disposed = false;
|
|
97
|
+
const box = new BoxRenderable(api.renderer, { id: "opencode-clm-panel", width: "100%", height: "100%", flexDirection: "column" });
|
|
98
|
+
const text = new TextRenderable(api.renderer, { id: "opencode-clm-panel-text", content: "Loading CLM panel…", wrapMode: "none", width: "100%", height: "100%" });
|
|
99
|
+
box.add(text);
|
|
100
|
+
|
|
101
|
+
const size = (): PanelSize => ({
|
|
102
|
+
width: Math.max(20, box.width || api.renderer.width),
|
|
103
|
+
height: Math.max(10, box.height || api.renderer.height),
|
|
104
|
+
});
|
|
105
|
+
const render = () => {
|
|
106
|
+
if (disposed || !model) return;
|
|
107
|
+
text.content = toStyledText(renderPanel(model, state, size()), api.theme.current);
|
|
108
|
+
};
|
|
109
|
+
box.onSizeChange = render;
|
|
110
|
+
// Leaving the route any other way (another plugin's navigate, a session event) removes or
|
|
111
|
+
// destroys the box: release the key mode then, or the prompt's base-mode keys stay dead.
|
|
112
|
+
const unmounted = () => controller.dispose();
|
|
113
|
+
(box as unknown as { onRemove: () => void }).onRemove = unmounted;
|
|
114
|
+
(box as unknown as { on?: (event: string, fn: () => void) => void }).on?.("destroyed", unmounted);
|
|
115
|
+
|
|
116
|
+
const popMode = api.mode.push(PANEL_MODE);
|
|
117
|
+
const dropLayer = api.keymap.registerLayer({
|
|
118
|
+
mode: PANEL_MODE,
|
|
119
|
+
bindings: KEY_BINDINGS.map(([key, panelKey]) => ({ key, desc: `CLM panel: ${panelKey}`, cmd: () => controller.dispatch(panelKey) })),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const perform = (effect: Effect | undefined) => {
|
|
123
|
+
if (!effect) return;
|
|
124
|
+
switch (effect.kind) {
|
|
125
|
+
case "close":
|
|
126
|
+
controller.dispose();
|
|
127
|
+
options.onClose();
|
|
128
|
+
return;
|
|
129
|
+
case "reload":
|
|
130
|
+
void controller.reload();
|
|
131
|
+
return;
|
|
132
|
+
case "apply":
|
|
133
|
+
void applySetting(effect.setting, effect.value);
|
|
134
|
+
return;
|
|
135
|
+
case "prompt":
|
|
136
|
+
api.ui.dialog.replace(() =>
|
|
137
|
+
api.ui.DialogPrompt({
|
|
138
|
+
title: `${effect.setting} · currently ${effect.current}`,
|
|
139
|
+
...(effect.placeholder ? { placeholder: effect.placeholder } : {}),
|
|
140
|
+
value: effect.current,
|
|
141
|
+
onConfirm: (value: string) => {
|
|
142
|
+
api.ui.dialog.clear();
|
|
143
|
+
if (value.trim() !== "") void applySetting(effect.setting, value.trim());
|
|
144
|
+
},
|
|
145
|
+
onCancel: () => api.ui.dialog.clear(),
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const applySetting = async (setting: string, value: string) => {
|
|
153
|
+
const error = await options.apply(setting, value).catch((cause: unknown) => (cause instanceof Error ? cause.message : String(cause)));
|
|
154
|
+
await controller.reload();
|
|
155
|
+
const row = model?.settings.rows.find((candidate) => candidate.key === setting);
|
|
156
|
+
const shown = `${row?.label ?? setting}: ${row?.value ?? value}`;
|
|
157
|
+
state = { ...state, message: error ? { text: error, warning: true } : { text: shown, warning: false } };
|
|
158
|
+
render();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const controller: PanelController = {
|
|
162
|
+
renderable: box,
|
|
163
|
+
dispatch(key) {
|
|
164
|
+
if (disposed || !model) {
|
|
165
|
+
if (key === "q" || key === "escape") perform({ kind: "close" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const result = reduce(state, key, model, size());
|
|
169
|
+
state = result.state;
|
|
170
|
+
render();
|
|
171
|
+
perform(result.effect);
|
|
172
|
+
},
|
|
173
|
+
async reload() {
|
|
174
|
+
const next = await options.load();
|
|
175
|
+
if (disposed) return;
|
|
176
|
+
model = next;
|
|
177
|
+
render();
|
|
178
|
+
},
|
|
179
|
+
setPage(page) {
|
|
180
|
+
state = { ...state, page, scroll: 0 };
|
|
181
|
+
render();
|
|
182
|
+
},
|
|
183
|
+
snapshot: () => ({ state, model }),
|
|
184
|
+
dispose() {
|
|
185
|
+
if (disposed) return;
|
|
186
|
+
disposed = true;
|
|
187
|
+
dropLayer();
|
|
188
|
+
popMode();
|
|
189
|
+
options.onDispose?.();
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
void controller.reload();
|
|
193
|
+
return controller;
|
|
194
|
+
}
|