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,554 @@
1
+ // The `/clm` panel as styled lines: frame, tab row, one of four pages, help row. Pure.
2
+ // Adapted from pi-clm src/viewer.ts (MIT, Copyright 2026 Emanuel Casco): the page layouts,
3
+ // texts and help rows follow its CLM layout; state lives in keys.ts instead of a component.
4
+
5
+ import type { Page } from "./command.ts";
6
+ import { PAGES } from "./command.ts";
7
+ import { editDiffRows, renderDiff, type SideBySideRow } from "./diff.ts";
8
+ import { fit, normalize, span, truncate, wrap, type Line, type Span, type Tone } from "./lines.ts";
9
+ import type { BudgetView, EditRevision, EditView, PanelModel } from "./model.ts";
10
+ import {
11
+ availableTimelineZooms,
12
+ bucketIndexOf,
13
+ formatBucketSpan,
14
+ formatClockTime,
15
+ formatMarkerCompact,
16
+ formatTokenCount,
17
+ layoutTimeline,
18
+ renderTimelineChart,
19
+ type TimelineMarker,
20
+ type TimelineZoom,
21
+ } from "./timeline.ts";
22
+
23
+ export interface PanelState {
24
+ page: Page;
25
+ /** First visible body line. */
26
+ scroll: number;
27
+ zoom: TimelineZoom;
28
+ /** Selected overview marker; undefined = "now". */
29
+ markerSel?: number;
30
+ /** Index into `model.revisions`; undefined = newest. */
31
+ revisionIndex?: number;
32
+ editSel: number;
33
+ /** Expanded edit rows, keyed `revision:row`. */
34
+ expanded: ReadonlySet<string>;
35
+ settingSel: number;
36
+ message?: { text: string; warning: boolean };
37
+ }
38
+
39
+ export interface PanelSize {
40
+ width: number;
41
+ height: number;
42
+ }
43
+
44
+ export function initialPanelState(page: Page = "overview"): PanelState {
45
+ return { page, scroll: 0, zoom: "fit", editSel: 0, expanded: new Set(), settingSel: 0 };
46
+ }
47
+
48
+ /** Frame rows around the body: top border, tabs, separator, separator, help, bottom border. */
49
+ const FRAME_ROWS = 6;
50
+ const ZOOM_LABELS: Record<TimelineZoom, string> = { fit: "all", requests: "detail", turns: "turns" };
51
+ const EDIT_TONE: Tone = "edit";
52
+ const KEPT_PREVIEW_CHARACTERS = 2_000;
53
+ const MIRROR_TEXT_MAX_LINES = 200;
54
+ const MIRROR_UNAVAILABLE_TEXT = "No mirror: requests carry the raw history plus the continuity annotations; edits are off.";
55
+
56
+ /** Body rows available for the page. */
57
+ export function viewportHeight(size: PanelSize): number {
58
+ return Math.max(1, size.height - FRAME_ROWS);
59
+ }
60
+
61
+ /** Columns available for page text (inside the border and one space of padding each side). */
62
+ export function contentWidth(size: PanelSize): number {
63
+ return Math.max(1, size.width - 4);
64
+ }
65
+
66
+ const fmt = formatTokenCount;
67
+
68
+ function wrapAll(lines: readonly Line[], width: number): Line[] {
69
+ return lines.flatMap((line) => (line.length === 0 ? [[]] : wrap(line, width)));
70
+ }
71
+
72
+ function text(value: string, tone?: Tone, bold?: boolean): Line {
73
+ return [span(value, tone, bold)];
74
+ }
75
+
76
+ function formatTime(value: string | undefined): string {
77
+ if (!value) return "unknown";
78
+ const date = new Date(value);
79
+ return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
80
+ }
81
+
82
+ // ---- overview ------------------------------------------------------------------------
83
+
84
+ /** The chosen zoom, or the first offered one when it is not offered at this width. */
85
+ export function effectiveZoom(model: PanelModel, state: PanelState, width: number): TimelineZoom {
86
+ const zooms = availableTimelineZooms(model.timeline, width);
87
+ return zooms.includes(state.zoom) ? state.zoom : zooms[0]!;
88
+ }
89
+
90
+ /** Index of the selected marker, or markers.length for "now". */
91
+ export function markerSelection(model: PanelModel, state: PanelState): number {
92
+ const now = model.timeline.markers.length;
93
+ return state.markerSel === undefined ? now : Math.max(0, Math.min(now, state.markerSel));
94
+ }
95
+
96
+ /** One line on fixed overhead and the usable share of the budget, when known. */
97
+ export function budgetLine(info: BudgetView | undefined): Line | undefined {
98
+ if (!info || (info.overhead === undefined && !info.tooSmall)) return undefined;
99
+ const parts: string[] = [];
100
+ if (info.overhead !== undefined) {
101
+ parts.push(`fixed overhead ~${fmt(info.overhead)}${info.overheadSource === "estimate" ? " (estimated)" : ""}`);
102
+ }
103
+ if (info.usable !== undefined && info.configured !== undefined) parts.push(`usable ${fmt(info.usable)} of ${fmt(info.configured)}`);
104
+ if (info.reserve !== undefined) parts.push(`reserve ${fmt(info.reserve)}`);
105
+ if (info.raised && info.budget !== undefined && info.effectiveUsable !== undefined) {
106
+ parts.push(`raised to ${fmt(info.budget)}${info.capped ? " (window cap)" : ""}, usable ${fmt(info.effectiveUsable)}`);
107
+ }
108
+ const label = info.tooSmall ? (info.raised ? "Budget was too small: " : "Budget too small: ") : "Budget: ";
109
+ return text(`${label}${parts.join(" · ")}`, info.tooSmall ? "warning" : "muted");
110
+ }
111
+
112
+ /**
113
+ * Picks the groups (a list row plus any details) to show within `budget` lines: the
114
+ * selected group always, then neighbours after and before it, with a `⋯ N earlier/later`
115
+ * line for what is cut at either end.
116
+ */
117
+ export function windowAroundSelection(
118
+ groups: readonly (readonly Line[])[],
119
+ selected: number,
120
+ budget: number,
121
+ more: (count: number, where: "earlier" | "later") => Line,
122
+ ): Line[] {
123
+ const total = groups.reduce((sum, group) => sum + group.length, 0);
124
+ if (total <= budget || groups.length === 0) return groups.flat();
125
+ const pick = Math.max(0, Math.min(groups.length - 1, selected));
126
+ let start = pick;
127
+ let end = pick + 1;
128
+ let used = groups[pick]!.length;
129
+ const markers = (from: number, to: number) => (from > 0 ? 1 : 0) + (to < groups.length ? 1 : 0);
130
+ for (let grew = true; grew;) {
131
+ grew = false;
132
+ if (end < groups.length && used + groups[end]!.length + markers(start, end + 1) <= budget) {
133
+ used += groups[end]!.length;
134
+ end++;
135
+ grew = true;
136
+ }
137
+ if (start > 0 && used + groups[start - 1]!.length + markers(start - 1, end) <= budget) {
138
+ used += groups[start - 1]!.length;
139
+ start--;
140
+ grew = true;
141
+ }
142
+ }
143
+ return [
144
+ ...(start > 0 ? [more(start, "earlier")] : []),
145
+ ...groups.slice(start, end).flat(),
146
+ ...(end < groups.length ? [more(groups.length - end, "later")] : []),
147
+ ];
148
+ }
149
+
150
+ function markerDetails(
151
+ model: PanelModel,
152
+ zoom: TimelineZoom,
153
+ width: number,
154
+ marker: TimelineMarker | undefined,
155
+ focusPoint: number | undefined,
156
+ ): string[] {
157
+ const timeline = model.timeline;
158
+ const layout = timeline.points.length > 0 ? layoutTimeline(timeline, { width, zoom, focusPoint }) : undefined;
159
+ const bucket = layout?.buckets[bucketIndexOf(layout, focusPoint)];
160
+ const bucketed = zoom !== "requests" && bucket !== undefined && bucket.first !== bucket.last;
161
+ const shared = bucketed && bucket.edits.length > 1 ? `${bucket.edits.length} edits in this column` : undefined;
162
+ const period = bucketed ? formatBucketSpan(timeline, bucket) : undefined;
163
+ const columnSpan = [period, shared].filter(Boolean).join(" · ") || undefined;
164
+ const lines: string[] = [];
165
+ if (marker) {
166
+ const parts = [
167
+ marker.afterPoint >= 0
168
+ ? `after request ${timeline.points[marker.afterPoint]?.request ?? marker.afterPoint + 1}`
169
+ : "before the first request",
170
+ ];
171
+ const revision = marker.kind === "applied" ? model.revisions.find((item) => item.revision === marker.revision) : undefined;
172
+ if (revision) {
173
+ const counts = new Map<string, number>();
174
+ for (const edit of revision.edits) counts.set(edit.kind, (counts.get(edit.kind) ?? 0) + 1);
175
+ parts.push(...["edited", "normalized", "removed", "restored", "added", "kept"]
176
+ .filter((kind) => counts.has(kind))
177
+ .map((kind) => `${counts.get(kind)} ${kind}`));
178
+ }
179
+ if (marker.kind === "reset" || marker.kind === "compacted") parts.push(marker.message);
180
+ lines.push(parts.join(" · "));
181
+ if (columnSpan) lines.push(columnSpan);
182
+ if (revision) lines.push("Enter: before/after in edits");
183
+ } else {
184
+ if (columnSpan) lines.push(columnSpan);
185
+ lines.push("Enter: current input");
186
+ }
187
+ return lines;
188
+ }
189
+
190
+ function overviewLines(model: PanelModel, state: PanelState, width: number, viewport: number): Line[] {
191
+ if (!model.found) return notFoundLines(model, width);
192
+ const timeline = model.timeline;
193
+ const notice = !model.enabled
194
+ ? text("Projection off: the model sees the raw history (/clm on).", "warning")
195
+ : model.mirrorUnavailable !== undefined
196
+ ? text(MIRROR_UNAVAILABLE_TEXT, "warning")
197
+ : model.lastOutcome?.kind === "rejected"
198
+ ? text(`Last edit rejected: ${model.lastOutcome.message}`, "warning")
199
+ : undefined;
200
+ const above = notice ? wrapAll([notice, []], width) : [];
201
+ const zoom = effectiveZoom(model, state, width);
202
+ const chartHeight = Math.max(4, Math.min(6, viewport - 8));
203
+ const selection = markerSelection(model, state);
204
+ const atNow = selection === timeline.markers.length;
205
+ const newest = timeline.points.length - 1;
206
+ const focusPoint = atNow ? Math.max(0, newest) : timeline.markers[selection]?.afterPoint;
207
+ const chart = renderTimelineChart(timeline, {
208
+ width,
209
+ height: chartHeight,
210
+ zoom,
211
+ focusPoint,
212
+ budget: model.budget,
213
+ selectedMarker: atNow ? undefined : selection,
214
+ selectedPoint: atNow && newest >= 0 ? newest : undefined,
215
+ });
216
+ const latest = timeline.points.at(-1);
217
+ const summary = [
218
+ `${timeline.points.length} requests`,
219
+ latest ? `now ${latest.measured ? "" : "~"}${fmt(latest.tokens)}` : undefined,
220
+ latest ? `peak ${fmt(timeline.peakTokens)}` : undefined,
221
+ model.budget !== undefined ? `budget ${fmt(model.budget)}` : undefined,
222
+ ].filter(Boolean).join(" · ");
223
+ const budget = budgetLine(model.budgetInfo);
224
+ const head = wrapAll([
225
+ [span("Context size", "accent", true), span(` · ${summary}`, "muted")],
226
+ ...(budget ? [budget] : []),
227
+ ...chart,
228
+ [],
229
+ ], width);
230
+ const details = (marker: TimelineMarker | undefined) =>
231
+ markerDetails(model, zoom, width, marker, focusPoint)
232
+ .flatMap((line) => wrap([span(line, "dim")], Math.max(10, width - 6)))
233
+ .map((line) => [span(" "), ...line]);
234
+ const groups: Line[][] = timeline.markers.map((marker, index) => {
235
+ const selected = index === selection;
236
+ const tone: Tone = marker.kind === "applied" ? EDIT_TONE : marker.kind === "rejected" ? "warning" : "muted";
237
+ const row = `${selected ? "› ▾" : " ▸"} ${formatMarkerCompact(marker)}`;
238
+ return [...wrap([span(row, tone, selected)], width), ...(selected ? details(marker) : [])];
239
+ });
240
+ const nowTime = formatClockTime(latest?.at) ?? "--:--";
241
+ const nowSize = latest ? `${latest.measured ? "" : "~"}${fmt(latest.tokens)}`.padStart(5) : "no completed requests yet";
242
+ const nowRow = `${atNow ? "› ▾" : " ▸"} now ${nowTime} ${nowSize}`;
243
+ groups.push([...wrap([span(nowRow, atNow ? "text" : "muted", atNow)], width), ...(atNow ? details(undefined) : [])]);
244
+ const listBudget = viewport - above.length - head.length;
245
+ const more = (count: number, where: "earlier" | "later") => text(` ⋯ ${count} ${where}`, "dim");
246
+ return [...above, ...head, ...windowAroundSelection(groups, selection, listBudget, more)];
247
+ }
248
+
249
+ function notFoundLines(model: PanelModel, width: number): Line[] {
250
+ return wrapAll([
251
+ text("No CLM data for this session.", "warning"),
252
+ [],
253
+ text(`Looked in ${model.directory}`, "muted"),
254
+ text("The server plugin writes it on the first model request of a session with CLM enabled.", "dim"),
255
+ ...model.warnings.map((warning) => text(warning, "warning")),
256
+ ], width);
257
+ }
258
+
259
+ // ---- input ---------------------------------------------------------------------------
260
+
261
+ function savingsBar(raw: number, effective: number, width: number): Line {
262
+ const barWidth = Math.max(10, Math.min(48, width - 18));
263
+ const fraction = raw > 0 ? effective / raw : 1;
264
+ const used = Math.max(0, Math.min(barWidth, Math.round(barWidth * fraction)));
265
+ const removed = raw > 0 ? Math.max(0, ((raw - effective) / raw) * 100) : 0;
266
+ return [span("█".repeat(used), "accent"), span("░".repeat(barWidth - used), "dim"), span(` ${removed.toFixed(1)}% removed`)];
267
+ }
268
+
269
+ function inputLines(model: PanelModel, width: number): Line[] {
270
+ if (!model.found) return notFoundLines(model, width);
271
+ const input = model.input;
272
+ const title: Line = [span("Current input", "accent", true), span(" · what the next model request will contain", "muted")];
273
+ if (!input) {
274
+ return wrapAll([
275
+ title,
276
+ [],
277
+ model.mirrorUnavailable !== undefined
278
+ ? text(`${MIRROR_UNAVAILABLE_TEXT} No input snapshot is kept.`, "warning")
279
+ : text("No input snapshot yet: snapshot.json appears after the next model request.", "muted"),
280
+ [],
281
+ text(`Mirror: ${model.mirrorPath}`, "dim"),
282
+ ], width);
283
+ }
284
+ const messages: Line[] = input.messages.length === 0
285
+ ? [text("No model-visible messages in the snapshot.", "muted")]
286
+ : input.messages.map((message) => [
287
+ span(`#${message.index}`, "dim"),
288
+ span(" "),
289
+ span(message.role, "accent"),
290
+ span(` ${fmt(message.tokens)} tok`, "muted"),
291
+ span(` · ${message.preview}`),
292
+ ]);
293
+ return wrapAll([
294
+ title,
295
+ [],
296
+ savingsBar(input.rawTokens, input.effectiveTokens, width),
297
+ text(`Tokens ${fmt(input.rawTokens)} raw → ${fmt(input.effectiveTokens)} effective`),
298
+ text(`Messages ${input.rawMessages} raw → ${input.sentMessages} effective · ${input.suffix} after the last edit`),
299
+ text(`Captured ${formatTime(input.capturedAt)}`),
300
+ ...(input.stale ? [text("Newer requests ran after this snapshot; the next model call includes their messages.", "warning")] : []),
301
+ [],
302
+ ...messages,
303
+ [],
304
+ text(`Mirror: ${input.mirrorPath}`, "dim"),
305
+ ], width);
306
+ }
307
+
308
+ // ---- edits ---------------------------------------------------------------------------
309
+
310
+ export function revisionIndex(model: PanelModel, state: PanelState): number {
311
+ const last = model.revisions.length - 1;
312
+ return state.revisionIndex === undefined ? Math.max(0, last) : Math.max(0, Math.min(last, state.revisionIndex));
313
+ }
314
+
315
+ export function currentRevision(model: PanelModel, state: PanelState): EditRevision | undefined {
316
+ return model.revisions[revisionIndex(model, state)];
317
+ }
318
+
319
+ export function editKey(revision: EditRevision | undefined, row: number): string {
320
+ return `${revision?.revision ?? 0}:${row}`;
321
+ }
322
+
323
+ const diffCache = new WeakMap<EditView, SideBySideRow[]>();
324
+
325
+ function diffRowsOf(edit: EditView): SideBySideRow[] {
326
+ let rows = diffCache.get(edit);
327
+ if (!rows) {
328
+ rows = editDiffRows(edit.kind, edit.beforeText ?? "", edit.afterText ?? "");
329
+ diffCache.set(edit, rows);
330
+ }
331
+ return rows;
332
+ }
333
+
334
+ function editGlyph(edit: EditView): { glyph: string; tone: Tone } {
335
+ switch (edit.kind) {
336
+ case "kept":
337
+ return { glyph: "=", tone: "muted" };
338
+ case "removed":
339
+ return { glyph: "−", tone: "error" };
340
+ case "added":
341
+ return { glyph: "+", tone: "success" };
342
+ case "restored":
343
+ return { glyph: "↺", tone: "warning" };
344
+ default:
345
+ return { glyph: "~", tone: "accent" };
346
+ }
347
+ }
348
+
349
+ function editRowHeader(edit: EditView, selected: boolean, expanded: boolean, width: number): Line[] {
350
+ const source = edit.sourceIndex === undefined ? "" : `#${edit.sourceIndex}`;
351
+ const destination = edit.outputIndex === undefined ? "" : `→#${edit.outputIndex}`;
352
+ const role = edit.beforeRole ?? edit.afterRole ?? "message";
353
+ const size = edit.beforeTokens === undefined
354
+ ? `${fmt(edit.afterTokens ?? 0)} tok`
355
+ : edit.afterTokens === undefined
356
+ ? `${fmt(edit.beforeTokens)} tok`
357
+ : `${fmt(edit.beforeTokens)}→${fmt(edit.afterTokens)} tok`;
358
+ const { glyph, tone } = editGlyph(edit);
359
+ const where = [source, destination].filter(Boolean).join(" ");
360
+ return wrap([
361
+ span(selected ? "›" : " ", "accent"),
362
+ span(` ${expanded ? "▾" : "▸"} `),
363
+ span(`${glyph} ${where ? `${where} ` : ""}${role} · ${edit.kind} · ${size}`, tone),
364
+ ], width);
365
+ }
366
+
367
+ function editBodyLines(edit: EditView, width: number): Line[] {
368
+ const indent = " ";
369
+ if (edit.kind === "kept" || edit.kind === "restored") {
370
+ const body = edit.beforeText ?? edit.afterText;
371
+ if (body === undefined) return [];
372
+ const clipped = body.length <= KEPT_PREVIEW_CHARACTERS ? body : `${body.slice(0, KEPT_PREVIEW_CHARACTERS - 1)}…`;
373
+ const label = `${edit.kind === "kept" ? "content" : "restored original"} · ${fmt(edit.beforeTokens ?? edit.afterTokens ?? 0)} tok`;
374
+ return wrapAll([
375
+ [span(indent), span(label, edit.kind === "kept" ? "muted" : "warning")],
376
+ ...clipped.split(/\r?\n/).map((line): Line => [span(`${indent} `), span(line || " ", "dim")]),
377
+ ], width);
378
+ }
379
+ const rows = diffRowsOf(edit);
380
+ if (rows.length === 0) {
381
+ const note = edit.beforeRole !== edit.afterRole && edit.beforeRole && edit.afterRole
382
+ ? `no text change · role ${edit.beforeRole} → ${edit.afterRole}`
383
+ : "no text change (full text compared; message structure only)";
384
+ return [[span(indent), span(note, "muted")]];
385
+ }
386
+ const describe = (label: string, index: number | undefined, role: string | undefined, tokens: number | undefined) =>
387
+ index === undefined && tokens === undefined
388
+ ? `${label} · —`
389
+ : `${label}${index === undefined ? "" : ` · #${index}`}${role ? ` ${role}` : ""} · ${fmt(tokens ?? 0)} tok`;
390
+ const rendered = renderDiff(rows, Math.max(1, width - indent.length), {
391
+ leftTitle: describe("before", edit.sourceIndex, edit.beforeRole, edit.beforeTokens),
392
+ rightTitle: describe("after", edit.outputIndex, edit.afterRole, edit.afterTokens),
393
+ leftPlaceholder: "(new block)",
394
+ rightPlaceholder: "(removed from the next request)",
395
+ });
396
+ return rendered.map((line) => [span(indent), ...line]);
397
+ }
398
+
399
+ /** The edits page plus where each message row starts, so keys can keep the selection on screen. */
400
+ export function editsPage(model: PanelModel, state: PanelState, width: number): { lines: Line[]; rowStarts: number[] } {
401
+ if (model.revisions.length === 0) {
402
+ return {
403
+ lines: wrapAll([
404
+ text("No accepted live-context compression is available in this session.", "muted"),
405
+ text("Open the panel after an accepted mirror edit.", "dim"),
406
+ ], width),
407
+ rowStarts: [],
408
+ };
409
+ }
410
+ const index = revisionIndex(model, state);
411
+ const revision = model.revisions[index]!;
412
+ const tabs: Span[] = [];
413
+ model.revisions.forEach((item, position) => {
414
+ if (position > 0) tabs.push(span(" "));
415
+ tabs.push(position === index ? span(`[r${item.revision}]`, "accent", true) : span(` r${item.revision} `, "muted"));
416
+ });
417
+ const source = revision.traceSource === "recorded" ? "exact recorded provenance" : "provenance unavailable (no revisions/rN.json)";
418
+ const size = `${revision.beforeTokens === undefined ? "?" : fmt(revision.beforeTokens)}→${fmt(revision.afterTokens ?? 0)} tokens`;
419
+ const lines = wrapAll([
420
+ text("Live-context compression runs", "accent", true),
421
+ [span("Runs ← "), ...tabs, span(` → ${index + 1}/${model.revisions.length}`)],
422
+ text(`r${revision.revision} from r${revision.sourceRevision} · ${size} · ${source}`, "muted"),
423
+ text("= kept · ~ rewritten · − removed · + added · ↺ restored · Enter: side-by-side diff · a: all", "dim"),
424
+ [],
425
+ ], width);
426
+ const rowStarts: number[] = [];
427
+ if (revision.edits.length === 0) {
428
+ lines.push(...wrapAll([text("This revision is saved, but its per-message provenance is unavailable.", "warning")], width));
429
+ if (revision.mirrorText !== undefined) {
430
+ const all = revision.mirrorText.split(/\r?\n/);
431
+ lines.push([], ...wrapAll([text(`Accepted mirror text (revisions/r${revision.revision}.md):`, "muted")], width));
432
+ lines.push(...wrapAll(all.slice(0, MIRROR_TEXT_MAX_LINES).map((line) => text(` ${line}`, "dim")), width));
433
+ if (all.length > MIRROR_TEXT_MAX_LINES) lines.push(text(` ⋯ ${all.length - MIRROR_TEXT_MAX_LINES} more lines`, "dim"));
434
+ }
435
+ return { lines, rowStarts };
436
+ }
437
+ revision.edits.forEach((edit, row) => {
438
+ rowStarts.push(lines.length);
439
+ const expanded = state.expanded.has(editKey(revision, row));
440
+ lines.push(...editRowHeader(edit, row === state.editSel, expanded, width));
441
+ if (expanded) lines.push(...editBodyLines(edit, width));
442
+ });
443
+ return { lines, rowStarts };
444
+ }
445
+
446
+ // ---- settings ------------------------------------------------------------------------
447
+
448
+ /** The settings page plus the line of the selected row. */
449
+ export function settingsPage(model: PanelModel, state: PanelState, width: number): { lines: Line[]; focus: number } {
450
+ const settings = model.settings;
451
+ const header = wrapAll([
452
+ [span("Settings", "accent", true), span(" · changes apply from the next request and are saved in this session", "muted")],
453
+ ...settings.summary.map((line) => text(line, "dim")),
454
+ ...(settings.warning ? [text(`⚠ ${settings.warning}`, "warning")] : []),
455
+ // The server's own record, also when the TUI's settings check passed.
456
+ ...(model.steeringError && !settings.warning?.includes(model.steeringError)
457
+ ? [text(`⚠ steering document not loaded, the session runs without it: ${model.steeringError}`, "warning")]
458
+ : []),
459
+ [],
460
+ ], width);
461
+ if (settings.rows.length === 0) {
462
+ return { lines: [...header, ...wrapAll([text("Settings are not available here.", "muted")], width)], focus: header.length };
463
+ }
464
+ const selected = Math.max(0, Math.min(settings.rows.length - 1, state.settingSel));
465
+ const labelWidth = Math.min(Math.max(...settings.rows.map((row) => row.label.length + (row.changed ? 2 : 0))) + 2, Math.floor(width / 2));
466
+ const lines = [...header];
467
+ let focus = header.length;
468
+ settings.rows.forEach((row, index) => {
469
+ const isSelected = index === selected;
470
+ if (isSelected) focus = lines.length;
471
+ const label = `${row.label}${row.changed ? " •" : ""}`;
472
+ lines.push(normalize([
473
+ span(isSelected ? "› " : " ", "accent"),
474
+ ...fit([span(label, isSelected ? "accent" : undefined)], labelWidth),
475
+ ...truncate([span(row.value, isSelected ? "accent" : "muted", isSelected)], Math.max(1, width - labelWidth - 2)),
476
+ ]));
477
+ });
478
+ const row = settings.rows[selected]!;
479
+ if (row.description) lines.push([], ...wrapAll([text(row.description, "dim")], width));
480
+ lines.push([], ...wrapAll([text(row.choices?.length ? "Enter: next value" : "Enter: type a value", "dim")], width));
481
+ if (state.message) {
482
+ lines.push(...wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width));
483
+ }
484
+ return { lines, focus };
485
+ }
486
+
487
+ // ---- frame ---------------------------------------------------------------------------
488
+
489
+ /** The whole page at `width` (unclipped; the frame scrolls it). */
490
+ export function pageLines(model: PanelModel, state: PanelState, width: number, viewport: number): Line[] {
491
+ switch (state.page) {
492
+ case "overview":
493
+ return overviewLines(model, state, width, viewport);
494
+ case "input":
495
+ return inputLines(model, width);
496
+ case "edits":
497
+ return model.found ? editsPage(model, state, width).lines : notFoundLines(model, width);
498
+ case "settings":
499
+ return settingsPage(model, state, width).lines;
500
+ }
501
+ }
502
+
503
+ export function helpText(model: PanelModel, state: PanelState, width: number): string {
504
+ switch (state.page) {
505
+ case "overview":
506
+ return `← → select · z zoom: ${ZOOM_LABELS[effectiveZoom(model, state, width)]} · Enter open · r reload · Tab pages · q close`;
507
+ case "settings":
508
+ return "↑ ↓ select · Enter change · Tab pages · q close";
509
+ case "edits":
510
+ return "1–4 or Tab: pages · ← →: revisions · ↑ ↓: select · Enter: diff · a: diff all · q: close";
511
+ case "input":
512
+ return "1–4 or Tab: pages · ↑ ↓ or j k: scroll · g G: ends · r reload · q: close";
513
+ }
514
+ }
515
+
516
+ export function maxScroll(model: PanelModel, state: PanelState, size: PanelSize): number {
517
+ return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length - viewportHeight(size));
518
+ }
519
+
520
+ /** The framed panel, exactly `size.height` lines of `size.width` cells. */
521
+ export function renderPanel(model: PanelModel, state: PanelState, size: PanelSize): Line[] {
522
+ const width = Math.max(8, size.width);
523
+ const inner = width - 2;
524
+ const content = contentWidth({ width, height: size.height });
525
+ const viewport = viewportHeight(size);
526
+ const page = pageLines(model, state, content, viewport);
527
+ const scroll = Math.max(0, Math.min(state.scroll, page.length - viewport));
528
+ const visible = page.slice(scroll, scroll + viewport);
529
+ while (visible.length < viewport) visible.push([]);
530
+
531
+ const border = (value: string) => span(value, "border");
532
+ const row = (line: Line): Line => normalize([border("│"), span(" "), ...fit(line, inner - 1), border("│")]);
533
+ const title = ` Live Context Viewer · r${model.revision} `;
534
+ const titleText = truncate([span(title)], inner, "").map((part) => part.text).join("");
535
+ const left = Math.max(0, Math.floor((inner - titleText.length) / 2));
536
+ const right = Math.max(0, inner - titleText.length - left);
537
+ const tabs: Span[] = [];
538
+ PAGES.forEach((page, index) => {
539
+ if (index > 0) tabs.push(span(" "));
540
+ const label = `${index + 1}:${page}`;
541
+ tabs.push(page === state.page ? span(`[${label}]`, "accent", true) : span(` ${label} `, "muted"));
542
+ });
543
+ // The position goes first so a narrow help row cannot cut it off.
544
+ const position = page.length > viewport ? `lines ${scroll + 1}-${Math.min(page.length, scroll + viewport)}/${page.length} · ` : "";
545
+ return [
546
+ normalize([border(`╭${"─".repeat(left)}`), span(titleText, "accent", true), border(`${"─".repeat(right)}╮`)]),
547
+ row(tabs),
548
+ [border(`├${"─".repeat(inner)}┤`)],
549
+ ...visible.map((line) => row(line)),
550
+ [border(`├${"─".repeat(inner)}┤`)],
551
+ row([span(`${position}${helpText(model, state, content)}`, "dim")]),
552
+ [border(`╰${"─".repeat(inner)}╯`)],
553
+ ];
554
+ }
@@ -94,6 +94,10 @@ export interface ClmStatus {
94
94
  checkpoint?: { revision: number; anchorCount: number; beforeEstimate: number; afterEstimate: number };
95
95
  lastRequest?: { rawMessages: number; sentMessages: number; mirrorBlocks: number };
96
96
  steering?: SteeringDocument;
97
+ /** Settings changed for this session (`budget 20k, guard off`); absent when none. */
98
+ changed?: string;
99
+ /** Saved settings that could not be used. */
100
+ settingsWarning?: string;
97
101
  }
