opencode-clm 0.3.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.
@@ -10,13 +10,13 @@
10
10
  */
11
11
  import { basename, resolve } from "node:path";
12
12
 
13
- import { resolveBudgetPolicy } from "./budget.ts";
13
+ import { FALLBACK_BUDGET, formatPercent, resolveBudget, resolveBudgetPolicy, type BudgetPolicyConfig } from "./budget.ts";
14
14
  import { MIN_OBSERVATION_CAP, resolveObservationCap } from "./observation.ts";
15
15
  import { toEditGate, type EditGate } from "./policy.ts";
16
- import { HOUSE_STEERING, parseCompaction, parseFlag, parseFractions, parseTokens, type ClmSettings, type CompactionMode } from "./settings.ts";
16
+ import { HOUSE_STEERING, parseBudget, parseCompaction, parseCooldown, parseFlag, parseFractions, parseMode, parseTokens, type ClmMode, type ClmSettings, type CompactionMode } from "./settings.ts";
17
17
 
18
18
  export type SettingKey =
19
- | "editing" | "budget" | "reserve" | "reminders" | "gate" | "guard" | "compaction" | "cap" | "steering" | "oneTool" | "trailer"
19
+ | "editing" | "budget" | "reserve" | "reminders" | "cooldown" | "gate" | "guard" | "compaction" | "cap" | "steering" | "mode" | "oneTool" | "trailer"
20
20
  | "compactPrompt" | "reasoning";
21
21
 
22
22
  /**
@@ -26,10 +26,13 @@ export type SettingKey =
26
26
  */
