opencode-clm 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +9 -1
- package/README.md +85 -258
- package/index.ts +68 -14
- package/package.json +54 -9
- package/src/atomic.ts +27 -0
- package/src/budget.ts +129 -5
- package/src/clm.ts +374 -20
- package/src/commands.ts +18 -0
- package/src/context-document.ts +31 -4
- package/src/mirror-store.ts +1 -1
- package/src/overrides.ts +193 -0
- package/src/panel/command.ts +69 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +103 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +547 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +454 -0
- package/src/panel/view.ts +545 -0
- package/src/presentation.ts +10 -1
- package/src/session-files.ts +184 -0
- package/src/settings-table.ts +406 -0
- package/src/settings.ts +2 -2
- package/src/state.ts +49 -2
- package/src/tui/data.ts +161 -0
- package/src/tui/intercept.ts +56 -0
- package/src/tui/panel.ts +194 -0
- package/src/tui/plugin.ts +280 -0
- package/tui.ts +7 -0
package/src/overrides.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session settings changes on disk: `<session dir>/overrides.json` =
|
|
3
|
+
* `{ version: 1, overrides }`, holding only what differs from the base (the plugin options,
|
|
4
|
+
* environment and defaults; for `editing`, state.json's `enabled`). A separate file because
|
|
5
|
+
* state.json belongs to the server's save queue: the TUI writes this file and never state.json.
|
|
6
|
+
*
|
|
7
|
+
* Writers (the TUI panel, typed `/clm config` in the TUI, and the server's `/clm config`)
|
|
8
|
+
* all go through `changeSetting`: parse → merge → drop base-equal keys → stage (resolve the
|
|
9
|
+
* policies, load a steering document or compact prompt strictly) → write atomically. A
|
|
10
|
+
* change that fails any step is reported and not written. The server reads the file before
|
|
11
|
+
* each request (`ClmSession.refreshSettings`). Staging follows pi-clm src/index.ts
|
|
12
|
+
* `changeSetting` / `stageSettings` (MIT, Copyright 2026 Emanuel Casco); written for this package.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, stat } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { writeJsonAtomic } from "./atomic.ts";
|
|
18
|
+
import { loadCompactPrompt } from "./compact.ts";
|
|
19
|
+
import {
|
|
20
|
+
applyOverrides,
|
|
21
|
+
describeSetting,
|
|
22
|
+
mergeOverrides,
|
|
23
|
+
sanitizeOverrides,
|
|
24
|
+
settingDescriptor,
|
|
25
|
+
unknownSettingText,
|
|
26
|
+
type ClmOverrides,
|
|
27
|
+
type FormatContext,
|
|
28
|
+
type SettingsValues,
|
|
29
|
+
} from "./settings-table.ts";
|
|
30
|
+
import type { ClmSettings } from "./settings.ts";
|
|
31
|
+
import { loadSteeringDocument, type SteeringDocument } from "./steering.ts";
|
|
32
|
+
|
|
33
|
+
export const OVERRIDES_FILE = "overrides.json";
|
|
34
|
+
export const OVERRIDES_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
export interface OverridesRead {
|
|
37
|
+
overrides: ClmOverrides;
|
|
38
|
+
/** Keys dropped as invalid, or a description of why the whole file was unusable. */
|
|
39
|
+
warning?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function overridesPath(sessionDirectory: string): string {
|
|
43
|
+
return join(sessionDirectory, OVERRIDES_FILE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reads and sanitizes overrides.json. Missing → no overrides. Never throws. */
|
|
47
|
+
export async function readOverrides(sessionDirectory: string): Promise<OverridesRead> {
|
|
48
|
+
const path = overridesPath(sessionDirectory);
|
|
49
|
+
let text: string;
|
|
50
|
+
try {
|
|
51
|
+
text = await readFile(path, "utf8");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { overrides: {} };
|
|
54
|
+
return { overrides: {}, warning: `could not read ${path}: ${describe(error)}` };
|
|
55
|
+
}
|
|
56
|
+
return parseOverridesText(text, path);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseOverridesText(text: string, path = OVERRIDES_FILE): OverridesRead {
|
|
60
|
+
let parsed: unknown;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(text);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { overrides: {}, warning: `${path} is not valid JSON (${describe(error)}); using the defaults` };
|
|
65
|
+
}
|
|
66
|
+
if (!parsed || typeof parsed !== "object" || (parsed as { version?: unknown }).version !== OVERRIDES_VERSION) {
|
|
67
|
+
return { overrides: {}, warning: `${path} has an unknown format; using the defaults` };
|
|
68
|
+
}
|
|
69
|
+
const { overrides, ignored } = sanitizeOverrides((parsed as { overrides?: unknown }).overrides);
|
|
70
|
+
return ignored.length > 0 ? { overrides, warning: `ignored invalid saved settings: ${ignored.join(", ")}` } : { overrides };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function writeOverrides(sessionDirectory: string, overrides: ClmOverrides): Promise<void> {
|
|
74
|
+
await writeJsonAtomic(overridesPath(sessionDirectory), { version: OVERRIDES_VERSION, overrides });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface StagedSettings {
|
|
78
|
+
settings: ClmSettings;
|
|
79
|
+
steering?: SteeringDocument;
|
|
80
|
+
/** Set when the steering document could not be loaded (non-strict staging only). */
|
|
81
|
+
steeringError?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolves base + overrides without activating anything. Strict (an interactive change):
|
|
86
|
+
* a steering document or compact prompt that does not load rejects the change. Not strict
|
|
87
|
+
* (reading the file before a request): a steering failure is recorded and the session runs
|
|
88
|
+
* protocol-only. `loaded` reuses an already loaded document for the same path.
|
|
89
|
+
*/
|
|
90
|
+
export function stageSettings(
|
|
91
|
+
base: ClmSettings,
|
|
92
|
+
overrides: ClmOverrides,
|
|
93
|
+
options: { strict: boolean; loaded?: SteeringDocument },
|
|
94
|
+
): StagedSettings {
|
|
95
|
+
const settings = applyOverrides(base, overrides);
|
|
96
|
+
const staged: StagedSettings = { settings };
|
|
97
|
+
const path = settings.steeringPath;
|
|
98
|
+
if (path) {
|
|
99
|
+
if (options.loaded && !options.strict && options.loaded.path === path) {
|
|
100
|
+
staged.steering = options.loaded;
|
|
101
|
+
} else {
|
|
102
|
+
try {
|
|
103
|
+
staged.steering = loadSteeringDocument(path);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (options.strict) throw new Error(`steering document not loaded: ${describe(error)}`);
|
|
106
|
+
staged.steeringError = describe(error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (options.strict && settings.compactPromptPath && settings.compactPromptPath !== base.compactPromptPath) {
|
|
111
|
+
loadCompactPrompt(settings.compactPromptPath);
|
|
112
|
+
}
|
|
113
|
+
return staged;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ChangeRequest {
|
|
117
|
+
/** The session directory (`<mirrorDir>/clm-<id>`). */
|
|
118
|
+
sessionDirectory: string;
|
|
119
|
+
/** Settings at load (options, environment, defaults). */
|
|
120
|
+
base: ClmSettings;
|
|
121
|
+
/** `editing` base: state.json's `enabled`. */
|
|
122
|
+
baseEditing: boolean;
|
|
123
|
+
/** Relative paths resolve against the project directory. */
|
|
124
|
+
projectDirectory: string;
|
|
125
|
+
format?: FormatContext;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ChangeResult {
|
|
129
|
+
overrides: ClmOverrides;
|
|
130
|
+
values: SettingsValues;
|
|
131
|
+
/** `Budget: 20k`. */
|
|
132
|
+
text: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function valuesOf(base: ClmSettings, baseEditing: boolean, overrides: ClmOverrides, settings?: ClmSettings): SettingsValues {
|
|
136
|
+
return { editing: overrides.editing ?? baseEditing, settings: settings ?? applyOverrides(base, overrides) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `/clm config <setting> <value>` and the panel's apply: validate, then write. Throws a
|
|
141
|
+
* user-facing message; nothing is written then.
|
|
142
|
+
*/
|
|
143
|
+
export async function changeSetting(request: ChangeRequest, name: string, text: string): Promise<ChangeResult> {
|
|
144
|
+
const descriptor = settingDescriptor(name);
|
|
145
|
+
if (!descriptor) throw new Error(unknownSettingText(name));
|
|
146
|
+
const parsed = descriptor.parse(text, { directory: request.projectDirectory });
|
|
147
|
+
const current = await readOverrides(request.sessionDirectory);
|
|
148
|
+
const baseValues = valuesOf(request.base, request.baseEditing, {}, request.base);
|
|
149
|
+
const overrides = mergeOverrides(baseValues, current.overrides, parsed);
|
|
150
|
+
const staged = stageSettings(request.base, overrides, { strict: true });
|
|
151
|
+
try {
|
|
152
|
+
await writeOverrides(request.sessionDirectory, overrides);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new Error(`Settings unchanged: saving ${overridesPath(request.sessionDirectory)} failed (${describe(error)}).`);
|
|
155
|
+
}
|
|
156
|
+
const values = valuesOf(request.base, request.baseEditing, overrides, staged.settings);
|
|
157
|
+
return { overrides, values, text: `${descriptor.label}: ${descriptor.format(values, request.format)}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `/clm config reset`: drop this session's changes. */
|
|
161
|
+
export async function resetSettings(sessionDirectory: string): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
await writeOverrides(sessionDirectory, {});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new Error(`Settings unchanged: saving ${overridesPath(sessionDirectory)} failed (${describe(error)}).`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Current values of one session, read from its overrides.json (non-strict, never throws for
|
|
171
|
+
* bad files). `mtimeMs`: when overrides.json was last written, if it exists.
|
|
172
|
+
*/
|
|
173
|
+
export async function sessionValues(sessionDirectory: string, base: ClmSettings, baseEditing: boolean): Promise<{ values: SettingsValues; warning?: string; overrides: ClmOverrides; mtimeMs?: number }> {
|
|
174
|
+
const read = await readOverrides(sessionDirectory);
|
|
175
|
+
const mtimeMs = await stat(overridesPath(sessionDirectory)).then((info) => info.mtimeMs, () => undefined);
|
|
176
|
+
const at = mtimeMs !== undefined ? { mtimeMs } : {};
|
|
177
|
+
try {
|
|
178
|
+
return { values: valuesOf(base, baseEditing, read.overrides), overrides: read.overrides, ...(read.warning ? { warning: read.warning } : {}), ...at };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { values: valuesOf(base, baseEditing, {}, base), overrides: {}, warning: `ignored saved settings: ${describe(error)}`, ...at };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `/clm config <setting>`: `Label: value — description`. */
|
|
185
|
+
export function showSetting(name: string, values: SettingsValues, format?: FormatContext): string {
|
|
186
|
+
const descriptor = settingDescriptor(name);
|
|
187
|
+
if (!descriptor) throw new Error(unknownSettingText(name));
|
|
188
|
+
return describeSetting(descriptor, values, format);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function describe(error: unknown): string {
|
|
192
|
+
return error instanceof Error ? error.message : String(error);
|
|
193
|
+
}
|
|
@@ -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
|
+
}
|