98
102
 
99
103
  /** One line for a toast. */
@@ -126,5 +130,7 @@ export function statusText(status: ClmStatus): string {
126
130
  lines.push(`last request: ${request.rawMessages} raw messages → ${request.sentMessages} sent, ${request.mirrorBlocks} mirror blocks`);
127
131
  }
128
132
  lines.push(steeringStatusLine(status.steering));
133
+ if (status.changed) lines.push(`Changed: ${status.changed}`);
134
+ if (status.settingsWarning) lines.push(`settings warning: ${status.settingsWarning}`);
129
135
  return lines.join("\n");
130
136
  }
package/src/projection.ts CHANGED
@@ -85,6 +85,14 @@ export function digestSourcePrefix(messages: readonly LiveContextMessage[]): str
85
85
  );
86
86
  }
87
87
 
88
+ /**
89
+ * `digestSourcePrefix` without OpenCode message ids. `Session.fork` copies every message
90
+ * under a new id, so the copied history digests the same way as the original.
91
+ */
92
+ export function digestSourceContent(messages: readonly LiveContextMessage[]): string {
93
+ return digestSourcePrefix(messages.map(({ ocMessageID: _id, ...rest }) => rest as LiveContextMessage));
94
+ }
95
+
88
96
  export function createProjectionCheckpoint(options: {
89
97
  revision: number;
90
98
  sourceMessages: readonly LiveContextMessage[];