opencode-clm 0.1.1 → 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.
@@ -0,0 +1,69 @@
1
+ // Parses a typed `/clm …` line into what the TUI should do with it. Pure; written for
2
+ // this package (the subcommands follow pi-clm's `/clm` and `/clm config`).
3
+
4
+ export type Page = "overview" | "input" | "edits" | "settings";
5
+ export const PAGES: readonly Page[] = ["overview", "input", "edits", "settings"];
6
+
7
+ export type ClmCommand =
8
+ /** Open the panel on a page. */
9
+ | { kind: "open"; page: Page }
10
+ /** `/clm config <setting>`: show one setting. */
11
+ | { kind: "config-show"; setting: string }
12
+ /** `/clm config <setting> <value…>`: change one setting. */
13
+ | { kind: "config-set"; setting: string; value: string }
14
+ | { kind: "config-reset" }
15
+ | { kind: "status" }
16
+ | { kind: "path" }
17
+ | { kind: "enable"; enabled: boolean }
18
+ /** Must reach the server command (it rewrites state.json): let the line through. */
19
+ | { kind: "server"; args: string }
20
+ | { kind: "usage"; text: string };
21
+
22
+ export const CLM_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | config [setting [value]]]";
23
+
24
+ /** Page names and the short forms pi accepts. */
25
+ const PAGE_ALIASES: Record<string, Page> = {
26
+ overview: "overview",
27
+ timeline: "overview",
28
+ input: "input",
29
+ edits: "edits",
30
+ edit: "edits",
31
+ settings: "settings",
32
+ };
33
+
34
+ /**
35
+ * `undefined` when the text is not a `/clm` line (e.g. `/clm-compact` or plain text);
36
+ * otherwise the action. Leading and trailing whitespace is ignored; subcommand names are
37
+ * case-insensitive, values keep their case (paths).
38
+ */
39
+ export function parseClmCommand(text: string): ClmCommand | undefined {
40
+ const match = /^\/clm(?:\s+([\s\S]*))?$/.exec(text.trim());
41
+ if (!match) return undefined;
42
+ const args = (match[1] ?? "").trim();
43
+ if (args === "") return { kind: "open", page: "overview" };
44
+ const [head = "", ...rest] = args.split(/\s+/);
45
+ const word = head.toLowerCase();
46
+ const page = PAGE_ALIASES[word];
47
+ if (page && rest.length === 0) return { kind: "open", page };
48
+ switch (word) {
49
+ case "status":
50
+ return rest.length === 0 ? { kind: "status" } : { kind: "usage", text: CLM_USAGE };
51
+ case "path":
52
+ return rest.length === 0 ? { kind: "path" } : { kind: "usage", text: CLM_USAGE };
53
+ case "on":
54
+ case "off":
55
+ return rest.length === 0 ? { kind: "enable", enabled: word === "on" } : { kind: "usage", text: CLM_USAGE };
56
+ case "reset":
57
+ return { kind: "server", args };
58
+ case "config": {
59
+ if (rest.length === 0) return { kind: "open", page: "settings" };
60
+ const setting = rest[0]!.toLowerCase();
61
+ if (setting === "reset" && rest.length === 1) return { kind: "config-reset" };
62
+ // The value is the rest of the line as typed, inner spaces kept.
63
+ const value = args.replace(/^\S+\s+\S+\s*/, "");
64
+ return value === "" ? { kind: "config-show", setting } : { kind: "config-set", setting, value };
65
+ }
66
+ default:
67
+ return { kind: "usage", text: CLM_USAGE };
68
+ }
69
+ }
@@ -0,0 +1,422 @@
1
+ // Adapted from pi-clm src/diff.ts (MIT, Copyright 2026 Emanuel Casco).
2
+ //
3
+ // Line and word diffs for the `/clm` edits page, laid out side by side (unified below
4
+ // MIN_SIDE_BY_SIDE_WIDTH). A small self-contained LCS diff, so the plugin needs no diff
5
+ // dependency. Output is styled lines (lines.ts tones) rather than ANSI strings.
6
+
7
+ import { fit, normalize, padEnd, span, trimEnd, wrap, type Line, type Span, type Tone } from "./lines.ts";
8
+
9
+ export interface DiffLine {
10
+ /** One-based line number in its own text. */
11
+ line: number;
12
+ text: string;
13
+ }
14
+
15
+ export type DiffOp =
16
+ | { kind: "same"; left: DiffLine; right: DiffLine }
17
+ | { kind: "removed"; left: DiffLine }
18
+ | { kind: "added"; right: DiffLine };
19
+
20
+ export type SideBySideRow =
21
+ | { kind: "same"; left: DiffLine; right: DiffLine }
22
+ | { kind: "changed"; left: DiffLine; right: DiffLine }
23
+ | { kind: "removed"; left: DiffLine }
24
+ | { kind: "added"; right: DiffLine }
25
+ | { kind: "skipped"; count: number }
26
+ /** Rows cut by `limitRows`; `count` is the number of source lines not shown. */
27
+ | { kind: "omitted"; count: number };
28
+
29
+ /** Above this many LCS cells the unmatched middle is treated as one replaced block. */
30
+ const MAX_LINE_CELLS = 1_000_000;
31
+ const MAX_WORD_CELLS = 40_000;
32
+ /** Longer lines are clipped for display (a minified blob would otherwise wrap into thousands of rows). */
33
+ export const MAX_LINE_CHARACTERS = 2_000;
34
+ /** Rows shown per edited message before an explicit "preview truncated" row. */
35
+ export const EDITED_DIFF_MAX_ROWS = 400;
36
+ /** Rows shown for a removed or added message (one-sided, so "expand all" stays cheap). */
37
+ export const ONE_SIDED_DIFF_MAX_ROWS = 60;
38
+ /** Below this width the diff is shown unified (one column). */
39
+ export const MIN_SIDE_BY_SIDE_WIDTH = 60;
40
+
41
+ function splitLines(text: string): string[] {
42
+ if (text === "") return [];
43
+ return text.replace(/\r\n?/g, "\n").split("\n");
44
+ }
45
+
46
+ /** LCS alignment of `a` and `b` as index pairs; undefined when the table would exceed `maxCells`. */
47
+ function lcsPairs<T>(a: readonly T[], b: readonly T[], maxCells: number): Array<[number, number]> | undefined {
48
+ const n = a.length;
49
+ const m = b.length;
50
+ if (n === 0 || m === 0) return [];
51
+ if ((n + 1) * (m + 1) > maxCells) return undefined;
52
+ const stride = m + 1;
53
+ const table = new Uint32Array((n + 1) * stride);
54
+ for (let i = n - 1; i >= 0; i--) {
55
+ for (let j = m - 1; j >= 0; j--) {
56
+ table[i * stride + j] = a[i] === b[j]
57
+ ? table[(i + 1) * stride + j + 1]! + 1
58
+ : Math.max(table[(i + 1) * stride + j]!, table[i * stride + j + 1]!);
59
+ }
60
+ }
61
+ const pairs: Array<[number, number]> = [];
62
+ let i = 0;
63
+ let j = 0;
64
+ while (i < n && j < m) {
65
+ if (a[i] === b[j]) {
66
+ pairs.push([i++, j++]);
67
+ } else if (table[(i + 1) * stride + j]! >= table[i * stride + j + 1]!) {
68
+ i++;
69
+ } else {
70
+ j++;
71
+ }
72
+ }
73
+ return pairs;
74
+ }
75
+
76
+ /** Line diff. The common prefix and suffix are matched first, so a small edit in a large body stays cheap. */
77
+ export function diffLines(before: string, after: string): DiffOp[] {
78
+ const a = splitLines(before);
79
+ const b = splitLines(after);
80
+ let prefix = 0;
81
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
82
+ let suffix = 0;
83
+ while (
84
+ suffix < a.length - prefix &&
85
+ suffix < b.length - prefix &&
86
+ a[a.length - 1 - suffix] === b[b.length - 1 - suffix]
87
+ ) suffix++;
88
+
89
+ const ops: DiffOp[] = [];
90
+ const same = (i: number, j: number) => ops.push({
91
+ kind: "same",
92
+ left: { line: i + 1, text: a[i]! },
93
+ right: { line: j + 1, text: b[j]! },
94
+ });
95
+ for (let index = 0; index < prefix; index++) same(index, index);
96
+
97
+ const middleA = a.slice(prefix, a.length - suffix);
98
+ const middleB = b.slice(prefix, b.length - suffix);
99
+ const pairs = lcsPairs(middleA, middleB, MAX_LINE_CELLS) ?? [];
100
+ let i = 0;
101
+ let j = 0;
102
+ const flushTo = (untilI: number, untilJ: number) => {
103
+ for (; i < untilI; i++) ops.push({ kind: "removed", left: { line: prefix + i + 1, text: middleA[i]! } });
104
+ for (; j < untilJ; j++) ops.push({ kind: "added", right: { line: prefix + j + 1, text: middleB[j]! } });
105
+ };
106
+ for (const [pi, pj] of pairs) {
107
+ flushTo(pi, pj);
108
+ same(prefix + i, prefix + j);
109
+ i++;
110
+ j++;
111
+ }
112
+ flushTo(middleA.length, middleB.length);
113
+ for (let index = 0; index < suffix; index++) same(a.length - suffix + index, b.length - suffix + index);
114
+ return ops;
115
+ }
116
+
117
+ /**
118
+ * Pairs removed/added runs into `changed` rows (like `diff -y`) and folds unchanged runs
119
+ * longer than `2 × context` into a `skipped` row. Returns [] when nothing differs.
120
+ */
121
+ export function sideBySideRows(ops: readonly DiffOp[], context = 3): SideBySideRow[] {
122
+ if (!ops.some((op) => op.kind !== "same")) return [];
123
+ const paired: SideBySideRow[] = [];
124
+ for (let index = 0; index < ops.length;) {
125
+ const op = ops[index]!;
126
+ if (op.kind === "same") {
127
+ paired.push(op);
128
+ index++;
129
+ continue;
130
+ }
131
+ const removed: DiffLine[] = [];
132
+ const added: DiffLine[] = [];
133
+ for (let next = ops[index]; next?.kind === "removed"; next = ops[++index]) removed.push(next.left);
134
+ for (let next = ops[index]; next?.kind === "added"; next = ops[++index]) added.push(next.right);
135
+ const count = Math.max(removed.length, added.length);
136
+ for (let k = 0; k < count; k++) {
137
+ const left = removed[k];
138
+ const right = added[k];
139
+ if (left && right) paired.push({ kind: "changed", left, right });
140
+ else if (left) paired.push({ kind: "removed", left });
141
+ else if (right) paired.push({ kind: "added", right });
142
+ }
143
+ }
144
+
145
+ const rows: SideBySideRow[] = [];
146
+ for (let index = 0; index < paired.length;) {
147
+ if (paired[index]!.kind !== "same") {
148
+ rows.push(paired[index++]!);
149
+ continue;
150
+ }
151
+ let end = index;
152
+ while (end < paired.length && paired[end]!.kind === "same") end++;
153
+ const run = paired.slice(index, end);
154
+ // Context is kept only on the side that touches a change.
155
+ const keepHead = index === 0 ? 0 : context;
156
+ const keepTail = end === paired.length ? 0 : context;
157
+ if (run.length <= keepHead + keepTail + 1) {
158
+ rows.push(...run);
159
+ } else {
160
+ rows.push(...run.slice(0, keepHead));
161
+ rows.push({ kind: "skipped", count: run.length - keepHead - keepTail });
162
+ rows.push(...run.slice(run.length - keepTail));
163
+ }
164
+ index = end;
165
+ }
166
+ return rows;
167
+ }
168
+
169
+ function rowLineCount(row: SideBySideRow): number {
170
+ return row.kind === "skipped" || row.kind === "omitted" ? row.count : 1;
171
+ }
172
+
173
+ /** Keeps at most `maxRows` rows and replaces the rest with one `omitted` row. */
174
+ export function limitRows(rows: readonly SideBySideRow[], maxRows: number): SideBySideRow[] {
175
+ if (rows.length <= maxRows) return [...rows];
176
+ const kept = rows.slice(0, Math.max(0, maxRows));
177
+ const count = rows.slice(kept.length).reduce((total, row) => total + rowLineCount(row), 0);
178
+ return [...kept, { kind: "omitted", count }];
179
+ }
180
+
181
+ export type EditDiffKind = "kept" | "edited" | "removed" | "restored" | "normalized" | "added";
182
+
183
+ /**
184
+ * Diff rows for one edit row of the edits page, bounded for display: removed and added
185
+ * messages compare against an empty side and show fewer rows. The diff itself runs on the
186
+ * full text, so a change anywhere is found.
187
+ */
188
+ export function editDiffRows(kind: EditDiffKind, before: string, after: string): SideBySideRow[] {
189
+ const left = kind === "added" ? "" : before;
190
+ const right = kind === "removed" ? "" : after;
191
+ const maxRows = kind === "removed" || kind === "added" ? ONE_SIDED_DIFF_MAX_ROWS : EDITED_DIFF_MAX_ROWS;
192
+ return limitRows(sideBySideRows(diffLines(left, right)), maxRows);
193
+ }
194
+
195
+ export interface WordSpan {
196
+ text: string;
197
+ changed: boolean;
198
+ }
199
+
200
+ function tokenize(text: string): string[] {
201
+ return text.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? [];
202
+ }
203
+
204
+ function mergeWordSpans(tokens: readonly string[], changed: readonly boolean[]): WordSpan[] {
205
+ const spans: WordSpan[] = [];
206
+ tokens.forEach((token, index) => {
207
+ const flag = changed[index]!;
208
+ const last = spans.at(-1);
209
+ if (last && last.changed === flag) last.text += token;
210
+ else spans.push({ text: token, changed: flag });
211
+ });
212
+ return spans;
213
+ }
214
+
215
+ /** Word-level spans for a changed line pair; all changed when the lines are too long to align. */
216
+ export function diffWords(before: string, after: string): { left: WordSpan[]; right: WordSpan[] } {
217
+ const a = tokenize(before);
218
+ const b = tokenize(after);
219
+ const pairs = lcsPairs(a, b, MAX_WORD_CELLS);
220
+ if (!pairs) {
221
+ return {
222
+ left: before ? [{ text: before, changed: true }] : [],
223
+ right: after ? [{ text: after, changed: true }] : [],
224
+ };
225
+ }
226
+ const leftChanged = a.map(() => true);
227
+ const rightChanged = b.map(() => true);
228
+ for (const [i, j] of pairs) {
229
+ leftChanged[i] = false;
230
+ rightChanged[j] = false;
231
+ }
232
+ // Whitespace alone between two changed words is not a meaningful match.
233
+ const markIsolatedSpace = (tokens: string[], changed: boolean[]) => {
234
+ for (let k = 1; k < tokens.length - 1; k++) {
235
+ if (!changed[k] && /^\s+$/.test(tokens[k]!) && changed[k - 1] && changed[k + 1]) changed[k] = true;
236
+ }
237
+ };
238
+ markIsolatedSpace(a, leftChanged);
239
+ markIsolatedSpace(b, rightChanged);
240
+ return { left: mergeWordSpans(a, leftChanged), right: mergeWordSpans(b, rightChanged) };
241
+ }
242
+
243
+ export interface DiffTitles {
244
+ leftTitle: string;
245
+ rightTitle: string;
246
+ /** Shown in the empty column when one side has no text at all. */
247
+ leftPlaceholder?: string;
248
+ rightPlaceholder?: string;
249
+ }
250
+
251
+ const CONTEXT: Tone = "muted";
252
+ const LINE_NUMBER: Tone = "dim";
253
+ const MUTED: Tone = "muted";
254
+ const TITLE: Tone = "accent";
255
+
256
+ function sanitize(text: string): string {
257
+ // Tabs become three spaces; other control characters would corrupt the layout.
258
+ const clean = text.replace(/\t/g, " ").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "�");
259
+ return clean.length <= MAX_LINE_CHARACTERS
260
+ ? clean
261
+ : `${clean.slice(0, MAX_LINE_CHARACTERS)}… (+${clean.length - MAX_LINE_CHARACTERS} chars)`;
262
+ }
263
+
264
+ function clipLine(text: string): string {
265
+ return text.length <= MAX_LINE_CHARACTERS ? text : text.slice(0, MAX_LINE_CHARACTERS);
266
+ }
267
+
268
+ function omittedLabel(count: number): string {
269
+ return `⋯ diff preview truncated: ${count} more line${count === 1 ? "" : "s"} not shown`;
270
+ }
271
+
272
+ function skippedLabel(count: number): string {
273
+ return `⋯ ${count} unchanged line${count === 1 ? "" : "s"}`;
274
+ }
275
+
276
+ /** Changed words get the emphasis tone of their side. */
277
+ function wordLine(spans: readonly WordSpan[], side: "left" | "right"): Line {
278
+ const base: Tone = side === "left" ? "diffDel" : "diffAdd";
279
+ const emphasis: Tone = side === "left" ? "diffDelEm" : "diffAddEm";
280
+ return normalize(spans.map((part) => span(sanitize(part.text), part.changed ? emphasis : base)));
281
+ }
282
+
283
+ interface RowSides {
284
+ left?: { line: number; text: Line };
285
+ right?: { line: number; text: Line };
286
+ marker: Span;
287
+ }
288
+
289
+ function rowSides(row: Exclude<SideBySideRow, { kind: "skipped" | "omitted" }>): RowSides {
290
+ switch (row.kind) {
291
+ case "same":
292
+ return {
293
+ left: { line: row.left.line, text: [span(sanitize(row.left.text), CONTEXT)] },
294
+ right: { line: row.right.line, text: [span(sanitize(row.right.text), CONTEXT)] },
295
+ marker: span("│", MUTED),
296
+ };
297
+ case "removed":
298
+ return { left: { line: row.left.line, text: [span(sanitize(row.left.text), "diffDel")] }, marker: span("−", "diffDel") };
299
+ case "added":
300
+ return { right: { line: row.right.line, text: [span(sanitize(row.right.text), "diffAdd")] }, marker: span("+", "diffAdd") };
301
+ case "changed": {
302
+ const words = diffWords(clipLine(row.left.text), clipLine(row.right.text));
303
+ return {
304
+ left: { line: row.left.line, text: wordLine(words.left, "left") },
305
+ right: { line: row.right.line, text: wordLine(words.right, "right") },
306
+ marker: span("~", TITLE),
307
+ };
308
+ }
309
+ }
310
+ }
311
+
312
+ function lineNumberWidth(rows: readonly SideBySideRow[]): number {
313
+ let max = 1;
314
+ for (const row of rows) {
315
+ if (row.kind === "skipped" || row.kind === "omitted") continue;
316
+ if ("left" in row) max = Math.max(max, row.left.line);
317
+ if ("right" in row) max = Math.max(max, row.right.line);
318
+ }
319
+ return String(max).length;
320
+ }
321
+
322
+ function wrapped(text: Line, width: number): Line[] {
323
+ return text.length === 0 ? [[]] : wrap(text, width);
324
+ }
325
+
326
+ /**
327
+ * Two columns of `line-number text`, separated by a marker column: `│` unchanged, `~`
328
+ * changed pair (changed words in the emphasis tone), `−` removed, `+` added. Every line
329
+ * is at most `width` cells wide.
330
+ */
331
+ export function renderSideBySide(rows: readonly SideBySideRow[], width: number, titles: DiffTitles): Line[] {
332
+ const leftWidth = Math.max(8, Math.floor((width - 3) / 2));
333
+ const rightWidth = Math.max(8, width - 3 - leftWidth);
334
+ const gutter = lineNumberWidth(rows);
335
+ const leftText = Math.max(1, leftWidth - gutter - 1);
336
+ const rightText = Math.max(1, rightWidth - gutter - 1);
337
+
338
+ const lines: Line[] = [
339
+ normalize([...fit([span(titles.leftTitle, TITLE)], leftWidth), span(" │ ", MUTED), ...fit([span(titles.rightTitle, TITLE)], rightWidth)]),
340
+ [span(`${"─".repeat(leftWidth)}─┼─${"─".repeat(rightWidth)}`, MUTED)],
341
+ ];
342
+ const content = (row: SideBySideRow) => row.kind !== "skipped" && row.kind !== "omitted";
343
+ let placeholderLeft = rows.some((row) => content(row) && row.kind !== "added") ? undefined : titles.leftPlaceholder;
344
+ let placeholderRight = rows.some((row) => content(row) && row.kind !== "removed") ? undefined : titles.rightPlaceholder;
345
+ const number = (line: number | undefined): Span =>
346
+ span(line === undefined ? " ".repeat(gutter) : String(line).padStart(gutter, " "), LINE_NUMBER);
347
+
348
+ for (const row of rows) {
349
+ if (row.kind === "skipped") {
350
+ const label = [span(skippedLabel(row.count), MUTED)];
351
+ lines.push(trimEnd(normalize([...fit(label, leftWidth), span(" ┆ ", MUTED), ...fit(label, rightWidth)])));
352
+ continue;
353
+ }
354
+ if (row.kind === "omitted") {
355
+ // Spans both columns: it describes the diff, not one side.
356
+ lines.push(...wrap([span(omittedLabel(row.count), TITLE)], width));
357
+ continue;
358
+ }
359
+ const sides = rowSides(row);
360
+ // Placeholders wrap inside their own column like text.
361
+ const leftLines = sides.left
362
+ ? wrapped(sides.left.text, leftText)
363
+ : placeholderLeft ? wrap([span(placeholderLeft, MUTED)], leftText) : [[]];
364
+ const rightLines = sides.right
365
+ ? wrapped(sides.right.text, rightText)
366
+ : placeholderRight ? wrap([span(placeholderRight, MUTED)], rightText) : [[]];
367
+ if (!sides.left) placeholderLeft = undefined;
368
+ if (!sides.right) placeholderRight = undefined;
369
+ const height = Math.max(leftLines.length, rightLines.length);
370
+ for (let k = 0; k < height; k++) {
371
+ const left = padEnd([number(k === 0 ? sides.left?.line : undefined), span(" "), ...(leftLines[k] ?? [])], leftWidth);
372
+ const right = [number(k === 0 ? sides.right?.line : undefined), span(" "), ...(rightLines[k] ?? [])];
373
+ const marker = k === 0 ? sides.marker : span("│", MUTED);
374
+ lines.push(trimEnd(normalize([...left, span(" "), marker, span(" "), ...right])));
375
+ }
376
+ }
377
+ return lines;
378
+ }
379
+
380
+ /** One-column `-`/`+` layout for narrow widths. */
381
+ export function renderUnified(rows: readonly SideBySideRow[], width: number, titles: DiffTitles): Line[] {
382
+ const gutter = lineNumberWidth(rows);
383
+ const textWidth = Math.max(1, width - gutter - 2);
384
+ const lines: Line[] = [...wrap([span(`${titles.leftTitle} → ${titles.rightTitle}`, TITLE)], width)];
385
+ const emit = (prefix: Span, line: number | undefined, text: Line) => {
386
+ wrapped(text, textWidth).forEach((part, k) => {
387
+ const number = span(k === 0 && line !== undefined ? String(line).padStart(gutter, " ") : " ".repeat(gutter), LINE_NUMBER);
388
+ lines.push(trimEnd(normalize([k === 0 ? prefix : span(" "), number, span(" "), ...part])));
389
+ });
390
+ };
391
+ for (const row of rows) {
392
+ switch (row.kind) {
393
+ case "skipped":
394
+ lines.push([span(`${" ".repeat(gutter + 2)}${skippedLabel(row.count)}`, MUTED)]);
395
+ break;
396
+ case "omitted":
397
+ lines.push(...wrap([span(omittedLabel(row.count), TITLE)], width));
398
+ break;
399
+ case "same":
400
+ emit(span(" "), row.left.line, [span(sanitize(row.left.text), CONTEXT)]);
401
+ break;
402
+ case "removed":
403
+ emit(span("-", "diffDel"), row.left.line, [span(sanitize(row.left.text), "diffDel")]);
404
+ break;
405
+ case "added":
406
+ emit(span("+", "diffAdd"), row.right.line, [span(sanitize(row.right.text), "diffAdd")]);
407
+ break;
408
+ case "changed": {
409
+ const words = diffWords(clipLine(row.left.text), clipLine(row.right.text));
410
+ emit(span("-", "diffDel"), row.left.line, wordLine(words.left, "left"));
411
+ emit(span("+", "diffAdd"), row.right.line, wordLine(words.right, "right"));
412
+ break;
413
+ }
414
+ }
415
+ }
416
+ return lines;
417
+ }
418
+
419
+ /** Side by side at `MIN_SIDE_BY_SIDE_WIDTH` and wider, unified below. */
420
+ export function renderDiff(rows: readonly SideBySideRow[], width: number, titles: DiffTitles): Line[] {
421
+ return width >= MIN_SIDE_BY_SIDE_WIDTH ? renderSideBySide(rows, width, titles) : renderUnified(rows, width, titles);
422
+ }
@@ -0,0 +1,103 @@
1
+ // Shapes of the session-directory files the `/clm` panel reads (v0.2.0 design §3). The
2
+ // server writes events.jsonl and state.json today; snapshot.json and revisions/rN.json are
3
+ // the v0.2.0 additions and may be absent (v0.1 sessions). Written for this package.
4
+
5
+ import type { ContextEditSourceKind } from "../types.ts";
6
+
7
+ export const EVENTS_FILE = "events.jsonl";
8
+ export const SNAPSHOT_FILE = "snapshot.json";
9
+ export const REVISIONS_DIRECTORY = "revisions";
10
+ export const MIRROR_FILE = "LIVE_CONTEXT.md";
11
+
12
+ /** One parsed line of events.jsonl: `{ at, event, ...fields }`. Unknown fields are kept. */
13
+ export type ClmEvent = Record<string, unknown> & { at?: string; event?: string };
14
+
15
+ /** One message row of `revisions/rN.json`. Indices are zero-based, as in `ContextEditTrace`. */
16
+ export interface RevisionFileRow {
17
+ kind: ContextEditSourceKind | "added";
18
+ sourceIndex?: number;
19
+ outputIndex?: number;
20
+ role: string;
21
+ /** Role before and after, when they differ (`role` is then the after role). */
22
+ beforeRole?: string;
23
+ afterRole?: string;
24
+ beforeTokens?: number;
25
+ afterTokens?: number;
26
+ /** Full rendered text before the edit (absent for `added`). */
27
+ before?: string;
28
+ /** Full rendered text after the edit (absent for `removed`). */
29
+ after?: string;
30
+ /** Full text when before and after are identical (kept rows); `before` and `after` are then absent. */
31
+ text?: string;
32
+ }
33
+
34
+ /** `revisions/rN.json`, written once per accepted edit (0600). */
35
+ export interface RevisionFile {
36
+ version: 1;
37
+ revision: number;
38
+ sourceRevision: number;
39
+ at: string;
40
+ beforeTokens: number;
41
+ afterTokens: number;
42
+ rows: RevisionFileRow[];
43
+ }
44
+
45
+ export interface SnapshotInputMessage {
46
+ index: number;
47
+ role: string;
48
+ tokens: number;
49
+ /** At most 120 characters. */
50
+ preview: string;
51
+ }
52
+
53
+ /** `snapshot.json`, replaced atomically on every request. */
54
+ export interface SnapshotFile {
55
+ at: string;
56
+ request: number;
57
+ revision: number;
58
+ enabled: boolean;
59
+ /** `budget` is the configured budget before any raise; the panel recomputes the raise with `budgetFit`. */
60
+ budget: { budget: number; reserve: number; limit: number; source: string };
61
+ calibration: { factor: number; samples: number };
62
+ steering?: { name: string; hash: string; path: string };
63
+ sizes: { estimated: number; observedPrevious?: number };
64
+ input: {
65
+ raw: number;
66
+ sent: number;
67
+ suffix: number;
68
+ rawTokens: number;
69
+ effectiveTokens: number;
70
+ messages: SnapshotInputMessage[];
71
+ };
72
+ /** Fixed overhead per request (system prompt + tool schemas), tokens, once measured. */
73
+ overhead?: number;
74
+ /**
75
+ * The server's base settings (options, environment, defaults; before overrides), in
76
+ * overrides.json form (`settingsAsOverrides`). The TUI validates changes against it.
77
+ */
78
+ base?: Record<string, unknown>;
79
+ }
80
+
81
+ /**
82
+ * Raw contents of one session directory, as read by session-files.ts. Values are unchecked
83
+ * JSON; model.ts validates them field by field.
84
+ */
85
+ export interface SessionFiles {
86
+ sessionID: string;
87
+ directory: string;
88
+ /** False when the session directory does not exist. */
89
+ found: boolean;
90
+ events: ClmEvent[];
91
+ /** events.jsonl lines that did not parse (torn or corrupt). */
92
+ skippedEventLines: number;
93
+ state?: unknown;
94
+ snapshot?: unknown;
95
+ /** `revisions/rN.json` contents by revision number. */
96
+ revisions: Map<number, unknown>;
97
+ /** `revisions/rN.md` (the accepted mirror text, v0.1) by revision number. */
98
+ revisionTexts: Map<number, string>;
99
+ /** Path of the mirror file, whether or not it exists. */
100
+ mirrorPath: string;
101
+ /** Read problems other than missing files. */
102
+ warnings: string[];
103
+ }