27
27
  export interface ClmOverrides {
28
28
  editing?: boolean;
29
- budget?: number | null;
29
+ /** Tokens, a share of the model window as text (`"50%"`), or null for the whole window. */
30
+ budget?: number | BudgetPercent | null;
30
31
  reserve?: number;
31
32
  /** Reminder fractions; empty disables every reminder, the budget − reserve one included. */
32
33
  reminders?: number[];
34
+ /** Reminder cooldown as a share of the budget; 0 is off. */
35
+ cooldown?: number;
33
36
  gate?: EditGate;
34
37
  guard?: "withhold" | "off";
35
38
  compaction?: CompactionMode;
@@ -42,8 +45,12 @@ export interface ClmOverrides {
42
45
  reasoning?: boolean;
43
46
  oneTool?: boolean;
44
47
  trailer?: boolean;
48
+ mode?: ClmMode;
45
49
  }
46
50
 
51
+ /** A budget percentage as stored in overrides.json: `"50%"`, `"12.5%"`. */
52
+ export type BudgetPercent = `${number}%`;
53
+
47
54
  /** Settings in force for one session: the resolved settings plus whether edits apply. */
48
55
  export interface SettingsValues {
49
56
  editing: boolean;
@@ -53,6 +60,8 @@ export interface SettingsValues {
53
60
  export interface FormatContext {
54
61
  /** Model context window, shown next to a `window` budget. */
55
62
  modelWindow?: number;
63
+ /** Model output limit; a percentage budget is a share of the window minus this. */
64
+ modelOutput?: number;
56
65
  }
57
66
 
58
67
  export interface ParseContext {
@@ -82,6 +91,24 @@ export function compactTokens(value: number): string {
82
91
  return value.toLocaleString("en-US");
83
92
  }
84
93
 
94
+ const percentText = (fraction: number) => formatPercent(fraction) as BudgetPercent;
95
+
96
+ /** `"50%"` → 0.5; undefined for anything but a percentage from 1% to 100%. */
97
+ export function budgetPercentFraction(value: unknown): number | undefined {
98
+ if (typeof value !== "string" || !/^\d+(?:\.\d+)?%$/.test(value)) return undefined;
99
+ const percent = Number(value.slice(0, -1));
100
+ return percent >= 1 && percent <= 100 ? percent / 100 : undefined;
101
+ }
102
+
103
+ /** The budget fields of a policy for a `budget` override. */
104
+ function budgetFields(budget: number | BudgetPercent | null): Pick<BudgetPolicyConfig, "contextBudget" | "contextFraction"> {
105
+ if (budget === null) return { contextBudget: undefined, contextFraction: undefined };
106
+ if (typeof budget === "number") return { contextBudget: budget, contextFraction: undefined };
107
+ const fraction = budgetPercentFraction(budget);
108
+ if (fraction === undefined) throw new Error(`budget must be a percentage from 1% to 100%, got ${JSON.stringify(budget)}`);
109
+ return { contextBudget: undefined, contextFraction: fraction };
110
+ }
111
+
85
112
  const onOff = (value: boolean) => (value ? "on" : "off");
86
113
 
87
114
  function formatReminders(fractions: readonly number[]): string {
@@ -138,19 +165,25 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
138
165
  key: "budget",
139
166
  name: "budget",
140
167
  label: "Budget",
141
- description: "Token budget the reminders, the edit gate and the overflow guard measure against, OpenCode's system prompt and tool schemas included. A number (32k) or \"window\" for the model's context window. Env: CLM_BUDGET.",
142
- placeholder: "32k, 1.5m, or window",
168
+ description: `Token budget the reminders, the edit gate and the overflow guard measure against, OpenCode's system prompt and tool schemas included. A percentage of the model window minus its output limit (50%; ${compactTokens(FALLBACK_BUDGET)} while the window is unknown), a number (32k), or "window" for all of it. Env: CLM_BUDGET.`,
169
+ placeholder: "50%, 32k, 1.5m, or window",
143
170
  format: (values, context) => {
144
- const budget = values.settings.budget.contextBudget;
145
- if (budget !== undefined) return compactTokens(budget);
171
+ const policy = values.settings.budget;
172
+ if (policy.contextBudget !== undefined) return compactTokens(policy.contextBudget);
173
+ if (policy.contextFraction !== undefined) {
174
+ const resolved = context?.modelWindow ? resolveBudget(policy, context.modelWindow, context.modelOutput) : undefined;
175
+ return `${formatPercent(policy.contextFraction)}${resolved ? ` (${compactTokens(resolved.budget)})` : ""}`;
176
+ }
146
177
  return `window${context?.modelWindow ? ` (${compactTokens(context.modelWindow)})` : ""}`;
147
178
  },
179
+ // The settings page pre-fills its prompt with the formatted value: `50% (131,072)`.
148
180
  parse: (text) => {
149
- const value = text.trim().toLowerCase();
150
- if (value === "window" || value === "model") return { budget: null };
151
- const budget = parseTokens(value, "budget");
152
- if (budget <= 0) throw new Error(`budget must be a positive number of tokens or "window", got "${text}"`);
153
- return { budget };
181
+ const parsed = parseBudget(text.replace(/\s*\([^)]*\)\s*$/, ""), "budget");
182
+ switch (parsed.kind) {
183
+ case "window": return { budget: null };
184
+ case "fraction": return { budget: percentText(parsed.fraction) };
185
+ case "tokens": return { budget: parsed.tokens };
186
+ }
154
187
  },
155
188
  },
156
189
  {
@@ -171,6 +204,17 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
171
204
  format: (values) => formatReminders(values.settings.budget.remindAtFractions),
172
205
  parse: (text) => ({ reminders: parseReminders(text) }),
173
206
  },
207
+ {
208
+ key: "cooldown",
209
+ // Named after the reminders it governs; `cooldown` is an alias.
210
+ name: "reminder-cooldown",
211
+ label: "Reminder cooldown",
212
+ description: "After an accepted edit, percentage reminders stay silent until the context grows by this share of the budget; tiers crossed meanwhile are skipped. The budget − reserve reminder always fires. A share (10%, 0.1) or off. Env: CLM_REMINDER_COOLDOWN.",
213
+ choices: ["10%", "25%", "5%", "off"],
214
+ placeholder: "10%, 0.1, or off",
215
+ format: (values) => (values.settings.budget.reminderCooldown > 0 ? formatPercent(values.settings.budget.reminderCooldown) : "off"),
216
+ parse: (text) => ({ cooldown: parseCooldown(text, "reminder-cooldown") }),
217
+ },
174
218
  {
175
219
  key: "gate",
176
220
  name: "gate",
@@ -223,6 +267,15 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
223
267
  return { steering: parsePathSetting(text, context.directory, ["none", "off", ""]) };
224
268
  },
225
269
  },
270
+ {
271
+ key: "mode",
272
+ name: "mode",
273
+ label: "Mode",
274
+ description: "edit: the model edits its context through the mirror. notices-only: no mirror, no protocol prompt and no edits; budget notices and reminders, the overflow guard, the observation cap and the size trailer still apply, to compare telling the model its budget with letting it edit. An accepted revision is kept but not applied while notices-only is in force. Env: CLM_MODE.",
275
+ choices: ["edit", "notices-only"],
276
+ format: (values) => values.settings.mode,
277
+ parse: (text) => ({ mode: parseMode(text) }),
278
+ },
226
279
  {
227
280
  key: "oneTool",
228
281
  name: "one-tool",
@@ -270,6 +323,9 @@ const ALIASES: Record<string, SettingKey> = {
270
323
  remind: "reminders",
271
324
  "remind-at": "reminders",
272
325
  remindat: "reminders",
326
+ cooldown: "cooldown",
327
+ "reminder-cooldown": "cooldown",
328
+ remindercooldown: "cooldown",
273
329
  gate: "gate",
274
330
  "edit-gate": "gate",
275
331
  guard: "guard",
@@ -290,6 +346,7 @@ const ALIASES: Record<string, SettingKey> = {
290
346
  "compact-prompt": "compactPrompt",
291
347
  compactprompt: "compactPrompt",
292
348
  reasoning: "reasoning",
349
+ mode: "mode",
293
350
  };
294
351
 
295
352
  /** Names `/clm config` lists. */
@@ -313,10 +370,13 @@ export function unknownSettingText(name: string): string {
313
370
  export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmSettings {
314
371
  const budget = resolveBudgetPolicy({
315
372
  ...base.budget,
316
- contextBudget: overrides.budget === null ? undefined : overrides.budget ?? base.budget.contextBudget,
373
+ ...(overrides.budget !== undefined
374
+ ? budgetFields(overrides.budget)
375
+ : { contextBudget: base.budget.contextBudget, contextFraction: base.budget.contextFraction }),
317
376
  reserve: overrides.reserve ?? base.budget.reserve,
318
377
  remindAtFractions: overrides.reminders ?? base.budget.remindAtFractions,
319
378
  remindAtReserve: overrides.reminders !== undefined ? overrides.reminders.length > 0 : base.budget.remindAtReserve,
379
+ reminderCooldown: overrides.cooldown ?? base.budget.reminderCooldown,
320
380
  });
321
381
  const observationCap = resolveObservationCap({
322
382
  maxCharacters: overrides.cap === null ? undefined : overrides.cap ?? base.observationCap.maxCharacters,
@@ -330,6 +390,7 @@ export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmS
330
390
  compaction: overrides.compaction ?? base.compaction,
331
391
  oneTool: overrides.oneTool ?? base.oneTool,
332
392
  trailer: overrides.trailer ?? base.trailer,
393
+ mode: overrides.mode ?? base.mode,
333
394
  observationCap,
334
395
  steeringPath: overrides.steering === null ? undefined : overrides.steering ?? base.steeringPath,
335
396
  compactPromptPath: overrides.compactPrompt === null ? undefined : overrides.compactPrompt ?? base.compactPromptPath,
@@ -342,9 +403,11 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
342
403
  const settings = values.settings;
343
404
  switch (key) {
344
405
  case "editing": return values.editing;
345
- case "budget": return settings.budget.contextBudget ?? null;
406
+ case "budget": return settings.budget.contextBudget ??
407
+ (settings.budget.contextFraction !== undefined ? percentText(settings.budget.contextFraction) : null);
346
408
  case "reserve": return settings.budget.reserve;
347
409
  case "reminders": return settings.budget.remindAtReserve || settings.budget.remindAtFractions.length > 0 ? [...settings.budget.remindAtFractions] : [];
410
+ case "cooldown": return settings.budget.reminderCooldown;
348
411
  case "gate": return settings.gate;
349
412
  case "guard": return settings.guard;
350
413
  case "compaction": return settings.compaction;
@@ -355,6 +418,7 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
355
418
  case "steering": return settings.steeringPath ?? null;
356
419
  case "compactPrompt": return settings.compactPromptPath ?? null;
357
420
  case "reasoning": return settings.reasoning;
421
+ case "mode": return settings.mode;
358
422
  }
359
423
  }
360
424
 
@@ -366,7 +430,7 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
366
430
  */
367
431
  export function settingsAsOverrides(settings: ClmSettings): ClmOverrides {
368
432
  const values: SettingsValues = { editing: true, settings };
369
- const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "gate", "guard", "compaction", "cap", "capHead", "steering", "oneTool", "trailer", "compactPrompt", "reasoning"];
433
+ const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "cooldown", "gate", "guard", "compaction", "cap", "capHead", "steering", "mode", "oneTool", "trailer", "compactPrompt", "reasoning"];
370
434
  return Object.fromEntries(keys.map((key) => [key, baseValue(key, values)])) as ClmOverrides;
371
435
  }
372
436
 
@@ -385,9 +449,10 @@ const isPath = (value: unknown) => value === null || (typeof value === "string"
385
449
  const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
386
450
  editing: (value) => typeof value === "boolean",
387
451
  // Integers: state.json's budget check stores them as counts.
388
- budget: (value) => value === null || (Number.isInteger(value) && (value as number) > 0),
452
+ budget: (value) => value === null || (Number.isInteger(value) && (value as number) > 0) || budgetPercentFraction(value) !== undefined,
389
453
  reserve: (value) => Number.isInteger(value) && (value as number) >= 0,
390
454
  reminders: (value) => Array.isArray(value) && value.every((fraction) => isNumber(fraction) && fraction > 0 && fraction < 1),
455
+ cooldown: (value) => isNumber(value) && value >= 0 && value < 1,
391
456
  gate: (value) => value === "fit" || value === "shrink" || value === "none",
392
457
  guard: (value) => value === "withhold" || value === "off",
393
458
  compaction: (value) => value === "auto" || value === "off" || value === "on",
@@ -398,6 +463,7 @@ const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
398
463
  steering: isPath,
399
464
  compactPrompt: isPath,
400
465
  reasoning: (value) => typeof value === "boolean",
466
+ mode: (value) => value === "edit" || value === "notices-only",
401
467
  };
402
468
 
403
469
  /**
package/src/settings.ts CHANGED
@@ -47,6 +47,22 @@ export interface ClmSettings {
47
47
  oneTool: boolean;
48
48
  /** Append `[context: ~N of B tokens after this result]` to tool results (paper-harness parity). */
49
49
  trailer: boolean;
50
+ /**
51
+ * `edit`: the model edits its context through the mirror. `notices-only`: no mirror, no
52
+ * protocol prompt and no edits; budget notices, reminders, the guard, the cap and the
53
+ * trailer still apply (to compare telling the model its budget with letting it edit).
54
+ */
55
+ mode: ClmMode;
56
+ }
57
+
58
+ export type ClmMode = "edit" | "notices-only";
59
+
60
+ /** `edit`, `notices-only`, or `notices` (an alias of `notices-only`). */
61
+ export function parseMode(value: unknown, name = "mode"): ClmMode {
62
+ const text = String(value).trim().toLowerCase();
63
+ if (text === "edit") return "edit";
64
+ if (text === "notices-only" || text === "notices") return "notices-only";
65
+ throw new Error(`${name} must be edit or notices-only, got ${JSON.stringify(value)}`);
50
66
  }
51
67
 
52
68
  export type CompactionMode = "auto" | "off" | "on";
@@ -64,6 +80,7 @@ export const SKILLS_DIR = join(PACKAGE_DIR, "skills");
64
80
  type Env = Record<string, string | undefined>;
65
81
 
66
82
  const OFF_WORDS = ["none", "off"];
83
+ const BUDGET_FORMS = `a number of tokens (32000, 32k), a percentage of the model window (1% to 100%), or "window"`;
67
84
 
68
85
  function pick(options: Record<string, unknown>, key: string, env: Env, envKey: string): unknown {
69
86
  if (options[key] !== undefined) return options[key];
@@ -85,6 +102,34 @@ export function parseTokens(value: unknown, name: string): number {
85
102
  return Math.round(Number(match[1]) * scale);
86
103
  }
87
104
 
105
+ export type BudgetValue =
106
+ | { kind: "tokens"; tokens: number }
107
+ | { kind: "fraction"; fraction: number }
108
+ | { kind: "window" };
109
+
110
+ /**
111
+ * A budget setting: `32000` / `32k` (tokens), `50%` (of the model window minus its output
112
+ * limit; 1% to 100%), or `window` / `model` (the whole of it).
113
+ */
114
+ export function parseBudget(value: unknown, name = "budget"): BudgetValue {
115
+ const text = String(value).trim().toLowerCase();
116
+ if (text === "window" || text === "model") return { kind: "window" };
117
+ if (text.endsWith("%")) {
118
+ const number = text.slice(0, -1).trim();
119
+ const percent = /^\d+(?:\.\d+)?$/.test(number) ? Number(number) : Number.NaN;
120
+ if (!(percent >= 1 && percent <= 100)) throw new Error(`${name} must be ${BUDGET_FORMS}, got ${JSON.stringify(value)}`);
121
+ return { kind: "fraction", fraction: percent / 100 };
122
+ }
123
+ let tokens: number;
124
+ try {
125
+ tokens = parseTokens(value, name);
126
+ } catch {
127
+ throw new Error(`${name} must be ${BUDGET_FORMS}, got ${JSON.stringify(value)}`);
128
+ }
129
+ if (tokens <= 0) throw new Error(`${name} must be a positive number of tokens or "window", got ${JSON.stringify(value)}`);
130
+ return { kind: "tokens", tokens };
131
+ }
132
+
88
133
  export function parseFlag(value: unknown, name: string): boolean {
89
134
  if (typeof value === "boolean") return value;
90
135
  const key = String(value).trim().toLowerCase();
@@ -111,6 +156,23 @@ export function parseFractions(value: unknown, name: string): number[] | "off" {
111
156
  return [...new Set(fractions)];
112
157
  }
113
158
 
159
+ const COOLDOWN_FORMS = `a share of the budget between 0 and 1, as a percentage (10%) or a fraction (0.1), or off`;
160
+
161
+ /**
162
+ * The reminder cooldown: `10%` or `0.1` (a share of the budget, strictly between 0 and 1), or
163
+ * `off` / `none` / `0` for no cooldown (0).
164
+ */
165
+ export function parseCooldown(value: unknown, name = "reminderCooldown"): number {
166
+ if (value === false) return 0;
167
+ const text = String(value).trim().toLowerCase();
168
+ if (text === "off" || text === "none" || text === "0" || text === "0%") return 0;
169
+ const percent = text.endsWith("%");
170
+ const number = percent ? text.slice(0, -1).trim() : text;
171
+ const parsed = /^\d*\.?\d+$/.test(number) ? Number(number) / (percent ? 100 : 1) : Number.NaN;
172
+ if (!(parsed > 0 && parsed < 1)) throw new Error(`${name} must be ${COOLDOWN_FORMS}, got ${JSON.stringify(value)}`);
173
+ return parsed;
174
+ }
175
+
114
176
  function withName<T>(name: string, parse: () => T): T {
115
177
  try {
116
178
  return parse();
@@ -132,6 +194,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
132
194
  const budgetRaw = pick(options, "budget", env, "CLM_BUDGET");
133
195
  const reserveRaw = pick(options, "reserve", env, "CLM_RESERVE");
134
196
  const remindRaw = pick(options, "remindAt", env, "CLM_REMIND_AT");
197
+ const cooldownRaw = pick(options, "reminderCooldown", env, "CLM_REMINDER_COOLDOWN");
135
198
  const gateRaw = pick(options, "gate", env, "CLM_EDIT_GATE");
136
199
  const guardRaw = pick(options, "guard", env, "CLM_OVERFLOW");
137
200
  const capRaw = pick(options, "observationCap", env, "CLM_OBSERVATION_CAP");
@@ -144,11 +207,13 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
144
207
  const compactionRaw = pick(options, "compaction", env, "CLM_NATIVE_COMPACTION");
145
208
  const oneToolRaw = pick(options, "oneTool", env, "CLM_ONE_TOOL_PER_TURN");
146
209
  const trailerRaw = pick(options, "trailer", env, "CLM_SIZE_TRAILER");
210
+ const modeRaw = pick(options, "mode", env, "CLM_MODE");
147
211
 
148
212
  const overrides: Partial<BudgetPolicyConfig> = {};
149
213
  if (budgetRaw !== undefined) {
150
- overrides.contextBudget = String(budgetRaw).trim().toLowerCase() === "window" ? undefined : parseTokens(budgetRaw, "budget");
151
- if (overrides.contextBudget === 0) throw new Error(`budget must be a positive number of tokens or "window", got ${JSON.stringify(budgetRaw)}`);
214
+ const parsed = parseBudget(budgetRaw, "budget");
215
+ overrides.contextBudget = parsed.kind === "tokens" ? parsed.tokens : undefined;
216
+ overrides.contextFraction = parsed.kind === "fraction" ? parsed.fraction : undefined;
152
217
  }
153
218
  if (reserveRaw !== undefined) overrides.reserve = parseTokens(reserveRaw, "reserve");
154
219
  if (remindRaw !== undefined) {
@@ -164,6 +229,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
164
229
  overrides.remindAtFractions = fractions;
165
230
  }
166
231
  }
232
+ if (cooldownRaw !== undefined) overrides.reminderCooldown = parseCooldown(cooldownRaw, "reminderCooldown");
167
233
  const budget = withName("budget", () => resolveBudgetPolicy({ ...DEFAULT_BUDGET_POLICY, ...overrides }));
168
234
 
169
235
  let guard: ClmSettings["guard"] = "withhold";
@@ -213,5 +279,6 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
213
279
  compaction: compactionRaw === undefined ? "auto" : parseCompaction(compactionRaw),
214
280
  oneTool: oneToolRaw === undefined ? false : parseFlag(oneToolRaw, "oneTool"),
215
281
  trailer: trailerRaw === undefined ? false : parseFlag(trailerRaw, "trailer"),
282
+ mode: modeRaw === undefined ? "edit" : parseMode(modeRaw),
216
283
  };
217
284
  }
package/src/tui/data.ts CHANGED
@@ -155,14 +155,15 @@ export function sizeLine(latest: Pick<TimelinePoint, "tokens" | "measured" | "es
155
155
  * `clm 12k / 32k · r2` (newest size against the budget in force), `clm off · r2`, or
156
156
  * undefined before the session has CLM data.
157
157
  */
158
- export function footerText(model: Pick<PanelModel, "found" | "enabled" | "revision" | "timeline" | "budget">): string | undefined {
158
+ export function footerText(model: Pick<PanelModel, "found" | "enabled" | "revision" | "timeline" | "budget"> & Partial<Pick<PanelModel, "noticesOnly">>): string | undefined {
159
159
  if (!model.found) return undefined;
160
160
  if (!model.enabled) return `clm off · r${model.revision}`;
161
+ const mode = model.noticesOnly ? " notices-only" : "";
161
162
  const latest = model.timeline.points.at(-1);
162
163
  const size = latest === undefined
163
164
  ? ""
164
165
  : ` ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}${model.budget !== undefined ? ` / ${formatTokenCount(model.budget)}` : ""}`;
165
- return `clm${size} · r${model.revision}`;
166
+ return `clm${mode}${size} · r${model.revision}`;
166
167
  }
167
168
 
168
169
  /** Key of the settings page's "Reset to defaults" row, and the choice that resets. */
@@ -199,6 +200,11 @@ export function detailLines(
199
200
  ? "Estimate not calibrated yet (characters ÷ 4 until the provider reports a size)"
200
201
  : `Estimate ×${calibration.factor.toFixed(2)}, calibrated from ${calibration.samples} provider count${calibration.samples === 1 ? "" : "s"}`);
201
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
+ }
202
208
  const limit = model.budgetInfo?.limit;
203
209
  lines.push(effective.settings.guard === "off"
204
210
  ? "Guard off"
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";
@@ -165,7 +165,8 @@ export function createPanel(api: PanelApi, options: PanelOptions): PanelControll
165
165
  await controller.reload();
166
166
  const row = model?.settings.rows.find((candidate) => candidate.key === setting);
167
167
  const shown = `${row?.label ?? setting}: ${row?.value ?? value}`;
168
- 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 };
169
170
  render();
170
171
  };
171
172
 
package/src/tui/plugin.ts CHANGED
@@ -139,8 +139,9 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
139
139
  const preferSettings = overridesNewer(current.overridesAt, files.snapshot);
140
140
  const model = buildPanelModel(files, { ...(latest ? { latest } : {}), ...(budget ? { budget } : {}), ...(preferSettings ? { preferSettings } : {}) });
141
141
  model.enabled = current.values.effective.editing;
142
+ if (current.values.effective.settings.mode === "notices-only") model.noticesOnly = true;
142
143
  model.settings = settingsView(current.values, model, {
143
- format: { ...(limits.context ? { modelWindow: limits.context } : {}) },
144
+ format: { ...(limits.context ? { modelWindow: limits.context } : {}), ...(limits.output ? { modelOutput: limits.output } : {}) },
144
145
  ...(current.warning ? { warning: current.warning } : {}),
145
146
  });
146
147
  model.settings.rows.push(resetRow(model.settings.changed.length));
@@ -456,7 +457,7 @@ export function statusSummary(model: PanelModel): string {
456
457
  if (!model.found) return `No CLM data for this session (${model.directory}).`;
457
458
  const latest = model.timeline.points.at(-1);
458
459
  const parts = [
459
- `CLM ${model.enabled ? "on" : "off"} · revision ${model.revision}`,
460
+ `CLM ${model.enabled ? "on" : "off"}${model.enabled && model.noticesOnly ? " · mode notices-only" : ""} · revision ${model.revision}`,
460
461
  latest ? `last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}` : "no requests yet",
461
462
  model.budget !== undefined ? `budget ${formatTokenCount(model.budget)}` : undefined,
462
463
  model.budgetInfo?.overhead !== undefined ? `fixed overhead ~${formatTokenCount(model.budgetInfo.overhead)}` : undefined,