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/src/tui/data.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { resolveBudget } from "../budget.ts";
6
6
  import type { LatestUsage, PanelModel, PanelSettings, SettingRow } from "../panel/model.ts";
7
- import { formatTokenCount } from "../panel/timeline.ts";
7
+ import { formatTokenCount, type TimelinePoint } from "../panel/timeline.ts";
8
8
  import type { ClmSettings } from "../settings.ts";
9
9
  import { applyOverrides, changedSettings, sanitizeOverrides, SETTINGS_TABLE, type FormatContext, type SettingsValues } from "../settings-table.ts";
10
10
 
@@ -109,7 +109,7 @@ export function fallbackBudget(settings: ClmSettings, limits: { context?: number
109
109
  */
110
110
  export function settingsView(
111
111
  values: { base: SettingsValues; effective: SettingsValues },
112
- model: Pick<PanelModel, "budgetInfo" | "timeline" | "mirrorPath">,
112
+ model: Pick<PanelModel, "budgetInfo" | "timeline" | "mirrorPath"> & Partial<Pick<PanelModel, "calibration" | "steering" | "annotations">>,
113
113
  options: { format?: FormatContext; warning?: string } = {},
114
114
  ): PanelSettings {
115
115
  const changed = new Set<string>(changedSettings(values.base, values.effective));
@@ -124,9 +124,10 @@ export function settingsView(
124
124
  }));
125
125
  const latest = model.timeline.points.at(-1);
126
126
  const summary: string[] = [];
127
- if (latest) summary.push(`Size last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}${latest.measured ? " (provider count)" : " (estimate)"}`);
127
+ if (latest) summary.push(sizeLine(latest, model.budgetInfo?.budget));
128
128
  const info = model.budgetInfo;
129
129
  if (info?.overhead !== undefined) summary.push(`Fixed overhead ~${formatTokenCount(info.overhead)} · usable ${formatTokenCount(info.usable ?? 0)}`);
130
+ summary.push(...detailLines(values.effective, model));
130
131
  summary.push(`Files mirror ${model.mirrorPath}`);
131
132
  return {
132
133
  rows,
@@ -136,6 +137,91 @@ export function settingsView(
136
137
  };
137
138
  }
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"> & Partial<Pick<PanelModel, "noticesOnly">>): string | undefined {
159
+ if (!model.found) return undefined;
160
+ if (!model.enabled) return `clm off · r${model.revision}`;
161
+ const mode = model.noticesOnly ? " notices-only" : "";
162
+ const latest = model.timeline.points.at(-1);
163
+ const size = latest === undefined
164
+ ? ""
165
+ : ` ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}${model.budget !== undefined ? ` / ${formatTokenCount(model.budget)}` : ""}`;
166
+ return `clm${mode}${size} · r${model.revision}`;
167
+ }
168
+
169
+ /** Key of the settings page's "Reset to defaults" row, and the choice that resets. */
170
+ export const RESET_ROW = "reset";
171
+ export const RESET_NOW = "reset now";
172
+
173
+ /**
174
+ * pi's "Reset to defaults" row: Enter cycles to "reset now", which drops this session's
175
+ * changes (the same as `/clm config reset`). With nothing changed Enter does nothing.
176
+ */
177
+ export function resetRow(changed: number): SettingRow {
178
+ const value = changed === 0 ? "nothing changed" : `${changed} changed`;
179
+ return {
180
+ key: RESET_ROW,
181
+ label: "Reset to defaults",
182
+ value,
183
+ 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.",
184
+ choices: changed === 0 ? [value] : [value, RESET_NOW],
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Settings-page details after the size lines (pi's settings summary): calibration, the
190
+ * overflow guard's limit, the steering document with its hash, annotation counts.
191
+ */
192
+ export function detailLines(
193
+ effective: SettingsValues,
194
+ model: Pick<PanelModel, "budgetInfo"> & Partial<Pick<PanelModel, "calibration" | "steering" | "annotations">>,
195
+ ): string[] {
196
+ const lines: string[] = [];
197
+ const calibration = model.calibration;
198
+ if (calibration) {
199
+ lines.push(calibration.samples === 0
200
+ ? "Estimate not calibrated yet (characters ÷ 4 until the provider reports a size)"
201
+ : `Estimate ×${calibration.factor.toFixed(2)}, calibrated from ${calibration.samples} provider count${calibration.samples === 1 ? "" : "s"}`);
202
+ }
203
+ if (effective.settings.mode === "notices-only") {
204
+ lines.push(effective.editing
205
+ ? "Mode notices-only: budget notices and the guard run; no mirror, the model cannot edit its context"
206
+ : "Mode notices-only (CLM editing is off, so nothing runs)");
207
+ }
208
+ const limit = model.budgetInfo?.limit;
209
+ lines.push(effective.settings.guard === "off"
210
+ ? "Guard off"
211
+ : `Guard withholds the oldest tool results above ${limit === undefined ? "budget − reserve" : formatTokenCount(limit)}`);
212
+ // pi appends the native-compaction state; `auto` and `on` leave OpenCode's own config.
213
+ if (effective.settings.compaction === "off") lines[lines.length - 1] += " · OpenCode's automatic compaction off";
214
+ // The hash prefix identifies an experiment arm (compare with sha256sum).
215
+ if (model.steering) lines.push(`Steering ${model.steering.name} (sha256 ${model.steering.hash}…)`);
216
+ const counts = model.annotations;
217
+ if (counts) {
218
+ lines.push(counts.total === 0
219
+ ? "Annotations none"
220
+ : `Annotations ${counts.active} continuity/pin · ${counts.archived} archive · ${counts.total} total`);
221
+ }
222
+ return lines;
223
+ }
224
+
139
225
  /**
140
226
  * The server's base settings, from snapshot.json `base` (the server writes it each
141
227
  * request), applied over the TUI's own resolution so non-table settings (mirrorDir…) stay.
@@ -1,5 +1,6 @@
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
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
3
4
  // a fake key context and focused prompt. Written for this package.
4
5
 
5
6
  import { parseClmCommand, type ClmCommand } from "../panel/command.ts";
@@ -20,8 +21,10 @@ export interface InterceptDeps {
20
21
  focused(): FocusedPrompt | null | undefined;
21
22
  /** False when `/clm` is not this package's to handle (`commands: false`, or a user-defined `/clm`). */
22
23
  owned(): boolean;
23
- /** Runs a command the TUI handles. */
24
- handle(command: Exclude<ClmCommand, { kind: "server" }>): Promise<void>;
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;
25
28
  /** Shows an error from `handle`. */
26
29
  report(message: string): void;
27
30
  }
@@ -31,7 +34,7 @@ export interface InterceptDeps {
31
34
  * handles; everything else passes through untouched: other text, `/clm-compact`, `/clm
32
35
  * reset`, modified Enter, a prompt without the expected internals, and every `/clm` line
33
36
  * 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.
37
+ * fixed. With `serverChannel`, `/clm reset` is consumed as well. Returns the command handled, for tests.
35
38
  */
36
39
  export function interceptEnter(context: InterceptContext, deps: InterceptDeps): ClmCommand | undefined {
37
40
  const event = context.event;
@@ -39,7 +42,8 @@ export function interceptEnter(context: InterceptContext, deps: InterceptDeps):
39
42
  const focused = deps.focused();
40
43
  if (!focused || typeof focused.plainText !== "string" || typeof focused.setText !== "function") return undefined;
41
44
  const command = parseClmCommand(focused.plainText);
42
- if (!command || command.kind === "server") return undefined;
45
+ if (!command) return undefined;
46
+ if (command.kind === "server" && !deps.serverChannel?.()) return undefined;
43
47
  // A throw here would land in the host's key handling: fail open, let the line through.
44
48
  let owned: boolean;
45
49
  try {
@@ -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
+ }
package/src/tui/panel.ts CHANGED
@@ -9,7 +9,7 @@ import type { TextChunk } from "@opentui/core";
9
9
  import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
10
10
 
11
11
  import type { Page } from "../panel/command.ts";
12
- import { reduce, type Effect, type Key } from "../panel/keys.ts";
12
+ import { reduce, withMessage, type Effect, type Key } from "../panel/keys.ts";
13
13
  import type { Line, Tone } from "../panel/lines.ts";
14
14
  import type { PanelModel } from "../panel/model.ts";
15
15
  import { initialPanelState, renderPanel, type PanelSize, type PanelState } from "../panel/view.ts";
@@ -66,6 +66,17 @@ export function toStyledText(lines: readonly Line[], theme: TuiThemeCurrent): St
66
66
  return new StyledText(chunks);
67
67
  }
68
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
+
69
80
  export interface PanelOptions {
70
81
  page: Page;
71
82
  /** Reads the session files and builds the model. Never rejects with a broken panel: errors become a model. */
@@ -154,7 +165,8 @@ export function createPanel(api: PanelApi, options: PanelOptions): PanelControll
154
165
  await controller.reload();
155
166
  const row = model?.settings.rows.find((candidate) => candidate.key === setting);
156
167
  const shown = `${row?.label ?? setting}: ${row?.value ?? value}`;
157
- state = { ...state, message: error ? { text: error, warning: true } : { text: shown, warning: false } };
168
+ const message = error ? { text: error, warning: true } : { text: shown, warning: false };
169
+ state = model ? withMessage(state, message, model, size()) : { ...state, message };
158
170
  render();
159
171
  };
160
172