@vigent/cli 0.0.0-stage → 0.2.1

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/dist/config.js ADDED
@@ -0,0 +1,406 @@
1
+ /**
2
+ * ~/.config/vigent/config.toml
3
+ *
4
+ * theme = "tokyonight"
5
+ *
6
+ * [ui]
7
+ * sidebar = true
8
+ * sidebar_width = 30
9
+ * number = true
10
+ * tilde = true
11
+ * icons = "unicode" # or "ascii"
12
+ *
13
+ * [highlights] # applied on top of whichever theme is active
14
+ * Normal = { bg = "NONE" } # transparent background
15
+ * CursorLine = "guibg=#2a2f3a"
16
+ * VigentSidebar = { link = "NormalFloat" }
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { parse as parseToml } from "smol-toml";
21
+ import { EFFORTS } from "./choices.js";
22
+ import { isEngineId } from "./engines.js";
23
+ import { WORKTREE_BACKENDS } from "./riftri.js";
24
+ import { KEY_MODES } from "./keys.js";
25
+ import { configDir, normalizeGroups } from "./theme.js";
26
+ import { DEFAULT_WORKTREE } from "./worktree.js";
27
+ export const DEFAULT_UI = {
28
+ sidebar: true,
29
+ sidebar_width: 30,
30
+ number: true,
31
+ tilde: true,
32
+ icons: "unicode",
33
+ timeoutlen: 1000,
34
+ // macOS Terminal.app can't show 24-bit color
35
+ termguicolors: process.env.TERM_PROGRAM !== "Apple_Terminal",
36
+ transparent: "auto",
37
+ whichkey: true,
38
+ };
39
+ export function configPath() {
40
+ return path.join(configDir(), "config.toml");
41
+ }
42
+ /** Load the config file. Problems become warnings so a typo never stops vigent from starting. */
43
+ export function loadConfig(file = configPath()) {
44
+ const config = { ui: { ...DEFAULT_UI }, highlights: {}, keys: {}, worktree: { ...DEFAULT_WORKTREE }, engines: {} };
45
+ const warnings = [];
46
+ if (!fs.existsSync(file))
47
+ return { config, warnings };
48
+ let raw;
49
+ try {
50
+ raw = parseToml(fs.readFileSync(file, "utf8"));
51
+ }
52
+ catch (err) {
53
+ warnings.push(`${path.basename(file)}: ${err.message.split("\n")[0]}`);
54
+ return { config, warnings };
55
+ }
56
+ for (const [key, value] of Object.entries(raw)) {
57
+ switch (key) {
58
+ case "theme":
59
+ case "colorscheme":
60
+ case "model":
61
+ case "leader":
62
+ case "mapleader":
63
+ if (typeof value === "string") {
64
+ const field = key === "colorscheme" ? "theme" : key === "mapleader" ? "leader" : key;
65
+ config[field] = value;
66
+ }
67
+ else
68
+ warnings.push(`config: '${key}' should be a string`);
69
+ break;
70
+ case "engine":
71
+ if (typeof value === "string" && (value === "auto" || isEngineId(value)))
72
+ config.engine = value;
73
+ else
74
+ warnings.push("config: 'engine' should be \"auto\", \"pi\", \"claude\", \"codex\" or \"opencode\"");
75
+ break;
76
+ case "engines":
77
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
78
+ warnings.push("config: [engines] should be a table");
79
+ break;
80
+ }
81
+ for (const [id, opts] of Object.entries(value)) {
82
+ if (!isEngineId(id)) {
83
+ warnings.push(`config: unknown engine [engines.${id}] (pi, claude, codex or opencode)`);
84
+ continue;
85
+ }
86
+ if (!opts || typeof opts !== "object" || Array.isArray(opts)) {
87
+ warnings.push(`config: [engines.${id}] should be a table`);
88
+ continue;
89
+ }
90
+ const e = {};
91
+ for (const [k, v] of Object.entries(opts)) {
92
+ if (k === "args" && Array.isArray(v) && v.every((a) => typeof a === "string"))
93
+ e.args = v;
94
+ else if (k === "model" && typeof v === "string")
95
+ e.model = v;
96
+ else if (k === "effort" && typeof v === "string" && EFFORTS[id].includes(v))
97
+ e.effort = v;
98
+ else if (k === "effort")
99
+ warnings.push(`config [engines.${id}]: 'effort' should be one of ${EFFORTS[id].join(", ")}`);
100
+ else
101
+ warnings.push(`config [engines.${id}]: ${k === "args" ? "'args' should be a list of strings" : k === "model" ? "'model' should be a string" : `unknown option '${k}'`}`);
102
+ }
103
+ config.engines[id] = e;
104
+ }
105
+ break;
106
+ case "keys":
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
108
+ warnings.push("config: [keys] should be a table");
109
+ break;
110
+ }
111
+ for (const [k, v] of Object.entries(value)) {
112
+ if (KEY_MODES.includes(k) && v && typeof v === "object" && !Array.isArray(v)) {
113
+ config.keys[k] = { ...config.keys[k], ...v };
114
+ }
115
+ else if (KEY_MODES.includes(k)) {
116
+ warnings.push(`config: [keys.${k}] should be a table`);
117
+ }
118
+ else {
119
+ // keys directly under [keys] are normal-mode mappings
120
+ config.keys.normal = { ...config.keys.normal, [k]: v };
121
+ }
122
+ }
123
+ break;
124
+ case "ui":
125
+ if (value && typeof value === "object") {
126
+ for (const [opt, v] of Object.entries(value)) {
127
+ const msg = setOption(config.ui, opt, v);
128
+ if (msg)
129
+ warnings.push(`config [ui]: ${msg}`);
130
+ }
131
+ }
132
+ break;
133
+ case "highlights":
134
+ config.highlights = normalizeGroups(value);
135
+ break;
136
+ case "worktree":
137
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
138
+ warnings.push("config: [worktree] should be a table");
139
+ break;
140
+ }
141
+ for (const [opt, v] of Object.entries(value)) {
142
+ const msg = setWorktreeOption(config.worktree, opt, v);
143
+ if (msg)
144
+ warnings.push(`config [worktree]: ${msg}`);
145
+ }
146
+ break;
147
+ default:
148
+ warnings.push(`config: unknown key '${key}'`);
149
+ }
150
+ }
151
+ return { config, warnings };
152
+ }
153
+ function setWorktreeOption(wt, name, value) {
154
+ switch (name) {
155
+ case "enabled":
156
+ if (typeof value !== "boolean")
157
+ return "'enabled' should be true or false";
158
+ wt.enabled = value;
159
+ return;
160
+ case "dir":
161
+ case "base":
162
+ if (typeof value !== "string")
163
+ return `'${name}' should be a string`;
164
+ if (name === "dir" && !value.trim())
165
+ return "'dir' can't be empty";
166
+ wt[name] = value;
167
+ return;
168
+ case "backend":
169
+ if (typeof value !== "string" || !WORKTREE_BACKENDS.includes(value))
170
+ return "'backend' should be \"auto\", \"riftri\" or \"git\"";
171
+ wt.backend = value;
172
+ return;
173
+ case "branch":
174
+ if (typeof value !== "string")
175
+ return "'branch' should be a string";
176
+ if (!value.includes("{name}"))
177
+ return "'branch' should contain {name}, e.g. \"{type}/{name}\" or \"agent/{name}\"";
178
+ wt.branch = value;
179
+ return;
180
+ default:
181
+ return `unknown option '${name}'`;
182
+ }
183
+ }
184
+ const ALIASES = {
185
+ sidebar: "sidebar",
186
+ sb: "sidebar",
187
+ sidebar_width: "sidebar_width",
188
+ sidebarwidth: "sidebar_width",
189
+ sbw: "sidebar_width",
190
+ number: "number",
191
+ nu: "number",
192
+ tilde: "tilde",
193
+ icons: "icons",
194
+ timeoutlen: "timeoutlen",
195
+ tm: "timeoutlen",
196
+ termguicolors: "termguicolors",
197
+ tgc: "termguicolors",
198
+ transparent: "transparent",
199
+ transp: "transparent",
200
+ whichkey: "whichkey",
201
+ wk: "whichkey",
202
+ };
203
+ /** Set one option from a config value. Returns an error message, or undefined on success. */
204
+ export function setOption(ui, name, value) {
205
+ const key = ALIASES[name];
206
+ if (!key)
207
+ return `unknown option '${name}'`;
208
+ switch (key) {
209
+ case "sidebar":
210
+ case "number":
211
+ case "tilde":
212
+ case "termguicolors":
213
+ case "whichkey":
214
+ if (typeof value !== "boolean")
215
+ return `'${name}' should be true or false`;
216
+ ui[key] = value;
217
+ return;
218
+ case "sidebar_width": {
219
+ const n = Number(value);
220
+ if (!Number.isInteger(n) || n < 16 || n > 80)
221
+ return `'${name}' should be a number from 16 to 80`;
222
+ ui.sidebar_width = n;
223
+ return;
224
+ }
225
+ case "icons":
226
+ if (value !== "unicode" && value !== "ascii")
227
+ return `'${name}' should be "unicode" or "ascii"`;
228
+ ui.icons = value;
229
+ return;
230
+ case "transparent":
231
+ if (value === true || value === false || value === "auto")
232
+ ui.transparent = value;
233
+ else if (value === "true" || value === "false")
234
+ ui.transparent = value === "true";
235
+ else
236
+ return `'${name}' should be true, false or "auto" (follow the theme)`;
237
+ return;
238
+ case "timeoutlen": {
239
+ const n = Number(value);
240
+ if (!Number.isInteger(n) || n < 0 || n > 10000)
241
+ return `'${name}' should be milliseconds from 0 to 10000`;
242
+ ui.timeoutlen = n;
243
+ return;
244
+ }
245
+ }
246
+ }
247
+ /**
248
+ * Apply a vim-style `:set` argument: `number`, `nonumber`, `number!`,
249
+ * `invnumber`, `sidebar_width=40`, `number?`. Returns the message to show.
250
+ */
251
+ export function applySetCommand(ui, arg,
252
+ /** what's on screen now, for toggling an option that follows the theme */
253
+ now = {}) {
254
+ const show = (k) => {
255
+ const v = ui[k];
256
+ return typeof v === "boolean" ? (v ? ` ${k}` : `no${k}`) : ` ${k}=${v}`;
257
+ };
258
+ if (!arg.trim()) {
259
+ return { message: Object.keys(ui).map(show).join(" ") };
260
+ }
261
+ const parts = arg.trim().split(/\s+/);
262
+ const shown = [];
263
+ for (const part of parts) {
264
+ let m;
265
+ if ((m = /^([\w]+)\?$/.exec(part))) {
266
+ const key = ALIASES[m[1]];
267
+ if (!key)
268
+ return { message: `E518: Unknown option: ${m[1]}`, error: true };
269
+ shown.push(show(key));
270
+ continue;
271
+ }
272
+ if ((m = /^([\w]+)=(.*)$/.exec(part))) {
273
+ const err = setOption(ui, m[1], m[2]);
274
+ if (err)
275
+ return { message: `E518: ${err}`, error: true };
276
+ continue;
277
+ }
278
+ if ((m = /^([\w]+)!$/.exec(part)) || (m = /^inv([\w]+)$/.exec(part))) {
279
+ const key = ALIASES[m[1]];
280
+ if (key === "transparent") {
281
+ // following the theme: flip what's on screen
282
+ ui.transparent = !(ui.transparent === "auto" ? Boolean(now.transparent) : ui.transparent);
283
+ continue;
284
+ }
285
+ if (!key || typeof ui[key] !== "boolean")
286
+ return { message: `E518: Unknown option: ${part}`, error: true };
287
+ ui[key] = !ui[key];
288
+ continue;
289
+ }
290
+ if ((m = /^([\w]+)&$/.exec(part))) {
291
+ // vim's :set option& : back to the default
292
+ const key = ALIASES[m[1]];
293
+ if (!key)
294
+ return { message: `E518: Unknown option: ${m[1]}`, error: true };
295
+ ui[key] = DEFAULT_UI[key];
296
+ continue;
297
+ }
298
+ const m2 = /^no([\w]+)$/.exec(part);
299
+ const off = Boolean(m2 && ALIASES[m2[1]]);
300
+ const key = ALIASES[off ? m2[1] : part];
301
+ if (!key)
302
+ return { message: `E518: Unknown option: ${part}`, error: true };
303
+ if (typeof ui[key] !== "boolean" && key !== "transparent") {
304
+ shown.push(show(key));
305
+ continue;
306
+ }
307
+ ui[key] = !off;
308
+ }
309
+ return { message: shown.join(" ") };
310
+ }
311
+ export const CONFIG_TEMPLATE = `# vigent config: ~/.config/vigent/config.toml
312
+ # Reference: https://github.com/farming-labs/vigent/blob/main/docs/config.md
313
+ # Check it any time with: vigent config check
314
+
315
+ # --- Theme -----------------------------------------------------------------
316
+ # Built-in: default, lazyvim (transparent), tokyonight, tokyonight-moon, gruvbox,
317
+ # catppuccin, catppuccin-latte, nord, rose-pine, kanagawa. Or any theme in ~/.config/vigent/themes, e.g. one made
318
+ # with vigent theme import (your nvim/vim colorscheme) or vigent theme new.
319
+ theme = "default"
320
+
321
+ # Model used for new pi agents.
322
+ # model = "claude-sonnet-4-5"
323
+
324
+ # --- Engines ---------------------------------------------------------------
325
+ # What runs new agents: "pi" (built in), or a coding-agent CLI you already
326
+ # have installed and signed in: "claude" (Claude Code), "codex", "opencode".
327
+ # "auto" picks pi if it has an API key, else the first signed-in CLI.
328
+ # Per agent: :new fix the race --engine codex. Status: :checkhealth
329
+ engine = "auto"
330
+
331
+ # Each CLI runs as it is, with its own login. Extra arguments and a model:
332
+ # [engines.claude]
333
+ # args = ["--permission-mode", "acceptEdits"] # the default
334
+ # model = "sonnet"
335
+ # [engines.codex]
336
+ # args = ["-c", 'sandbox_mode="workspace-write"'] # the default
337
+ # [engines.opencode]
338
+ # model = "anthropic/claude-sonnet-4-5"
339
+
340
+ # --- Leader ----------------------------------------------------------------
341
+ # What <leader> means in [keys]. Vim's default is "\\".
342
+ leader = "<Space>"
343
+
344
+ # --- Options (also live: :set nonumber, :set sidebar_width=40, ...) --------
345
+ [ui]
346
+ sidebar = true
347
+ sidebar_width = 30
348
+ number = true # session numbers in the sidebar
349
+ tilde = true # ~ on empty sidebar rows
350
+ icons = "unicode" # "ascii" for fonts without box-drawing characters
351
+ timeoutlen = 1000 # ms to wait for the rest of a multi-key mapping
352
+ # termguicolors = false # for terminals without 24-bit color: use the nearest of 256
353
+ # transparent = true # use the terminal's background with any theme ("auto": the theme decides)
354
+ whichkey = true # after <leader>, show what each next key does (like which-key.nvim)
355
+
356
+ # --- Colors (also live: :hi Group guifg=#... guibg=#...) -------------------
357
+ # Applied on top of whichever theme is active. A value is a table, a vim
358
+ # :hi string, or the name of a group to link to.
359
+ [highlights]
360
+ # Normal = { bg = "NONE" } # use the terminal's background
361
+ # CursorLine = "guibg=#2a2f3a"
362
+ # Comment = "gui=NONE" # no italic comments
363
+ # VigentModeNormal = { fg = "#1a1b26", bg = "#7aa2f7", bold = true }
364
+
365
+ # --- Worktrees --------------------------------------------------------------
366
+ # Each agent gets its own git worktree and branch, so agents never touch your
367
+ # checkout or each other. ":new fix flaky checkout test" creates the branch
368
+ # fix/flaky-checkout-test in ../<repo>-worktrees/fix-flaky-checkout-test.
369
+ [worktree]
370
+ enabled = true
371
+ dir = "../{repo}-worktrees" # relative to the repo root
372
+ branch = "{type}/{name}" # {type} = feat, fix, docs, refactor, ... from the agent's name
373
+ # base = "main" # default: the branch you have checked out
374
+ # With riftri installed (https://riftri.dev), unchanged files in agent
375
+ # worktrees share copy-on-write storage, so each new agent costs almost no
376
+ # disk. "auto" uses riftri when it can and git otherwise; "git" turns it off.
377
+ backend = "auto"
378
+
379
+ # --- Keys (also live: :nmap, :imap, :cmap, :unmap; list them with :help) ---
380
+ # Left side: vim notation, e.g. j gg <C-d> <S-Tab> <M-x> <CR> <leader>t
381
+ # Right side:
382
+ # "session.next" an action (all of them: :help)
383
+ # ":colorscheme nord<CR>" run an ex command
384
+ # ":new " open the command line with that text
385
+ # "gg" other keys, with their built-in meaning
386
+ # ["fold.open_all", "scroll.bottom"] several, in order
387
+ # false unbind a built-in key
388
+
389
+ [keys.normal]
390
+ "<leader>n" = ":new " # type a name, then Enter
391
+ "<leader>x" = ":kill<CR>"
392
+ "<leader>t" = "theme.picker"
393
+ "<leader>h" = "app.help"
394
+ "<Tab>" = "session.next"
395
+ "<S-Tab>" = "session.prev"
396
+ # "J" = "scroll.half_down"
397
+ # "K" = "scroll.half_up"
398
+ # "q" = false # stop q from showing the quit hint
399
+
400
+ [keys.insert]
401
+ # "jk" = "<Esc>" # leave INSERT by typing jk quickly
402
+ # "<C-s>" = "edit.submit"
403
+
404
+ [keys.command]
405
+ # "<C-p>" = "edit.clear"
406
+ `;
package/dist/demo.js ADDED
@@ -0,0 +1,81 @@
1
+ /** Scripted demo content so the UI can be explored without API keys. */
2
+ import { DemoSession } from "./session.js";
3
+ import { branchName } from "./worktree.js";
4
+ export function createDemoSessions() {
5
+ const auth = new DemoSession("auth-refactor", "claude-sonnet-5-5", {
6
+ branch: branchName("auth-refactor"),
7
+ stat: { files: 2, added: 41, removed: 18 },
8
+ });
9
+ auth.pushUser("Refactor the session middleware to use the new token store.");
10
+ auth.play([
11
+ { delay: 300, kind: "state", state: "running" },
12
+ {
13
+ delay: 400,
14
+ kind: "agent",
15
+ streamMs: 90,
16
+ text: "I'll start by reading the current middleware to understand how sessions are validated today.",
17
+ },
18
+ {
19
+ delay: 500,
20
+ kind: "tool",
21
+ tool: {
22
+ name: "read",
23
+ summary: "src/middleware/auth.ts",
24
+ output: "import { verify } from '../lib/jwt';\n\nexport async function authMiddleware(req, res, next) {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token) return res.status(401).end();\n // legacy: validates against the jwt secret directly\n const claims = verify(token, process.env.JWT_SECRET);\n req.user = claims.sub;\n next();\n}",
25
+ },
26
+ },
27
+ {
28
+ delay: 900,
29
+ kind: "tool",
30
+ tool: {
31
+ name: "edit",
32
+ summary: "src/middleware/auth.ts (+41 −18)",
33
+ output: "- const claims = verify(token, process.env.JWT_SECRET);\n- req.user = claims.sub;\n+ const session = await tokenStore.resolve(token);\n+ if (!session) return res.status(401).end();\n+ req.user = session.userId;",
34
+ },
35
+ },
36
+ {
37
+ delay: 800,
38
+ kind: "tool",
39
+ tool: { name: "bash", summary: "npm test -- auth", output: "PASS test/middleware/auth.test.ts\n ✓ rejects missing token (3 ms)\n ✓ resolves session from TokenStore (5 ms)\n ✓ rejects revoked tokens (2 ms)\n\nTests: 14 passed, 14 total" },
40
+ },
41
+ {
42
+ delay: 700,
43
+ kind: "agent",
44
+ streamMs: 80,
45
+ text: [
46
+ "Done. The middleware now resolves tokens through **TokenStore** instead of verifying JWTs inline:",
47
+ "- revoked tokens are rejected with a `401`",
48
+ "- all 14 tests pass",
49
+ "```ts",
50
+ "const session = await tokenStore.resolve(token);",
51
+ "if (!session) return res.status(401).end();",
52
+ "```",
53
+ "Want me to update the docs too?",
54
+ ].join("\n"),
55
+ },
56
+ { delay: 300, kind: "state", state: "waiting" },
57
+ ]);
58
+ const flaky = new DemoSession("fix-flaky-test", "claude-haiku-5-5", {
59
+ branch: branchName("fix-flaky-test"),
60
+ stat: { files: 1, added: 6, removed: 2 },
61
+ });
62
+ flaky.pushUser("The checkout e2e test fails ~1 in 5 runs. Find the race and fix it.");
63
+ flaky.play([
64
+ { delay: 1200, kind: "state", state: "running" },
65
+ { delay: 600, kind: "agent", streamMs: 110, text: "Reproducing first — running the suite 10 times to catch the failure." },
66
+ {
67
+ delay: 1500,
68
+ kind: "tool",
69
+ tool: { name: "bash", summary: "for i in $(seq 10); do npm run e2e -- checkout; done", output: "run 1..6: pass\nrun 7: FAIL expected cart badge '3', got '2'\nrun 8..10: pass" },
70
+ },
71
+ { delay: 2500, kind: "agent", streamMs: 120, text: "Caught it: the badge assertion runs before the cart mutation settles. The test clicks 'add' and asserts immediately instead of awaiting the network idle. Fixing with a proper waitFor on the cart response." },
72
+ { delay: 200, kind: "state", state: "waiting" },
73
+ ]);
74
+ const docs = new DemoSession("docs-pass", "claude-haiku-5-5");
75
+ docs.pushUser("Sweep the README for stale CLI flags.");
76
+ docs.play([
77
+ { delay: 500, kind: "agent", text: "Swept README.md and docs/cli.md: fixed 3 stale flags (--watch → --dev, removed --legacy-peer, documented --json). Committed as docs: refresh CLI flags." },
78
+ { delay: 100, kind: "state", state: "idle" },
79
+ ]);
80
+ return [auth, flaky, docs];
81
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * :diff layouts for the floating diff window.
3
+ *
4
+ * unified: git's own output, colored.
5
+ * split: side by side, old on the left and new on the right, with line
6
+ * numbers; removed and added lines are paired up row by row.
7
+ *
8
+ * Both return the line indices of hunk headers so ]c / [c can jump.
9
+ */
10
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ function lineGroup(line) {
12
+ if (line.startsWith("+++") || line.startsWith("---"))
13
+ return "Title";
14
+ if (line.startsWith("+"))
15
+ return "Added";
16
+ if (line.startsWith("-"))
17
+ return "Removed";
18
+ if (line.startsWith("@@"))
19
+ return "Special";
20
+ if (/^(diff --git|index |new file|deleted file|similarity|rename )/.test(line))
21
+ return "Comment";
22
+ return "NormalFloat";
23
+ }
24
+ export function unifiedView(text) {
25
+ const raw = text.replace(/\n$/, "").split("\n");
26
+ return {
27
+ lines: raw.map((l) => [[lineGroup(l), l]]),
28
+ jumps: raw.map((l, i) => (l.startsWith("@@") ? i : -1)).filter((i) => i >= 0),
29
+ };
30
+ }
31
+ function cell(no, text, width, group) {
32
+ if (text === undefined)
33
+ return [["NonText", " ".repeat(width)]];
34
+ const num = no === undefined ? " " : String(no).padStart(4);
35
+ const body = truncateToWidth(text.replace(/\t/g, " "), Math.max(0, width - 5), "…");
36
+ return [
37
+ ["LineNr", `${num} `],
38
+ [group, body + " ".repeat(Math.max(0, width - 5 - visibleWidth(body)))],
39
+ ];
40
+ }
41
+ /** Side-by-side layout for a window `width` columns wide (inside its border). */
42
+ export function splitView(text, width) {
43
+ const col = Math.max(12, Math.floor((width - 3) / 2));
44
+ const lines = [];
45
+ const jumps = [];
46
+ let oldNo = 0;
47
+ let newNo = 0;
48
+ let dels = [];
49
+ let adds = [];
50
+ const row = (l, r, lg = "NormalFloat", rg = "NormalFloat") => lines.push([...cell(l?.no, l?.text, col, lg), ["NonText", " │ "], ...cell(r?.no, r?.text, col, rg)]);
51
+ const flush = () => {
52
+ for (let i = 0; i < Math.max(dels.length, adds.length); i++)
53
+ row(dels[i], adds[i], "Removed", "Added");
54
+ dels = [];
55
+ adds = [];
56
+ };
57
+ for (const line of text.replace(/\n$/, "").split("\n")) {
58
+ if (line.startsWith("diff --git")) {
59
+ flush();
60
+ if (lines.length > 0)
61
+ lines.push([]);
62
+ continue;
63
+ }
64
+ const file = /^\+\+\+ (?:b\/)?(.*)$/.exec(line);
65
+ if (file) {
66
+ lines.push([["Title", file[1] === "/dev/null" ? "(deleted file)" : file[1]]]);
67
+ continue;
68
+ }
69
+ if (/^(---|index |new file|deleted file|similarity|rename |\\ No newline)/.test(line))
70
+ continue;
71
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(line);
72
+ if (hunk) {
73
+ flush();
74
+ oldNo = Number(hunk[1]);
75
+ newNo = Number(hunk[2]);
76
+ jumps.push(lines.length);
77
+ lines.push([["Special", line]]);
78
+ continue;
79
+ }
80
+ if (line.startsWith("-")) {
81
+ dels.push({ no: oldNo++, text: line.slice(1) });
82
+ }
83
+ else if (line.startsWith("+")) {
84
+ adds.push({ no: newNo++, text: line.slice(1) });
85
+ }
86
+ else {
87
+ flush();
88
+ const t = line.startsWith(" ") ? line.slice(1) : line;
89
+ row({ no: oldNo++, text: t }, { no: newNo++, text: t });
90
+ }
91
+ }
92
+ flush();
93
+ return { lines, jumps };
94
+ }