@vigent/cli 0.0.0-stage → 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/dist/app.js ADDED
@@ -0,0 +1,2487 @@
1
+ /**
2
+ * The Vigent app: a full-screen modal TUI component rendered by pi-tui.
3
+ *
4
+ * Layout (vim-style):
5
+ * ┌ sidebar │ transcript ┐
6
+ * │ statusline │
7
+ * └ cmdline ┘
8
+ */
9
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@mariozechner/pi-tui";
10
+ import { applySetCommand, CONFIG_TEMPLATE, configPath, DEFAULT_UI } from "./config.js";
11
+ import { setTomlValue, tomlValue } from "./config-edit.js";
12
+ import { SettingsFloat } from "./settings.js";
13
+ import { TextFloat } from "./float.js";
14
+ import { ACTIONS, formatKeys, KEY_MODES, KeySequencer, Keymap, keyFromInput, parseKeys, replayKeys, tokenText, } from "./keys.js";
15
+ import * as path from "node:path";
16
+ import * as fs from "node:fs";
17
+ import { setDetached } from "./agents.js";
18
+ import { VERSION_TEXT } from "./version.js";
19
+ import { backendName, formatBytes, riftriBackends, RIFTRI_INSTALL } from "./riftri.js";
20
+ import { ENGINE_NAMES, isEngineId } from "./engines.js";
21
+ import { copyToClipboard } from "./clipboard.js";
22
+ import { splitView, unifiedView } from "./diffview.js";
23
+ import { TREE_GLYPHS } from "./tree.js";
24
+ import { transcriptMarkdown } from "./export.js";
25
+ import { renderMarkdown } from "./markdown.js";
26
+ import { ChoicePicker, PICKER_ICONS, ThemePicker } from "./picker.js";
27
+ import { EFFORTS, effortChoices, modelChoices } from "./choices.js";
28
+ import { mergeWorktree, openPullRequest } from "./review.js";
29
+ import { BUILTIN_THEMES, extractPalette, listThemes, loadTheme, RESET, sgr, themeFileFor, themesDir } from "./theme.js";
30
+ import { parseHighlightCommand } from "./vimcolors.js";
31
+ import { DEFAULT_WORKTREE, diffText, findWorktree, listWorktrees, removeWorktree, } from "./worktree.js";
32
+ const MODE_GROUP = {
33
+ NORMAL: "VigentModeNormal",
34
+ INSERT: "VigentModeInsert",
35
+ VISUAL: "VigentModeVisual",
36
+ COMMAND: "VigentModeCommand",
37
+ SEARCH: "VigentModeCommand",
38
+ };
39
+ const ICONS = {
40
+ unicode: {
41
+ spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
42
+ state: { waiting: "◐", idle: "○", paused: "‖", error: "✗" },
43
+ tee: "├╴",
44
+ last: "└╴",
45
+ pipe: "│ ",
46
+ sep: "│",
47
+ worktree: "⎇ ",
48
+ prompt: "❯ ",
49
+ folded: "▸",
50
+ open: "▾",
51
+ cursor: "▍",
52
+ minus: "−",
53
+ bullet: "•",
54
+ queued: "⧗",
55
+ open_agent: "▸",
56
+ here: "●",
57
+ ok: "●",
58
+ warn: "◐",
59
+ off: "○",
60
+ },
61
+ ascii: {
62
+ spinner: ["|", "/", "-", "\\"],
63
+ state: { waiting: "~", idle: "o", paused: "=", error: "x" },
64
+ tee: "|-",
65
+ last: "`-",
66
+ pipe: "| ",
67
+ sep: "|",
68
+ worktree: "wt:",
69
+ prompt: "> ",
70
+ folded: "+",
71
+ open: "-",
72
+ cursor: "_",
73
+ minus: "-",
74
+ bullet: "-",
75
+ queued: "+",
76
+ open_agent: ">",
77
+ here: "*",
78
+ ok: "+",
79
+ warn: "~",
80
+ off: "-",
81
+ },
82
+ };
83
+ export class App {
84
+ tui;
85
+ terminal;
86
+ painter;
87
+ ui;
88
+ keymap;
89
+ workspace;
90
+ mode = "NORMAL";
91
+ sessions = [];
92
+ focusIdx = 0;
93
+ altIdx = 0;
94
+ cmdBuf = "";
95
+ inputBuf = "";
96
+ message = "";
97
+ messageGroup = "Comment";
98
+ scrollOffset = 0; // lines from bottom
99
+ searchQuery = "";
100
+ matches = [];
101
+ matchIdx = -1;
102
+ spinnerFrame = 0;
103
+ spinnerTimer;
104
+ statTimer;
105
+ paneCache = { lines: [], key: "" };
106
+ onQuit = () => { };
107
+ /** reopen an agent in an existing worktree (`:attach`) */
108
+ attachSession = () => {
109
+ throw new Error("attaching isn't available here");
110
+ };
111
+ /** create an agent; `worktree: false` runs it in the current checkout */
112
+ newSession = () => {
113
+ throw new Error("newSession factory not set");
114
+ };
115
+ /** pi, Claude Code, Codex, OpenCode: installed? signed in? (undefined until checked) */
116
+ engines;
117
+ /** checks the engines again (:checkhealth) */
118
+ detectEngines;
119
+ /** can agent worktrees be copy-on-write here (riftri)? undefined until checked */
120
+ storage;
121
+ /** checks riftri again (:checkhealth) */
122
+ checkStorage;
123
+ /** scripted agents, no model behind them (shown in the statusline) */
124
+ demo = false;
125
+ /** model and effort for new agents, per engine ([engines.<id>] in config.toml, :model / :effort with no agent) */
126
+ newAgentModel = {};
127
+ newAgentEffort = {};
128
+ /** save an agent's settings (model, effort) so they survive a restart */
129
+ persist = () => { };
130
+ /** `engine` from config.toml or :settings; "auto" lets defaultEngine() pick */
131
+ preferredEngine = "auto";
132
+ /** key changes made in :settings, saved with w: lhs -> rhs (false unbinds) */
133
+ keyChanges = new Map();
134
+ /** the engine :new uses without --engine */
135
+ defaultEngine = () => "pi";
136
+ seq;
137
+ searchBackward = false;
138
+ /** VISUAL selection, as line indices into the transcript pane */
139
+ visual = { anchor: 0, cursor: 0 };
140
+ /** a y/n question in the command line (dd, quitting with agents running) */
141
+ confirm;
142
+ /** copies text to the system clipboard; returns how (replaced in tests) */
143
+ clipboard = (text) => copyToClipboard(text, (d) => this.terminal.write(d));
144
+ constructor(tui, terminal, painter, ui = { ...DEFAULT_UI }, keymap = new Keymap(), workspace = { config: DEFAULT_WORKTREE }) {
145
+ this.tui = tui;
146
+ this.terminal = terminal;
147
+ this.painter = painter;
148
+ this.ui = ui;
149
+ this.keymap = keymap;
150
+ this.workspace = workspace;
151
+ this.applyColorMode();
152
+ this.seq = new KeySequencer(keymap, {
153
+ mode: () => this.keyMode(),
154
+ run: (binding, keys) => this.runBinding(binding, keys),
155
+ timeoutlen: () => this.ui.timeoutlen,
156
+ changed: () => this.tui.requestRender(),
157
+ hint: () => {
158
+ if (!this.ui.whichkey)
159
+ return false;
160
+ this.tui.requestRender();
161
+ return true;
162
+ },
163
+ hintDelay: () => 300,
164
+ });
165
+ }
166
+ get icons() {
167
+ return ICONS[this.ui.icons];
168
+ }
169
+ spinner() {
170
+ const frames = this.icons.spinner;
171
+ return frames[this.spinnerFrame % frames.length];
172
+ }
173
+ // ------------------------------------------------------------------ state
174
+ get focusedSession() {
175
+ return this.sessions[this.focusIdx];
176
+ }
177
+ addSession(s, focus = true) {
178
+ this.sessions.push(s);
179
+ s.onChange(() => {
180
+ this.syncSpinner();
181
+ this.tui.requestRender();
182
+ });
183
+ if (focus) {
184
+ this.altIdx = this.focusIdx;
185
+ this.focusIdx = this.sessions.length - 1;
186
+ }
187
+ s.refreshStat(0); // reopened agents show their changes right away
188
+ if (s.worktree?.path && !this.statTimer) {
189
+ // edits made outside the agent (you, your editor) show up too
190
+ this.statTimer = setInterval(() => {
191
+ for (const x of this.sessions)
192
+ x.refreshStat(0);
193
+ }, 5000);
194
+ this.statTimer.unref?.();
195
+ }
196
+ this.syncSpinner();
197
+ this.tui.requestRender();
198
+ }
199
+ focusSession(idx) {
200
+ if (idx < 0 || idx >= this.sessions.length || idx === this.focusIdx)
201
+ return;
202
+ this.altIdx = this.focusIdx;
203
+ this.focusIdx = idx;
204
+ this.scrollOffset = 0;
205
+ this.clearSearch();
206
+ this.focusedSession?.refreshStat(0);
207
+ }
208
+ syncSpinner() {
209
+ const running = this.sessions.some((s) => s.state === "running");
210
+ if (running && !this.spinnerTimer) {
211
+ this.spinnerTimer = setInterval(() => {
212
+ this.spinnerFrame = (this.spinnerFrame + 1) % 120;
213
+ this.tui.requestRender();
214
+ }, 120);
215
+ this.spinnerTimer.unref?.(); // animation only; the terminal keeps the app alive
216
+ }
217
+ else if (!running && this.spinnerTimer) {
218
+ clearInterval(this.spinnerTimer);
219
+ this.spinnerTimer = undefined;
220
+ }
221
+ }
222
+ setTheme(theme) {
223
+ this.painter.setTheme(theme);
224
+ this.repaint();
225
+ }
226
+ repaint() {
227
+ this.invalidate();
228
+ this.tui.requestRender(true);
229
+ }
230
+ /** Show a startup message (config warnings etc.) in the command line. */
231
+ notify(text, group = "WarningMsg") {
232
+ this.setMessage(text, group);
233
+ this.tui.requestRender();
234
+ }
235
+ stopTimers() {
236
+ if (this.spinnerTimer)
237
+ clearInterval(this.spinnerTimer);
238
+ if (this.statTimer)
239
+ clearInterval(this.statTimer);
240
+ }
241
+ invalidate() {
242
+ this.paneCache.key = "";
243
+ }
244
+ // ------------------------------------------------------------------ input
245
+ keyMode() {
246
+ if (this.mode === "NORMAL")
247
+ return "normal";
248
+ if (this.mode === "INSERT")
249
+ return "insert";
250
+ if (this.mode === "VISUAL")
251
+ return "visual";
252
+ return "command";
253
+ }
254
+ applyColorMode() {
255
+ this.painter.setColorMode(this.ui.termguicolors ? "truecolor" : "256");
256
+ this.painter.setTransparency(this.ui.transparent ?? "auto");
257
+ }
258
+ /** Ask a y/n question in the command line, like vim's confirm prompts. */
259
+ ask(question, onYes) {
260
+ this.seq.reset();
261
+ this.confirm = { question, onYes };
262
+ }
263
+ handleInput(data) {
264
+ if (this.confirm) {
265
+ const { onYes } = this.confirm;
266
+ this.confirm = undefined;
267
+ this.message = "";
268
+ if (data === "y" || data === "Y")
269
+ onYes();
270
+ this.tui.requestRender();
271
+ return;
272
+ }
273
+ if (this.dashboardKey(data)) {
274
+ this.tui.requestRender();
275
+ return;
276
+ }
277
+ if (data.startsWith("\x1b[200~")) {
278
+ // bracketed paste: insert as text, never as mappings
279
+ this.typeText(data.slice(6).replace(/\x1b\[201~$/, "").replace(/\r?\n/g, " "));
280
+ this.tui.requestRender();
281
+ return;
282
+ }
283
+ const key = keyFromInput(data);
284
+ if (!key)
285
+ return;
286
+ if (this.mode === "NORMAL" && this.seq.pendingKeys.length === 0)
287
+ this.message = "";
288
+ for (const k of this.splitEscape(key))
289
+ this.seq.feed(k);
290
+ this.tui.requestRender();
291
+ }
292
+ /**
293
+ * Esc followed quickly by a key arrives as one Alt-key sequence (<M-j>).
294
+ * Vim resolves this with a timeout; here, an Alt key that nothing is
295
+ * mapped to is read as Esc and then the key, which is what was typed.
296
+ */
297
+ splitEscape(key) {
298
+ const m = /^<M-(.+)>$/.exec(key);
299
+ if (!m)
300
+ return [key];
301
+ const r = this.keymap.lookup(this.keyMode(), [...this.seq.pendingKeys, key]);
302
+ if (r.exact || r.prefix)
303
+ return [key];
304
+ const rest = m[1];
305
+ return ["<Esc>", ...parseKeys([...rest].length === 1 ? rest : `<${rest}>`)];
306
+ }
307
+ typeText(text) {
308
+ if (this.mode === "INSERT")
309
+ this.inputBuf += text;
310
+ else if (this.mode === "COMMAND" || this.mode === "SEARCH")
311
+ this.cmdBuf += text;
312
+ }
313
+ runBinding(binding, keys) {
314
+ if (!binding) {
315
+ for (const k of keys) {
316
+ const text = tokenText(k);
317
+ if (text !== undefined)
318
+ this.typeText(text);
319
+ }
320
+ return;
321
+ }
322
+ for (const rhs of binding.rhs)
323
+ this.runRhs(rhs);
324
+ }
325
+ runRhs(rhs) {
326
+ switch (rhs.kind) {
327
+ case "action":
328
+ this.runAction(rhs.name, rhs.arg);
329
+ return;
330
+ case "ex":
331
+ if (rhs.execute) {
332
+ this.exec(rhs.cmd);
333
+ }
334
+ else {
335
+ this.mode = "COMMAND";
336
+ this.cmdBuf = rhs.cmd;
337
+ }
338
+ return;
339
+ case "keys":
340
+ replayKeys(this.keymap, { mode: () => this.keyMode(), run: (b, k) => this.runBinding(b, k) }, rhs.keys);
341
+ return;
342
+ case "nop":
343
+ return;
344
+ }
345
+ }
346
+ /** Every built-in behavior, by the name used in keymaps. */
347
+ runAction(name, arg) {
348
+ const half = Math.floor(this.contentHeight() / 2);
349
+ const page = Math.max(1, this.contentHeight() - 2);
350
+ const count = this.sessions.length;
351
+ switch (name) {
352
+ case "mode.normal":
353
+ if (this.mode === "COMMAND" || this.mode === "SEARCH")
354
+ this.cmdBuf = "";
355
+ this.mode = "NORMAL";
356
+ return;
357
+ case "mode.insert":
358
+ this.mode = "INSERT";
359
+ return;
360
+ case "mode.command":
361
+ this.mode = "COMMAND";
362
+ this.cmdBuf = "";
363
+ return;
364
+ case "mode.search":
365
+ case "mode.search_back":
366
+ this.mode = "SEARCH";
367
+ this.searchBackward = name === "mode.search_back";
368
+ this.cmdBuf = "";
369
+ return;
370
+ case "mode.visual": {
371
+ const lines = this.buildPaneLines(this.paneWidth());
372
+ if (lines.length === 0)
373
+ return;
374
+ const bottom = this.viewRange(lines.length).end - 1;
375
+ this.visual = { anchor: bottom, cursor: bottom };
376
+ this.mode = "VISUAL";
377
+ return;
378
+ }
379
+ case "cursor.down":
380
+ return this.moveCursor(1);
381
+ case "cursor.up":
382
+ return this.moveCursor(-1);
383
+ case "cursor.half_down":
384
+ return this.moveCursor(half);
385
+ case "cursor.half_up":
386
+ return this.moveCursor(-half);
387
+ case "cursor.top":
388
+ return this.moveCursor(-Number.MAX_SAFE_INTEGER);
389
+ case "cursor.bottom":
390
+ return this.moveCursor(Number.MAX_SAFE_INTEGER);
391
+ case "visual.swap":
392
+ this.visual = { anchor: this.visual.cursor, cursor: this.visual.anchor };
393
+ return this.revealLine(this.visual.cursor);
394
+ case "visual.inner_block": {
395
+ const lines = this.buildPaneLines(this.paneWidth());
396
+ const block = lines[this.visual.cursor]?.block;
397
+ if (block === undefined) {
398
+ this.setMessage("no code block under the cursor", "WarningMsg");
399
+ return;
400
+ }
401
+ const idx = lines.map((l, i) => (l.block === block && !l.label ? i : -1)).filter((i) => i >= 0);
402
+ this.visual = { anchor: idx[0], cursor: idx[idx.length - 1] };
403
+ return;
404
+ }
405
+ case "visual.yank": {
406
+ const lines = this.buildPaneLines(this.paneWidth());
407
+ const [a, b] = [Math.min(this.visual.anchor, this.visual.cursor), Math.max(this.visual.anchor, this.visual.cursor)];
408
+ this.yank(lines.slice(a, b + 1).map((l) => l.plain).join("\n"), b - a + 1);
409
+ this.mode = "NORMAL";
410
+ return;
411
+ }
412
+ case "message.next":
413
+ case "message.prev":
414
+ return this.jumpMessage(name === "message.next" ? 1 : -1);
415
+ case "yank.reply": {
416
+ const reply = [...(this.focusedSession?.items ?? [])].reverse().find((i) => i.kind === "agent" && i.text);
417
+ if (!reply)
418
+ this.setMessage("no reply to yank yet", "WarningMsg");
419
+ else
420
+ this.yank(reply.text, reply.text.split("\n").length);
421
+ return;
422
+ }
423
+ case "yank.block": {
424
+ const lines = this.buildPaneLines(this.paneWidth());
425
+ const { start, end } = this.viewRange(lines.length);
426
+ let block;
427
+ for (let i = end - 1; i >= 0 && block === undefined; i--)
428
+ if (lines[i].block !== undefined && i >= start - 1)
429
+ block = lines[i].block;
430
+ if (block === undefined)
431
+ for (let i = lines.length - 1; i >= 0 && block === undefined; i--)
432
+ block = lines[i].block;
433
+ if (block === undefined) {
434
+ this.setMessage("no code block to yank", "WarningMsg");
435
+ return;
436
+ }
437
+ const code = lines.filter((l) => l.block === block && !l.label).map((l) => l.plain.replace(/^ {2,4}/, ""));
438
+ this.yank(code.join("\n"), code.length);
439
+ return;
440
+ }
441
+ case "scroll.down":
442
+ return this.scrollBy(-1);
443
+ case "scroll.up":
444
+ return this.scrollBy(1);
445
+ case "scroll.half_down":
446
+ return this.scrollBy(-half);
447
+ case "scroll.half_up":
448
+ return this.scrollBy(half);
449
+ case "scroll.page_down":
450
+ return this.scrollBy(-page);
451
+ case "scroll.page_up":
452
+ return this.scrollBy(page);
453
+ case "scroll.top":
454
+ return this.scrollTop();
455
+ case "scroll.bottom":
456
+ this.scrollOffset = 0;
457
+ return;
458
+ case "session.next":
459
+ if (count > 0)
460
+ this.focusSession((this.focusIdx + 1) % count);
461
+ return;
462
+ case "session.prev":
463
+ if (count > 0)
464
+ this.focusSession((this.focusIdx - 1 + count) % count);
465
+ return;
466
+ case "session.alternate":
467
+ return this.focusSession(this.altIdx);
468
+ case "session.goto": {
469
+ const n = Number(arg);
470
+ if (!Number.isInteger(n) || n < 1)
471
+ this.setMessage(`session.goto needs a number, got '${arg ?? ""}'`, "ErrorMsg");
472
+ else
473
+ this.focusSession(n - 1);
474
+ return;
475
+ }
476
+ case "session.new":
477
+ return this.exec("new");
478
+ case "session.kill": {
479
+ const s = this.focusedSession;
480
+ if (!s)
481
+ return;
482
+ const keep = s.worktree?.path ? " (its worktree is kept)" : "";
483
+ return this.ask(`Kill '${s.name}'${keep}? [y/N]`, () => this.exec("kill"));
484
+ }
485
+ case "session.pause":
486
+ return this.exec("pause");
487
+ case "session.resume":
488
+ return this.exec("resume");
489
+ case "session.abort": {
490
+ const s = this.focusedSession;
491
+ if (s?.state === "running") {
492
+ s.abort();
493
+ this.setMessage(`stopped '${s.name}'`, "WarningMsg");
494
+ }
495
+ return;
496
+ }
497
+ case "fold.toggle":
498
+ return this.focusedSession?.toggleFolds();
499
+ case "fold.open_all":
500
+ return this.setAllFolds(false);
501
+ case "fold.close_all":
502
+ return this.setAllFolds(true);
503
+ case "search.next":
504
+ return this.gotoMatch(1);
505
+ case "search.prev":
506
+ return this.gotoMatch(-1);
507
+ case "search.clear":
508
+ return this.clearSearch();
509
+ case "edit.submit":
510
+ return this.submit();
511
+ case "edit.backspace":
512
+ if (this.mode === "INSERT") {
513
+ this.inputBuf = this.inputBuf.slice(0, -1);
514
+ }
515
+ else if (this.cmdBuf.length === 0) {
516
+ this.mode = "NORMAL"; // vim: backspace on an empty command line leaves it
517
+ }
518
+ else {
519
+ this.cmdBuf = this.cmdBuf.slice(0, -1);
520
+ }
521
+ return;
522
+ case "edit.delete_word":
523
+ if (this.mode === "INSERT")
524
+ this.inputBuf = this.inputBuf.replace(/\S*\s*$/, "");
525
+ else
526
+ this.cmdBuf = this.cmdBuf.replace(/\S*\s*$/, "");
527
+ return;
528
+ case "edit.clear":
529
+ if (this.mode === "INSERT")
530
+ this.inputBuf = "";
531
+ else
532
+ this.cmdBuf = "";
533
+ return;
534
+ case "theme.picker":
535
+ return this.openThemePicker();
536
+ case "app.help":
537
+ return this.openHelp();
538
+ case "app.redraw":
539
+ return this.repaint();
540
+ case "app.quit":
541
+ return this.exec("qa");
542
+ case "app.quit_force":
543
+ return this.onQuit();
544
+ case "app.dashboard":
545
+ return this.openDashboard();
546
+ case "session.tree":
547
+ return this.openTree();
548
+ case "app.quit_hint":
549
+ this.setMessage("Type :q and press <Enter> to exit vigent", "Comment");
550
+ return;
551
+ case "noop":
552
+ return;
553
+ default:
554
+ this.setMessage(`unknown action '${name}'`, "ErrorMsg");
555
+ }
556
+ }
557
+ submit() {
558
+ if (this.mode === "INSERT") {
559
+ const text = this.inputBuf.trim();
560
+ this.inputBuf = "";
561
+ this.mode = "NORMAL";
562
+ let s = this.focusedSession;
563
+ if (text && !s) {
564
+ // no agent yet: the first prompt starts one, named after the task
565
+ s = this.startAgent(agentNameFrom(text));
566
+ if (!s)
567
+ return;
568
+ }
569
+ if (text && s) {
570
+ const waiting = s.submit(text);
571
+ if (waiting > 0)
572
+ this.setMessage(`queued: '${s.name}' gets it after the current run (${waiting} waiting)`, "Comment");
573
+ this.scrollOffset = 0;
574
+ }
575
+ }
576
+ else if (this.mode === "COMMAND") {
577
+ const cmd = this.cmdBuf;
578
+ this.cmdBuf = "";
579
+ this.mode = "NORMAL";
580
+ this.exec(cmd);
581
+ }
582
+ else if (this.mode === "SEARCH") {
583
+ this.searchQuery = this.cmdBuf;
584
+ this.cmdBuf = "";
585
+ this.mode = "NORMAL";
586
+ this.runSearch();
587
+ }
588
+ }
589
+ // ------------------------------------------------------------------ ex commands
590
+ exec(input) {
591
+ const parsed = /^\s*(\S*)\s*([\s\S]*)$/.exec(input) ?? ["", "", ""];
592
+ const cmd = parsed[1];
593
+ // mappings keep trailing whitespace (":nmap x :new " opens ":new "), like vim
594
+ const rawArg = parsed[2];
595
+ const arg = rawArg.trim();
596
+ switch (cmd) {
597
+ case "":
598
+ return;
599
+ case "q":
600
+ case "qa":
601
+ case "quit":
602
+ case "qall":
603
+ case "x":
604
+ case "xa": {
605
+ const running = this.sessions.filter((x) => x.state === "running").length;
606
+ if (running > 0) {
607
+ this.setMessage(`E37: ${running} agent${running === 1 ? " is" : "s are"} still running (add ! to quit anyway)`, "ErrorMsg");
608
+ return;
609
+ }
610
+ this.onQuit();
611
+ return;
612
+ }
613
+ case "q!":
614
+ case "qa!":
615
+ case "quit!":
616
+ case "qall!":
617
+ this.onQuit();
618
+ return;
619
+ case "queue":
620
+ this.execQueue(rawArg.trimStart());
621
+ return;
622
+ case "pause":
623
+ case "resume": {
624
+ const s = this.focusedSession;
625
+ if (!s)
626
+ return;
627
+ if (cmd === "pause") {
628
+ s.pause();
629
+ this.setMessage(`paused '${s.name}'${s.queue.length ? ` (${s.queue.length} queued)` : ""} · :resume to continue`, "WarningMsg");
630
+ }
631
+ else if (s.state !== "paused") {
632
+ this.setMessage(`'${s.name}' isn't paused`, "Comment");
633
+ }
634
+ else {
635
+ s.resume();
636
+ this.message = "";
637
+ }
638
+ return;
639
+ }
640
+ case "merge":
641
+ this.execMerge(arg);
642
+ return;
643
+ case "pr":
644
+ this.execPr();
645
+ return;
646
+ case "dash":
647
+ case "dashboard":
648
+ this.openDashboard();
649
+ return;
650
+ case "tree":
651
+ this.openTree();
652
+ return;
653
+ case "checkhealth":
654
+ case "che":
655
+ case "engines":
656
+ this.openHealth();
657
+ return;
658
+ case "settings":
659
+ case "options":
660
+ case "prefs":
661
+ this.openSettings();
662
+ return;
663
+ case "w":
664
+ case "w!":
665
+ case "write":
666
+ case "write!":
667
+ this.execWrite(arg, cmd.endsWith("!"));
668
+ return;
669
+ case "new":
670
+ case "new!": {
671
+ const modelFlag = /(?:^|\s)--model[= ](\S+)/.exec(arg);
672
+ const engineFlag = /(?:^|\s)(?:--engine|-e)[= ](\S+)/.exec(arg);
673
+ const name = arg
674
+ .replace(/(?:^|\s)--model[= ]\S+/, "")
675
+ .replace(/(?:^|\s)(?:--engine|-e)[= ]\S+/, "")
676
+ .trim() || `agent-${this.sessions.length + 1}`;
677
+ this.startAgent(name, { worktree: cmd === "new", model: modelFlag?.[1], engine: engineFlag?.[1] });
678
+ return;
679
+ }
680
+ case "kill":
681
+ case "kill!":
682
+ this.killFocused(cmd === "kill!");
683
+ return;
684
+ case "diff":
685
+ this.openDiff(/^(split|vert|vertical|side)$/.test(arg));
686
+ return;
687
+ case "worktrees":
688
+ case "worktree":
689
+ case "wt":
690
+ this.execWorktree(arg);
691
+ return;
692
+ case "attach":
693
+ this.execAttach(arg);
694
+ return;
695
+ case "colorscheme":
696
+ case "colo":
697
+ case "theme": {
698
+ if (!arg) {
699
+ this.openThemePicker();
700
+ return;
701
+ }
702
+ const save = /^save(!?)\s+(\S+)$/.exec(arg) ?? (/^save(!?)$/.test(arg) ? [arg, arg.endsWith("!") ? "!" : "", ""] : null);
703
+ if (cmd === "theme" && save) {
704
+ this.saveTheme(save[2], save[1] === "!");
705
+ return;
706
+ }
707
+ try {
708
+ this.setTheme(loadTheme(arg));
709
+ this.setMessage(`colorscheme ${this.painter.theme.name}`, "ModeMsg");
710
+ }
711
+ catch (err) {
712
+ this.setMessage(err.message, "ErrorMsg");
713
+ }
714
+ return;
715
+ }
716
+ case "hi":
717
+ case "highlight":
718
+ case "hi!":
719
+ case "highlight!":
720
+ this.execHighlight(arg);
721
+ return;
722
+ case "set":
723
+ case "se": {
724
+ const r = applySetCommand(this.ui, arg, { transparent: this.painter.transparent });
725
+ this.applyColorMode();
726
+ this.setMessage(r.message, r.error ? "ErrorMsg" : "Normal");
727
+ this.repaint();
728
+ return;
729
+ }
730
+ case "model":
731
+ case "mo":
732
+ if (arg)
733
+ this.chooseModel(arg);
734
+ else
735
+ this.openModelPicker();
736
+ return;
737
+ case "effort":
738
+ case "think":
739
+ if (arg)
740
+ this.chooseEffort(arg);
741
+ else
742
+ this.openEffortPicker();
743
+ return;
744
+ case "help":
745
+ case "h":
746
+ this.openHelp();
747
+ return;
748
+ case "let":
749
+ this.execLet(arg);
750
+ return;
751
+ default: {
752
+ const map = /^([nicvx]?)(?:nore)?map$/.exec(cmd);
753
+ if (map)
754
+ return this.execMap(map[1], rawArg);
755
+ const unmap = /^([nicvx]?)unmap$/.exec(cmd);
756
+ if (unmap)
757
+ return this.execUnmap(unmap[1], arg);
758
+ this.setMessage(`E492: Not an editor command: ${cmd}`, "ErrorMsg");
759
+ }
760
+ }
761
+ }
762
+ setMessage(text, group) {
763
+ this.message = text;
764
+ this.messageGroup = group;
765
+ }
766
+ // ---------------------------------------------------------------- review flow
767
+ /** `:queue <prompt>` sends it after the current run; `:queue` lists what's waiting. */
768
+ execQueue(text) {
769
+ const s = this.focusedSession;
770
+ if (!s) {
771
+ this.setMessage("no agent (:new <task> starts one)", "ErrorMsg");
772
+ return;
773
+ }
774
+ if (!text.trim()) {
775
+ if (s.queue.length === 0) {
776
+ this.setMessage(`nothing queued for '${s.name}'`, "Comment");
777
+ return;
778
+ }
779
+ const lines = s.queue.map((q, i) => [
780
+ ["LineNr", `${String(i + 1).padStart(2)} `],
781
+ ["NormalFloat", q],
782
+ ]);
783
+ this.openFloat(`Queued for ${s.name}`, lines, 80);
784
+ return;
785
+ }
786
+ const waiting = s.submit(text.trim());
787
+ this.setMessage(waiting > 0 ? `queued for '${s.name}' (${waiting} waiting)` : `sent to '${s.name}'`, "Comment");
788
+ }
789
+ /** `:merge` squash-merges the agent's branch into its base in your checkout (`:merge --no-squash` merges). */
790
+ execMerge(arg) {
791
+ const s = this.focusedSession;
792
+ const wt = s?.worktree;
793
+ if (!s || !wt?.path || !this.workspace.root) {
794
+ this.setMessage(s ? `'${s.name}' has no worktree to merge` : "no agent", "ErrorMsg");
795
+ return;
796
+ }
797
+ if (s.state === "running") {
798
+ this.setMessage(`'${s.name}' is still running; wait for it or :pause it first`, "ErrorMsg");
799
+ return;
800
+ }
801
+ const r = mergeWorktree(this.workspace.root, wt, { squash: !/--no-squash\b/.test(arg) });
802
+ this.setMessage(r.ok ? `${r.message} · :kill! cleans up the worktree` : r.message, r.ok ? "ModeMsg" : "ErrorMsg");
803
+ s.refreshStat(0);
804
+ }
805
+ /** `:pr` pushes the agent's branch and opens a pull request. */
806
+ execPr() {
807
+ const s = this.focusedSession;
808
+ const wt = s?.worktree;
809
+ if (!s || !wt?.path || !this.workspace.root) {
810
+ this.setMessage(s ? `'${s.name}' has no worktree to open a PR from` : "no agent", "ErrorMsg");
811
+ return;
812
+ }
813
+ if (s.state === "running") {
814
+ this.setMessage(`'${s.name}' is still running; wait for it or :pause it first`, "ErrorMsg");
815
+ return;
816
+ }
817
+ this.setMessage(`pushing ${wt.branch}…`, "Comment");
818
+ this.tui.requestRender();
819
+ const r = openPullRequest(this.workspace.root, wt);
820
+ this.setMessage(r.message, r.ok ? "ModeMsg" : "ErrorMsg");
821
+ }
822
+ /** `:w [file]`: the transcript as Markdown (`:w!` overwrites). */
823
+ execWrite(arg, force) {
824
+ const s = this.focusedSession;
825
+ if (!s) {
826
+ this.setMessage("E32: No agent to write", "ErrorMsg");
827
+ return;
828
+ }
829
+ const file = path.resolve(arg || `${slugifyName(s.name)}.md`);
830
+ if (fs.existsSync(file) && !force) {
831
+ this.setMessage(`E13: File exists (add ! to override): ${this.shortPath(file)}`, "ErrorMsg");
832
+ return;
833
+ }
834
+ const md = transcriptMarkdown(s);
835
+ try {
836
+ fs.writeFileSync(file, md);
837
+ }
838
+ catch (err) {
839
+ this.setMessage(`E212: Can't open file for writing: ${err.message}`, "ErrorMsg");
840
+ return;
841
+ }
842
+ this.setMessage(`"${this.shortPath(file)}" ${md.split("\n").length - 1}L, ${Buffer.byteLength(md)}B written`, "Normal");
843
+ }
844
+ /** `:tree`: the conversation with every branch; Enter goes back to a point. */
845
+ openTree() {
846
+ const s = this.focusedSession;
847
+ if (!s) {
848
+ this.setMessage("no agent (:new <task> starts one)", "ErrorMsg");
849
+ return;
850
+ }
851
+ const rows = s.tree(TREE_GLYPHS[this.ui.icons]);
852
+ if (!rows) {
853
+ this.setMessage(`'${s.name}' has no conversation tree (demo agents don't keep one)`, "Comment");
854
+ return;
855
+ }
856
+ if (rows.length === 0) {
857
+ this.setMessage(`nothing in '${s.name}' yet`, "Comment");
858
+ return;
859
+ }
860
+ const I = this.icons;
861
+ const kindGlyph = {
862
+ user: ["VigentUser", I.prompt],
863
+ agent: ["Normal", ""],
864
+ tool: ["VigentTool", `${I.folded} `],
865
+ summary: ["Special", "≈ "],
866
+ note: ["NonText", "… "],
867
+ };
868
+ // every ├─ is one more alternative next to the └─ that ends its fork
869
+ const branches = 1 + rows.filter((r) => r.guide.endsWith(TREE_GLYPHS[this.ui.icons].tee)).length;
870
+ const lines = rows.map((r) => {
871
+ const [g, glyph] = kindGlyph[r.kind];
872
+ const textGroup = r.current ? "Title" : r.onPath ? (r.kind === "tool" ? "VigentTool" : "NormalFloat") : "Comment";
873
+ return [
874
+ ["NonText", r.guide],
875
+ [r.onPath ? g : "Comment", glyph],
876
+ [textGroup, r.text],
877
+ ["String", r.current ? ` ${I.here} here` : ""],
878
+ ["Special", r.label ? ` [${r.label}]` : ""],
879
+ ];
880
+ });
881
+ const here = rows.findIndex((r) => r.current);
882
+ const prompts = rows.filter((r) => r.kind === "user").length;
883
+ const summary = `${prompts} prompt${prompts === 1 ? "" : "s"} · ${branches > 1 ? `${branches} branches` : "no branches yet"}`;
884
+ this.openFloat(`Tree · ${s.name} · ${summary} · Enter: go back to that point`, lines, Math.min(110, this.terminal.columns - 4), {
885
+ selectable: rows.map((_, i) => i),
886
+ initial: here >= 0 ? here : rows.length - 1,
887
+ onSelect: (i) => this.goToTreeNode(s, rows[i]),
888
+ });
889
+ }
890
+ goToTreeNode(s, row) {
891
+ if (row.current)
892
+ return;
893
+ s.navigate(row.id)
894
+ .then(({ editorText }) => {
895
+ this.scrollOffset = 0;
896
+ this.invalidate();
897
+ if (editorText !== undefined) {
898
+ this.mode = "INSERT";
899
+ this.inputBuf = editorText;
900
+ this.setMessage("editing an earlier prompt · Enter sends it as a new branch (the old one stays in :tree)", "Comment");
901
+ }
902
+ else {
903
+ this.setMessage("back at an earlier point · your next prompt starts a new branch (the old one stays in :tree)", "Comment");
904
+ }
905
+ this.tui.requestRender(true);
906
+ })
907
+ .catch((err) => {
908
+ this.setMessage(err.message, "ErrorMsg");
909
+ this.tui.requestRender();
910
+ });
911
+ }
912
+ /** Called when the engine checks finish. */
913
+ setEngines(engines) {
914
+ this.engines = engines;
915
+ this.tui.requestRender();
916
+ }
917
+ /** Who `:model` / `:effort` change: the focused agent, or new agents on the default engine. */
918
+ modelTarget(forNewAgents = false) {
919
+ const s = forNewAgents ? undefined : this.focusedSession;
920
+ if (s)
921
+ return { session: s, engine: s.engine, label: `'${s.name}' (${ENGINE_NAMES[s.engine]})` };
922
+ const engine = this.defaultEngine();
923
+ return { engine, label: `new ${ENGINE_NAMES[engine]} agents` };
924
+ }
925
+ /** `:model <name>` */
926
+ chooseModel(model, t = this.modelTarget()) {
927
+ const value = model === "default" ? undefined : model;
928
+ if (!t.session) {
929
+ if (value)
930
+ this.newAgentModel[t.engine] = value;
931
+ else
932
+ delete this.newAgentModel[t.engine];
933
+ this.setMessage(`model for ${t.label}: ${model}`, "ModeMsg");
934
+ return;
935
+ }
936
+ const s = t.session;
937
+ Promise.resolve(s.setModel(value))
938
+ .then(() => {
939
+ this.persist(s);
940
+ this.setMessage(`model for ${t.label}: ${model} (from the next prompt)`, "ModeMsg");
941
+ this.tui.requestRender();
942
+ })
943
+ .catch((err) => {
944
+ this.setMessage(String(err?.message ?? err), "ErrorMsg");
945
+ this.tui.requestRender();
946
+ });
947
+ }
948
+ /** `:effort <level>` */
949
+ chooseEffort(level, t = this.modelTarget()) {
950
+ const allowed = ["default", ...EFFORTS[t.engine]];
951
+ if (!allowed.includes(level)) {
952
+ this.setMessage(`${ENGINE_NAMES[t.engine]} takes: ${allowed.join(", ")}`, "ErrorMsg");
953
+ return;
954
+ }
955
+ const value = level === "default" ? undefined : level;
956
+ if (!t.session) {
957
+ if (value)
958
+ this.newAgentEffort[t.engine] = value;
959
+ else
960
+ delete this.newAgentEffort[t.engine];
961
+ this.setMessage(`effort for ${t.label}: ${level}`, "ModeMsg");
962
+ return;
963
+ }
964
+ const s = t.session;
965
+ Promise.resolve(s.setEffort(value))
966
+ .then(() => {
967
+ this.persist(s);
968
+ this.setMessage(`effort for ${t.label}: ${level} (from the next prompt)`, "ModeMsg");
969
+ this.tui.requestRender();
970
+ })
971
+ .catch((err) => {
972
+ this.setMessage(String(err?.message ?? err), "ErrorMsg");
973
+ this.tui.requestRender();
974
+ });
975
+ }
976
+ /** `:model`: pick from what the engine offers, or type any name. */
977
+ openModelPicker(t = this.modelTarget()) {
978
+ const current = t.session
979
+ ? "chosenModel" in t.session
980
+ ? (t.session.chosenModel ?? "default")
981
+ : t.session.model
982
+ : (this.newAgentModel[t.engine] ?? "default");
983
+ this.openChoices(`Model · ${t.label}`, this.modelChoices(t.engine), current, true, (v) => this.chooseModel(v, t));
984
+ }
985
+ /** `:effort`: the reasoning effort / thinking levels the engine accepts. */
986
+ openEffortPicker(t = this.modelTarget()) {
987
+ const current = (t.session ? t.session.effort : this.newAgentEffort[t.engine]) ?? "default";
988
+ this.openChoices(`Effort · ${t.label}`, effortChoices(t.engine), current, false, (v) => this.chooseEffort(v, t));
989
+ }
990
+ /** where model lists come from (replaced in tests) */
991
+ modelChoices = (engine) => modelChoices(engine);
992
+ openChoices(title, items, current, custom, done) {
993
+ this.seq.reset();
994
+ let handle;
995
+ const picker = new ChoicePicker(this.painter, PICKER_ICONS[this.ui.icons], title, items, current, custom, (v) => {
996
+ handle?.hide();
997
+ if (v !== null)
998
+ done(v);
999
+ this.repaint();
1000
+ }, () => this.tui.requestRender());
1001
+ handle = this.tui.showOverlay(picker, { width: Math.min(72, this.terminal.columns - 4), anchor: "center" });
1002
+ this.tui.requestRender();
1003
+ }
1004
+ /** `:settings`: theme, look, agents and keys in one float; w saves them to config.toml. */
1005
+ openSettings() {
1006
+ this.seq.reset();
1007
+ const ui = this.ui;
1008
+ const wt = this.workspace.config;
1009
+ const onOff = (b) => (b ? "on" : "off");
1010
+ const cycle = (values, now, dir) => values[(values.indexOf(now) + dir + values.length) % values.length];
1011
+ const refresh = () => {
1012
+ this.applyColorMode();
1013
+ this.repaint();
1014
+ };
1015
+ const items = [
1016
+ {
1017
+ section: "Look",
1018
+ label: "Theme",
1019
+ value: () => this.painter.theme.name,
1020
+ open: () => {
1021
+ handle?.hide();
1022
+ this.openThemePicker();
1023
+ },
1024
+ hint: `${listThemes().length} themes, with a live preview · :theme save <name> keeps your own`,
1025
+ },
1026
+ {
1027
+ section: "Look",
1028
+ label: "Transparent background",
1029
+ value: () => (ui.transparent === "auto" ? `auto (${this.painter.transparent ? "on" : "off"}: the theme decides)` : onOff(ui.transparent)),
1030
+ change: (d) => {
1031
+ ui.transparent = cycle(["auto", true, false], ui.transparent, d);
1032
+ refresh();
1033
+ },
1034
+ hint: "the terminal's own background shows through (its blur, opacity or wallpaper)",
1035
+ },
1036
+ { section: "Look", label: "Icons", value: () => ui.icons, change: () => ((ui.icons = ui.icons === "unicode" ? "ascii" : "unicode"), refresh()), hint: "ascii for fonts without box-drawing characters" },
1037
+ { section: "Look", label: "24-bit color", value: () => onOff(ui.termguicolors), change: () => ((ui.termguicolors = !ui.termguicolors), refresh()), hint: "off: the nearest of the 256 standard colors (vim's 'termguicolors')" },
1038
+ { section: "Layout", label: "Sidebar", value: () => onOff(ui.sidebar), change: () => ((ui.sidebar = !ui.sidebar), refresh()) },
1039
+ {
1040
+ section: "Layout",
1041
+ label: "Sidebar width",
1042
+ value: () => `${ui.sidebar_width} columns`,
1043
+ change: (d) => ((ui.sidebar_width = Math.max(16, Math.min(80, ui.sidebar_width + 2 * d))), refresh()),
1044
+ hint: "h / l: 2 columns narrower / wider",
1045
+ },
1046
+ { section: "Layout", label: "Session numbers", value: () => onOff(ui.number), change: () => ((ui.number = !ui.number), refresh()) },
1047
+ { section: "Layout", label: "~ on empty sidebar rows", value: () => onOff(ui.tilde), change: () => ((ui.tilde = !ui.tilde), refresh()) },
1048
+ {
1049
+ section: "Agents",
1050
+ label: "Engine for new agents",
1051
+ value: () => (this.preferredEngine === "auto" ? `auto (${ENGINE_NAMES[this.defaultEngine()]})` : ENGINE_NAMES[this.preferredEngine]),
1052
+ change: (d) => {
1053
+ this.preferredEngine = cycle(["auto", "pi", "claude", "codex", "opencode"], this.preferredEngine, d);
1054
+ },
1055
+ hint: "auto: pi with an API key, else the first signed-in CLI · :checkhealth shows which are ready",
1056
+ },
1057
+ {
1058
+ section: "Agents",
1059
+ label: "Model for new agents",
1060
+ value: () => this.newAgentModel[this.defaultEngine()] ?? `default (${ENGINE_NAMES[this.defaultEngine()]}'s)`,
1061
+ open: () => {
1062
+ handle?.hide();
1063
+ this.openModelPicker(this.modelTarget(true));
1064
+ },
1065
+ hint: ":model in an agent changes just that agent",
1066
+ },
1067
+ {
1068
+ section: "Agents",
1069
+ label: "Effort for new agents",
1070
+ value: () => this.newAgentEffort[this.defaultEngine()] ?? "default",
1071
+ change: (d) => {
1072
+ const e = this.defaultEngine();
1073
+ const all = ["default", ...EFFORTS[e]];
1074
+ const next = cycle(all, this.newAgentEffort[e] ?? "default", d);
1075
+ if (next === "default")
1076
+ delete this.newAgentEffort[e];
1077
+ else
1078
+ this.newAgentEffort[e] = next;
1079
+ },
1080
+ hint: "reasoning effort (pi: thinking level) · :effort in an agent changes just that agent",
1081
+ },
1082
+ { section: "Agents", label: "A worktree for each agent", value: () => onOff(wt.enabled), change: () => (wt.enabled = !wt.enabled), hint: "off: agents work in this checkout" },
1083
+ {
1084
+ section: "Agents",
1085
+ label: "Worktree storage",
1086
+ value: () => ({ auto: "auto (riftri when it can)", riftri: "riftri", git: "git" })[wt.backend ?? "auto"],
1087
+ change: (d) => (wt.backend = cycle(["auto", "riftri", "git"], wt.backend ?? "auto", d)),
1088
+ hint: "riftri: copy-on-write worktrees that share unchanged files (riftri.dev)",
1089
+ },
1090
+ {
1091
+ section: "Keys",
1092
+ label: "which-key popup",
1093
+ value: () => onOff(ui.whichkey),
1094
+ change: () => (ui.whichkey = !ui.whichkey),
1095
+ hint: "after <leader> (or any key that starts mappings), show what each next key does",
1096
+ },
1097
+ {
1098
+ section: "Keys",
1099
+ label: "Leader key",
1100
+ value: () => formatKeys(parseKeys(this.keymap.leader)),
1101
+ bind: (keys) => {
1102
+ if (keys.length !== 1)
1103
+ return "the leader is one key";
1104
+ this.keymap.leader = keys[0];
1105
+ return `leader is now ${keys[0]} (mappings written with <leader> use it from the next start)`;
1106
+ },
1107
+ hint: "Enter, then press the key <leader> should mean",
1108
+ },
1109
+ ];
1110
+ // every NORMAL-mode mapping, one row per thing it does
1111
+ const groups = new Map();
1112
+ for (const b of this.keymap.bindings("normal")) {
1113
+ if (b.rhs.length === 1 && b.rhs[0].kind === "nop")
1114
+ continue;
1115
+ // 1-9 jump to an agent: not worth a row each
1116
+ if (b.rhs.length === 1 && b.rhs[0].kind === "action" && b.rhs[0].name === "session.goto")
1117
+ continue;
1118
+ groups.set(b.rhsText, [...(groups.get(b.rhsText) ?? []), b]);
1119
+ }
1120
+ for (const [rhsText, bindings] of groups) {
1121
+ const label = settingsLabel(bindings[0].rhs);
1122
+ items.push({
1123
+ section: "Keys (NORMAL mode)",
1124
+ label: label.charAt(0).toUpperCase() + label.slice(1),
1125
+ value: () => this.keymap
1126
+ .bindings("normal")
1127
+ .filter((b) => b.rhsText === rhsText)
1128
+ .map((b) => formatKeys(b.keys))
1129
+ .join(" ") || "(none)",
1130
+ bind: (keys) => {
1131
+ const lhs = keys.join("");
1132
+ try {
1133
+ parseKeys(lhs);
1134
+ }
1135
+ catch {
1136
+ return `'${lhs}' isn't a key`;
1137
+ }
1138
+ const taken = this.keymap.lookup("normal", parseKeys(lhs)).exact;
1139
+ for (const b of this.keymap.bindings("normal").filter((x) => x.rhsText === rhsText)) {
1140
+ const old = b.keys.join("");
1141
+ this.keymap.unset("normal", old);
1142
+ this.keyChanges.set(old, false);
1143
+ }
1144
+ this.keymap.set("normal", lhs, rhsText);
1145
+ this.keyChanges.set(lhs, rhsText);
1146
+ return `${formatKeys(parseKeys(lhs))} now: ${label}${taken && taken.rhsText !== rhsText ? ` (was: ${describeRhs(taken.rhs)})` : ""} · w saves it`;
1147
+ },
1148
+ hint: "Enter, then the new keys (they replace these)",
1149
+ });
1150
+ }
1151
+ let handle;
1152
+ const float = new SettingsFloat(this.painter, PICKER_ICONS[ui.icons], items, () => this.terminal.rows - 2, () => {
1153
+ handle?.hide();
1154
+ this.repaint();
1155
+ }, () => this.saveSettings(), () => this.tui.requestRender());
1156
+ handle = this.tui.showOverlay(float, { width: Math.min(96, this.terminal.columns - 4), anchor: "center" });
1157
+ this.tui.requestRender();
1158
+ }
1159
+ /** Write what :settings changed into config.toml, keeping everything else in the file as it is. */
1160
+ saveSettings() {
1161
+ const file = configPath();
1162
+ try {
1163
+ let text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : CONFIG_TEMPLATE;
1164
+ const ui = this.ui;
1165
+ const set = (section, key, value) => {
1166
+ text = setTomlValue(text, section, key, tomlValue(value));
1167
+ };
1168
+ set(undefined, "theme", this.painter.theme.name);
1169
+ set(undefined, "engine", this.preferredEngine);
1170
+ set(undefined, "leader", this.keymap.leader);
1171
+ for (const k of ["sidebar", "sidebar_width", "number", "tilde", "icons", "termguicolors", "transparent", "whichkey"])
1172
+ set("ui", k, ui[k]);
1173
+ set("worktree", "enabled", this.workspace.config.enabled);
1174
+ set("worktree", "backend", this.workspace.config.backend ?? "auto");
1175
+ for (const [lhs, rhs] of this.keyChanges)
1176
+ set("keys.normal", lhs, rhs);
1177
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1178
+ fs.writeFileSync(file, text);
1179
+ return `saved to ${this.shortPath(file)}`;
1180
+ }
1181
+ catch (err) {
1182
+ return `couldn't save: ${err.message}`;
1183
+ }
1184
+ }
1185
+ /**
1186
+ * Start an agent (`:new`, or the first prompt typed with no agent yet).
1187
+ * Returns it, or undefined after showing why it couldn't start.
1188
+ */
1189
+ startAgent(name, opts = {}) {
1190
+ const engine = opts.engine ?? this.defaultEngine();
1191
+ if (!isEngineId(engine)) {
1192
+ this.setMessage(`unknown engine '${engine}': pi, claude, codex or opencode`, "ErrorMsg");
1193
+ return undefined;
1194
+ }
1195
+ const info = this.engines?.find((e) => e.id === engine);
1196
+ if (info && !info.installed) {
1197
+ this.setMessage(`${info.name} isn't installed: ${info.install} · :checkhealth`, "ErrorMsg");
1198
+ return undefined;
1199
+ }
1200
+ const withWorktree = opts.worktree !== false && this.workspace.config.enabled;
1201
+ try {
1202
+ const model = opts.model ?? this.newAgentModel[engine];
1203
+ const effort = this.newAgentEffort[engine];
1204
+ const s = this.newSession(name, { worktree: withWorktree, model, engine, ...(effort ? { effort } : {}) });
1205
+ this.addSession(s);
1206
+ const wt = s.worktree;
1207
+ if (wt?.riftri)
1208
+ this.refreshStorage();
1209
+ const by = s.engine !== "pi" ? ` · ${ENGINE_NAMES[s.engine]}` : "";
1210
+ if (wt?.path) {
1211
+ const how = wt.riftri ? ` · riftri ${wt.riftri}` : wt.riftriSkipped ? " · full copy (riftri declined: :checkhealth)" : "";
1212
+ this.setMessage(`'${name}'${by} on ${wt.branch} in ${this.shortPath(wt.path)}${wt.base ? ` (from ${wt.base})` : ""}${how}`, wt.riftriSkipped ? "WarningMsg" : "ModeMsg");
1213
+ }
1214
+ else if (wt) {
1215
+ this.setMessage(`'${name}'${by} on ${wt.branch}`, "ModeMsg");
1216
+ }
1217
+ else {
1218
+ const why = !withWorktree || !this.workspace.config.enabled ? "" : " (not a git repo, so no worktree)";
1219
+ this.setMessage(`'${name}'${by} in this checkout${why}`, "ModeMsg");
1220
+ }
1221
+ return s;
1222
+ }
1223
+ catch (err) {
1224
+ this.setMessage(String(err?.message ?? err), "ErrorMsg");
1225
+ return undefined;
1226
+ }
1227
+ }
1228
+ /**
1229
+ * `:theme save <name>`: what's on screen (the theme, your :hi changes,
1230
+ * transparency) as ~/.config/vigent/themes/<name>.toml, then switch to it.
1231
+ */
1232
+ saveTheme(name, force) {
1233
+ if (!name || !/^[\w.-]+$/.test(name)) {
1234
+ this.setMessage("usage: :theme save <name> (letters, digits, - _ .)", "ErrorMsg");
1235
+ return;
1236
+ }
1237
+ if (BUILTIN_THEMES[name]) {
1238
+ this.setMessage(`'${name}' is a built-in theme; save under another name`, "ErrorMsg");
1239
+ return;
1240
+ }
1241
+ const file = path.join(themesDir(), `${name}.toml`);
1242
+ if (fs.existsSync(file) && !force) {
1243
+ this.setMessage(`E13: ${this.shortPath(file)} exists (add ! to overwrite)`, "ErrorMsg");
1244
+ return;
1245
+ }
1246
+ try {
1247
+ const builtinBase = Boolean(BUILTIN_THEMES[this.painter.theme.name]) && BUILTIN_THEMES[this.painter.theme.name] === this.painter.theme;
1248
+ fs.mkdirSync(themesDir(), { recursive: true });
1249
+ fs.writeFileSync(file, themeFileFor(name, this.painter, builtinBase));
1250
+ this.setTheme(loadTheme(name));
1251
+ this.setMessage(`saved ${this.shortPath(file)} · :colorscheme ${name} (or theme = "${name}" in config.toml)`, "ModeMsg");
1252
+ }
1253
+ catch (err) {
1254
+ this.setMessage(`couldn't save the theme: ${err.message}`, "ErrorMsg");
1255
+ }
1256
+ }
1257
+ /** `:checkhealth` (or `:engines`): which agent engines can run, and how to connect the rest. */
1258
+ openHealth() {
1259
+ const show = () => {
1260
+ const engines = this.engines ?? [];
1261
+ const I = this.icons;
1262
+ const lines = [];
1263
+ const def = this.defaultEngine();
1264
+ lines.push([["Title", "vigent: agent engines"]]);
1265
+ lines.push([["Comment", "Each CLI runs as installed, with its own login; vigent never reads credentials."]]);
1266
+ for (const e of engines) {
1267
+ lines.push([]);
1268
+ const glyph = e.connected ? I.ok : e.installed ? I.warn : I.off;
1269
+ const group = e.connected ? "String" : e.installed ? "WarningMsg" : "Comment";
1270
+ lines.push([
1271
+ [group, `${glyph} `],
1272
+ ["Title", e.name],
1273
+ ["Comment", e.version ? ` ${e.version}` : ""],
1274
+ ["Special", e.id === def ? " (default for :new)" : ""],
1275
+ ]);
1276
+ lines.push([["NormalFloat", " "], [e.connected ? "NormalFloat" : group, `${e.connected ? "OK " : e.installed ? "WARNING " : ""}${e.detail}`]]);
1277
+ if (!e.installed && e.install)
1278
+ lines.push([["Comment", " install: "], ["Special", e.install]]);
1279
+ else if (!e.connected && e.connect)
1280
+ lines.push([["Comment", " connect: "], ["Special", e.connect]]);
1281
+ if (e.connected)
1282
+ lines.push([["Comment", " use: "], ["Special", `:new <task> --engine ${e.id}`]]);
1283
+ }
1284
+ lines.push([]);
1285
+ lines.push([["Comment", "Connect in another terminal, then :checkhealth again."]]);
1286
+ lines.push(...this.storageHealthLines());
1287
+ this.openFloat("checkhealth", lines, 84);
1288
+ };
1289
+ if (!this.detectEngines && !this.checkStorage)
1290
+ return show();
1291
+ this.setMessage("checking engines…", "Comment");
1292
+ this.tui.requestRender();
1293
+ Promise.all([
1294
+ this.detectEngines?.() ?? Promise.resolve(this.engines),
1295
+ this.checkStorage?.().catch(() => this.storage) ?? Promise.resolve(this.storage),
1296
+ ])
1297
+ .then(([engines, storage]) => {
1298
+ this.engines = engines;
1299
+ this.storage = storage;
1300
+ this.message = "";
1301
+ show();
1302
+ })
1303
+ .catch((err) => {
1304
+ this.setMessage(`checkhealth: ${err.message}`, "ErrorMsg");
1305
+ this.tui.requestRender();
1306
+ });
1307
+ }
1308
+ /** Recount what riftri's worktrees cost, in the background. */
1309
+ refreshStorage() {
1310
+ this.checkStorage?.()
1311
+ .then((h) => {
1312
+ this.storage = h;
1313
+ this.tui.requestRender();
1314
+ })
1315
+ .catch(() => { });
1316
+ }
1317
+ /** The worktrees part of :checkhealth: riftri, and what agent worktrees cost on disk. */
1318
+ storageHealthLines() {
1319
+ const h = this.storage;
1320
+ if (!h)
1321
+ return [];
1322
+ const I = this.icons;
1323
+ const lines = [[], [["Title", "vigent: agent worktrees"]], []];
1324
+ const glyph = h.ready ? I.ok : h.installed ? I.warn : I.off;
1325
+ const group = h.ready ? "String" : h.installed ? "WarningMsg" : "Comment";
1326
+ lines.push([
1327
+ [group, `${glyph} `],
1328
+ ["Title", "riftri"],
1329
+ ["Comment", h.version ? ` ${h.version}` : ""],
1330
+ ["Comment", " lightweight git worktrees · riftri.dev"],
1331
+ ]);
1332
+ lines.push([["NormalFloat", " "], [h.ready ? "NormalFloat" : group, `${h.ready ? "OK " : h.installed ? "WARNING " : ""}${h.detail}`]]);
1333
+ for (const b of h.blockers) {
1334
+ wrapTextWithAnsi(b, 74).forEach((l, i) => lines.push([["NormalFloat", " "], ["WarningMsg", i === 0 ? "- " : " "], ["NormalFloat", l]]));
1335
+ }
1336
+ if (h.next)
1337
+ lines.push([["Comment", " try: "], ["Special", h.next]]);
1338
+ if (!h.installed)
1339
+ lines.push([["Comment", " install: "], ["Special", RIFTRI_INSTALL]]);
1340
+ if (h.views && h.logicalBytes !== undefined && h.allocatedBytes !== undefined) {
1341
+ const saved = Math.max(0, h.logicalBytes - h.allocatedBytes);
1342
+ lines.push([
1343
+ ["NormalFloat", " "],
1344
+ ["NormalFloat", `${h.views} worktree${h.views === 1 ? "" : "s"}: ${formatBytes(h.logicalBytes)} of files in ${formatBytes(h.allocatedBytes)} on disk`],
1345
+ ["String", saved > 0 ? ` (saves ${formatBytes(saved)})` : ""],
1346
+ ]);
1347
+ }
1348
+ return lines;
1349
+ }
1350
+ /** `:dash`: every agent at a glance; Enter jumps to one. */
1351
+ openDashboard() {
1352
+ if (this.sessions.length === 0) {
1353
+ this.setMessage("no agents yet (:new <task> starts one)", "Comment");
1354
+ return;
1355
+ }
1356
+ const I = this.icons;
1357
+ const lines = [];
1358
+ const rows = [];
1359
+ const stateGroup = (st) => st === "running" ? "String" : st === "waiting" ? "WarningMsg" : st === "error" ? "ErrorMsg" : "Comment";
1360
+ this.sessions.forEach((s, i) => {
1361
+ const st = s.worktree?.stat;
1362
+ const glyph = s.state === "running" ? this.spinner() : I.state[s.state];
1363
+ const last = [...s.items].reverse().find((x) => x.kind === "agent" || x.kind === "user");
1364
+ const stat = st && (st.added || st.removed)
1365
+ ? [["Added", ` +${st.added}`], ["Removed", ` ${I.minus}${st.removed}`]]
1366
+ : [["Comment", ""]];
1367
+ rows.push(lines.length);
1368
+ lines.push([
1369
+ ["LineNr", `${String(i + 1).padStart(2)} `],
1370
+ [stateGroup(s.state), `${glyph} `],
1371
+ ["Title", truncateToWidth(s.name, 26, "…").padEnd(27)],
1372
+ [stateGroup(s.state), s.state.padEnd(8)],
1373
+ ["Comment", ` ${truncateToWidth(s.worktree ? `${I.worktree}${s.worktree.branch}` : s.model, 34, "…")}`],
1374
+ ...stat,
1375
+ ["Comment", s.queue.length ? ` ${I.queued}${s.queue.length}` : ""],
1376
+ ]);
1377
+ lines.push([
1378
+ ["NormalFloat", " "],
1379
+ ["Comment", last ? truncateToWidth(plainPreview(last.text), 90, "…") : "(no messages yet)"],
1380
+ ]);
1381
+ });
1382
+ const running = this.sessions.filter((s) => s.state === "running").length;
1383
+ lines.push([]);
1384
+ lines.push([["Comment", `${this.sessions.length} agents · ${running} running · j/k move · Enter open`]]);
1385
+ this.openFloat("Dashboard", lines, Math.min(110, this.terminal.columns - 4), {
1386
+ selectable: rows,
1387
+ onSelect: (lineIdx) => this.focusSession(rows.indexOf(lineIdx)),
1388
+ initial: rows[this.focusIdx],
1389
+ });
1390
+ }
1391
+ // ---------------------------------------------------------------- visual & yank
1392
+ /** First and last+1 pane lines on screen. */
1393
+ viewRange(total) {
1394
+ const h = this.contentHeight();
1395
+ const start = Math.max(0, total - h - this.scrollOffset);
1396
+ return { start, end: Math.min(total, start + h) };
1397
+ }
1398
+ /** Scroll just enough to show a pane line. */
1399
+ revealLine(idx) {
1400
+ const total = this.buildPaneLines(this.paneWidth()).length;
1401
+ const h = this.contentHeight();
1402
+ const { start, end } = this.viewRange(total);
1403
+ if (idx < start)
1404
+ this.scrollOffset = Math.max(0, total - h - idx);
1405
+ else if (idx >= end)
1406
+ this.scrollOffset = Math.max(0, total - idx - 1);
1407
+ }
1408
+ moveCursor(delta) {
1409
+ const total = this.buildPaneLines(this.paneWidth()).length;
1410
+ this.visual.cursor = Math.max(0, Math.min(total - 1, this.visual.cursor + delta));
1411
+ this.revealLine(this.visual.cursor);
1412
+ }
1413
+ yank(text, lineCount) {
1414
+ const how = this.clipboard(text);
1415
+ this.setMessage(`${lineCount} line${lineCount === 1 ? "" : "s"} yanked${how === "terminal" ? "" : ` (${how})`}`, "Normal");
1416
+ }
1417
+ /** `{` / `}`: put the previous / next message at the top of the pane. */
1418
+ jumpMessage(dir) {
1419
+ const lines = this.buildPaneLines(this.paneWidth());
1420
+ const { start } = this.viewRange(lines.length);
1421
+ const starts = lines.map((l, i) => (l.start ? i : -1)).filter((i) => i >= 0);
1422
+ const target = dir > 0 ? starts.find((i) => i > start) : [...starts].reverse().find((i) => i < start);
1423
+ if (target === undefined) {
1424
+ if (dir > 0)
1425
+ this.scrollOffset = 0;
1426
+ else
1427
+ this.scrollTop();
1428
+ return;
1429
+ }
1430
+ const h = this.contentHeight();
1431
+ this.scrollOffset = Math.max(0, Math.min(Math.max(0, lines.length - h), lines.length - h - target));
1432
+ }
1433
+ shortPath(p) {
1434
+ const rel = path.relative(process.cwd(), p);
1435
+ return rel && !rel.startsWith(`..${path.sep}..`) ? rel : p;
1436
+ }
1437
+ /** `:kill` stops the agent and keeps its worktree; `:kill!` also removes it (git's safe remove). */
1438
+ killFocused(removeTree) {
1439
+ const s = this.focusedSession;
1440
+ if (!s)
1441
+ return;
1442
+ s.abort();
1443
+ s.dispose();
1444
+ this.sessions.splice(this.focusIdx, 1);
1445
+ this.focusIdx = Math.max(0, Math.min(this.focusIdx, this.sessions.length - 1));
1446
+ const wt = s.worktree;
1447
+ if (!wt?.path || !this.workspace.root) {
1448
+ this.setMessage(`killed '${s.name}'`, "WarningMsg");
1449
+ return;
1450
+ }
1451
+ if (!removeTree) {
1452
+ try {
1453
+ setDetached(wt.path, true); // not reopened at the next start; :attach brings it back
1454
+ }
1455
+ catch {
1456
+ // the worktree may already be gone
1457
+ }
1458
+ this.setMessage(`killed '${s.name}' · kept ${wt.branch} (:attach ${wt.branch} reopens it, :worktree remove deletes it)`, "WarningMsg");
1459
+ return;
1460
+ }
1461
+ const r = removeWorktree(this.workspace.root, { path: wt.path, branch: wt.branch, base: wt.base });
1462
+ if (r.removed && wt.riftri)
1463
+ this.refreshStorage();
1464
+ this.setMessage(`killed '${s.name}' · ${r.message}`, r.removed ? "WarningMsg" : "ErrorMsg");
1465
+ }
1466
+ /** `:attach <branch>` reopens an agent in one of the worktrees listed by :worktrees. */
1467
+ execAttach(arg) {
1468
+ const root = this.workspace.root;
1469
+ if (!root) {
1470
+ this.setMessage("not in a git repository", "ErrorMsg");
1471
+ return;
1472
+ }
1473
+ if (!arg) {
1474
+ this.setMessage("usage: :attach <branch> (see :worktrees)", "Comment");
1475
+ return;
1476
+ }
1477
+ const w = findWorktree(root, arg, this.workspace.config);
1478
+ if (!w?.branch) {
1479
+ this.setMessage(`no worktree '${arg}' (see :worktrees)`, "ErrorMsg");
1480
+ return;
1481
+ }
1482
+ const owner = this.sessions.findIndex((s) => s.worktree?.path === w.path);
1483
+ if (owner >= 0) {
1484
+ this.focusSession(owner);
1485
+ return;
1486
+ }
1487
+ try {
1488
+ const s = this.attachSession({ branch: w.branch, path: w.path });
1489
+ this.addSession(s);
1490
+ this.setMessage(`attached '${s.name}' to ${w.branch}`, "ModeMsg");
1491
+ }
1492
+ catch (err) {
1493
+ this.setMessage(err.message, "ErrorMsg");
1494
+ }
1495
+ }
1496
+ /** `:worktrees` lists them; `:worktree remove[!] <branch>` deletes one. */
1497
+ execWorktree(arg) {
1498
+ const root = this.workspace.root;
1499
+ if (!root) {
1500
+ this.setMessage("not in a git repository", "ErrorMsg");
1501
+ return;
1502
+ }
1503
+ const m = /^(remove|rm)(!?)\s+(\S+)$/.exec(arg);
1504
+ if (m) {
1505
+ const w = findWorktree(root, m[3], this.workspace.config);
1506
+ if (!w) {
1507
+ this.setMessage(`no worktree '${m[3]}' (see :worktrees)`, "ErrorMsg");
1508
+ return;
1509
+ }
1510
+ const owner = this.sessions.find((s) => s.worktree?.path === w.path);
1511
+ if (owner) {
1512
+ this.setMessage(`'${owner.name}' is working in ${w.branch}; stop it first (:kill or :kill!)`, "ErrorMsg");
1513
+ return;
1514
+ }
1515
+ const r = removeWorktree(root, w, m[2] === "!");
1516
+ if (r.removed)
1517
+ this.refreshStorage();
1518
+ this.setMessage(r.message, r.removed ? "WarningMsg" : "ErrorMsg");
1519
+ return;
1520
+ }
1521
+ if (arg) {
1522
+ this.setMessage("usage: :worktrees | :worktree remove[!] <branch>", "Comment");
1523
+ return;
1524
+ }
1525
+ let all;
1526
+ try {
1527
+ all = listWorktrees(root, this.workspace.config);
1528
+ }
1529
+ catch (err) {
1530
+ this.setMessage(err.message, "ErrorMsg");
1531
+ return;
1532
+ }
1533
+ if (all.length === 0) {
1534
+ this.setMessage("no agent worktrees yet (:new <task> creates one)", "Comment");
1535
+ return;
1536
+ }
1537
+ const backends = riftriBackends(root, all.map((w) => w.path));
1538
+ const lines = all.map((w) => {
1539
+ const owner = this.sessions.find((s) => s.worktree?.path === w.path);
1540
+ const backend = backends.get(w.path);
1541
+ return [
1542
+ [owner ? "String" : "Comment", owner ? "● " : " "],
1543
+ ["Title", `${(w.branch ?? `(detached ${w.head})`).padEnd(30)} `],
1544
+ ["NormalFloat", `${truncateToWidth(owner?.name ?? "", 20, "…").padEnd(20)} `],
1545
+ // the storage column only when riftri made some of them
1546
+ [backend ? "String" : "Comment", backends.size ? `${(backend ? `riftri ${backend}` : "git").padEnd(16)} ` : ""],
1547
+ ["Comment", this.shortPath(w.path)],
1548
+ ];
1549
+ });
1550
+ const st = this.storage;
1551
+ if (st?.views && st.logicalBytes !== undefined && st.allocatedBytes !== undefined) {
1552
+ lines.push([]);
1553
+ lines.push([
1554
+ ["Comment", `riftri: ${formatBytes(st.logicalBytes)} of files in ${formatBytes(st.allocatedBytes)} on disk (${backendName(st.backend)})`],
1555
+ ]);
1556
+ }
1557
+ lines.push([]);
1558
+ lines.push([["Comment", "● = an agent is working in it · :attach <branch> reopens one · :worktree remove <branch> deletes it"]]);
1559
+ this.openFloat("Worktrees", lines, 112);
1560
+ }
1561
+ /** `:diff`: what the focused agent changed since its worktree was created. */
1562
+ openDiff(split = false) {
1563
+ const s = this.focusedSession;
1564
+ const wt = s?.worktree;
1565
+ if (!s || !wt?.path) {
1566
+ this.setMessage(s ? `'${s.name}' has no worktree to diff` : "no agent", "ErrorMsg");
1567
+ return;
1568
+ }
1569
+ diffText(wt)
1570
+ .then((text) => {
1571
+ if (!text.trim()) {
1572
+ this.setMessage(`no changes on ${wt.branch} yet`, "Comment");
1573
+ this.tui.requestRender();
1574
+ return;
1575
+ }
1576
+ const width = this.terminal.columns - 4;
1577
+ const unified = unifiedView(text);
1578
+ const side = splitView(text, width - 3);
1579
+ const n = unified.jumps.length;
1580
+ const title = (layout) => `${wt.branch} vs ${wt.base ?? "base"} · ${n} hunk${n === 1 ? "" : "s"} · ]c [c jump · s ${layout}`;
1581
+ const views = [
1582
+ { title: title("side by side"), lines: unified.lines, jumps: unified.jumps },
1583
+ { title: title("unified"), lines: side.lines, jumps: side.jumps },
1584
+ ];
1585
+ const [first, second] = split ? [views[1], views[0]] : views;
1586
+ this.openFloat(first.title, first.lines, width, { jumps: first.jumps, alternate: second });
1587
+ })
1588
+ .catch((err) => {
1589
+ this.setMessage(`git diff failed: ${err.message.split("\n")[0]}`, "ErrorMsg");
1590
+ this.tui.requestRender();
1591
+ });
1592
+ }
1593
+ /** `:map`, `:nmap`, `:inoremap`, ... (all mappings are non-recursive, like noremap). */
1594
+ execMap(letter, arg) {
1595
+ const modes = mapModes(letter);
1596
+ const m = /^(\S+)(?:\s+([\s\S]*\S[\s\S]*))?\s*$/.exec(arg.trimStart());
1597
+ if (!m) {
1598
+ this.openMappings(modes);
1599
+ return;
1600
+ }
1601
+ const [, lhs, rhs] = m;
1602
+ if (rhs === undefined) {
1603
+ const found = modes.flatMap((mode) => {
1604
+ try {
1605
+ const b = this.keymap.lookup(mode, parseKeys(lhs, this.keymap.leader)).exact;
1606
+ return b ? [`${MODE_LETTER[mode]} ${formatKeys(b.keys)} ${b.rhsText}`] : [];
1607
+ }
1608
+ catch {
1609
+ return [];
1610
+ }
1611
+ });
1612
+ this.setMessage(found.length > 0 ? found.join(" ") : "No mapping found", found.length > 0 ? "Normal" : "Comment");
1613
+ return;
1614
+ }
1615
+ try {
1616
+ for (const mode of modes)
1617
+ this.keymap.set(mode, lhs, rhs);
1618
+ this.message = "";
1619
+ }
1620
+ catch (err) {
1621
+ this.setMessage(`E474: ${err.message}`, "ErrorMsg");
1622
+ }
1623
+ }
1624
+ execUnmap(letter, arg) {
1625
+ const lhs = arg.trim();
1626
+ if (!lhs) {
1627
+ this.setMessage("E474: Invalid argument", "ErrorMsg");
1628
+ return;
1629
+ }
1630
+ try {
1631
+ const removed = mapModes(letter).map((mode) => this.keymap.unset(mode, lhs)).some(Boolean);
1632
+ if (!removed)
1633
+ this.setMessage("E31: No such mapping", "ErrorMsg");
1634
+ }
1635
+ catch (err) {
1636
+ this.setMessage(`E474: ${err.message}`, "ErrorMsg");
1637
+ }
1638
+ }
1639
+ /** `:let mapleader = ","` (affects mappings defined after it, like vim). */
1640
+ execLet(arg) {
1641
+ const m = /^(?:g:)?mapleader\s*=\s*(["'])(.*)\1$/.exec(arg.trim());
1642
+ if (!m) {
1643
+ this.setMessage("only :let mapleader = \"<key>\" is supported", "Comment");
1644
+ return;
1645
+ }
1646
+ try {
1647
+ parseKeys(m[2]);
1648
+ this.keymap.leader = m[2];
1649
+ }
1650
+ catch {
1651
+ this.setMessage(`E474: leader '${m[2]}' is not a key`, "ErrorMsg");
1652
+ }
1653
+ }
1654
+ openFloat(title, lines, width, opts = {}) {
1655
+ this.seq.reset();
1656
+ let handle;
1657
+ const float = new TextFloat(this.painter, PICKER_ICONS[this.ui.icons], title, lines, () => this.terminal.rows - 4, () => {
1658
+ handle?.hide();
1659
+ this.repaint();
1660
+ }, () => this.tui.requestRender(), opts);
1661
+ handle = this.tui.showOverlay(float, { width: Math.min(width, this.terminal.columns - 4), anchor: "center" });
1662
+ this.tui.requestRender();
1663
+ }
1664
+ /** `:map` with no arguments: list your mappings, vim-style. */
1665
+ openMappings(modes) {
1666
+ const lines = [];
1667
+ for (const mode of modes) {
1668
+ for (const b of this.keymap.bindings(mode).filter((x) => x.user)) {
1669
+ lines.push([
1670
+ ["Comment", `${MODE_LETTER[mode]} `],
1671
+ ["Title", formatKeys(b.keys).padEnd(14)],
1672
+ ["NormalFloat", ` ${b.rhsText}`],
1673
+ ]);
1674
+ }
1675
+ }
1676
+ if (lines.length === 0) {
1677
+ this.setMessage("No mapping found", "Comment");
1678
+ return;
1679
+ }
1680
+ this.openFloat("Mappings", lines, 72);
1681
+ }
1682
+ /** `:help`: every active key, generated from the keymap so it shows your mappings. */
1683
+ openHelp() {
1684
+ const lines = [];
1685
+ const labels = { normal: "NORMAL", insert: "INSERT", visual: "VISUAL", command: "COMMAND LINE" };
1686
+ for (const mode of KEY_MODES) {
1687
+ if (lines.length > 0)
1688
+ lines.push([]);
1689
+ lines.push([["Title", labels[mode]]]);
1690
+ const groups = new Map();
1691
+ for (const b of this.keymap.bindings(mode)) {
1692
+ const first = b.rhs[0];
1693
+ const single = b.rhs.length === 1 && first.kind === "action";
1694
+ const groupKey = single && first.name === "session.goto" ? "session.goto" : b.rhsText;
1695
+ const desc = single && first.name === "session.goto" ? "jump to session N" : describeRhs(b.rhs);
1696
+ const g = groups.get(groupKey) ?? { keys: [], desc, user: false };
1697
+ g.keys.push({ text: formatKeys(b.keys), user: b.user });
1698
+ g.user ||= b.user;
1699
+ groups.set(groupKey, g);
1700
+ }
1701
+ // your mappings first
1702
+ const ordered = [...groups.values()].sort((a, b) => Number(b.user) - Number(a.user));
1703
+ for (const g of ordered) {
1704
+ const texts = g.keys.map((k) => k.text);
1705
+ const keySegs = g.keys.length > 4 && groupKeyIsDigits(texts)
1706
+ ? [["Identifier", `${texts[0]}-${texts[texts.length - 1]}`]]
1707
+ : g.keys.map((k, i) => [k.user ? "String" : "Identifier", (i > 0 ? " " : "") + k.text]);
1708
+ const width = keySegs.reduce((n, [, t]) => n + visibleWidth(t), 0);
1709
+ lines.push([
1710
+ [g.user ? "String" : "Comment", g.user ? "* " : " "],
1711
+ ...keySegs,
1712
+ ["NormalFloat", " ".repeat(Math.max(1, 17 - width)) + g.desc],
1713
+ ]);
1714
+ }
1715
+ }
1716
+ lines.push([]);
1717
+ lines.push([["Title", "COMMANDS"]]);
1718
+ for (const [c, d] of EX_HELP)
1719
+ lines.push([["Comment", " "], ["Identifier", c.padEnd(29)], ["NormalFloat", d]]);
1720
+ lines.push([]);
1721
+ lines.push([["Comment", `* = your mapping · leader = ${this.keymap.leader === " " ? "<Space>" : this.keymap.leader}`]]);
1722
+ this.openFloat("Keys", lines, 76);
1723
+ }
1724
+ execHighlight(arg) {
1725
+ const P = this.painter;
1726
+ const cmd = parseHighlightCommand(arg);
1727
+ if (!cmd) {
1728
+ this.setMessage("usage: :hi Group guifg=#rrggbb guibg=#rrggbb gui=bold | :hi link A B | :hi clear", "Comment");
1729
+ return;
1730
+ }
1731
+ switch (cmd.kind) {
1732
+ case "show": {
1733
+ const a = P.attr(cmd.group);
1734
+ this.message = `${cmd.group} ${sgr(a)}xxx${RESET}${P.s("Normal")} ${describeAttr(a)}`;
1735
+ this.messageGroup = "Normal";
1736
+ return;
1737
+ }
1738
+ case "set":
1739
+ P.highlight(cmd.group, cmd.attr);
1740
+ break;
1741
+ case "link":
1742
+ P.link(cmd.from, cmd.to);
1743
+ break;
1744
+ case "clear":
1745
+ P.clear(cmd.group);
1746
+ break;
1747
+ }
1748
+ this.message = "";
1749
+ this.repaint();
1750
+ }
1751
+ /** `:colorscheme` with no argument: floating picker with live preview. */
1752
+ openThemePicker() {
1753
+ this.seq.reset();
1754
+ const original = this.painter.theme;
1755
+ const themes = listThemes();
1756
+ const preview = (name) => {
1757
+ try {
1758
+ this.painter.setTheme(loadTheme(name));
1759
+ this.invalidate();
1760
+ this.message = "";
1761
+ }
1762
+ catch (err) {
1763
+ this.setMessage(err.message, "ErrorMsg");
1764
+ }
1765
+ this.tui.requestRender(true);
1766
+ };
1767
+ let handle;
1768
+ const picker = new ThemePicker(this.painter, themes, original.name, PICKER_ICONS[this.ui.icons], preview, (name) => {
1769
+ handle?.hide();
1770
+ if (name) {
1771
+ preview(name);
1772
+ this.setMessage(`colorscheme ${name}`, "ModeMsg");
1773
+ }
1774
+ else {
1775
+ this.painter.setTheme(original);
1776
+ }
1777
+ this.repaint();
1778
+ }, () => this.tui.requestRender(), (name) => {
1779
+ try {
1780
+ const t = loadTheme(name);
1781
+ const p = extractPalette(t);
1782
+ return {
1783
+ colors: [p.bg, p.red, p.yellow, p.green, p.cyan, p.blue, p.magenta],
1784
+ tags: [t.background === "light" ? "light" : "dark", ...(t.transparent ? ["transparent"] : [])],
1785
+ };
1786
+ }
1787
+ catch {
1788
+ return undefined;
1789
+ }
1790
+ });
1791
+ handle = this.tui.showOverlay(picker, { width: 64, anchor: "center" });
1792
+ this.tui.requestRender();
1793
+ }
1794
+ // ------------------------------------------------------------------ scroll & search
1795
+ /** transcript rows: the screen minus winbar, statusline and command line */
1796
+ contentHeight() {
1797
+ return Math.max(1, this.terminal.rows - 3);
1798
+ }
1799
+ scrollBy(delta) {
1800
+ const total = this.buildPaneLines(this.paneWidth()).length;
1801
+ const max = Math.max(0, total - this.contentHeight());
1802
+ this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
1803
+ }
1804
+ scrollTop() {
1805
+ const total = this.buildPaneLines(this.paneWidth()).length;
1806
+ this.scrollOffset = Math.max(0, total - this.contentHeight());
1807
+ }
1808
+ setAllFolds(folded) {
1809
+ const s = this.focusedSession;
1810
+ if (!s)
1811
+ return;
1812
+ for (const i of s.items)
1813
+ if (i.tool)
1814
+ i.tool.folded = folded;
1815
+ s.version++;
1816
+ this.paneCache.key = "";
1817
+ }
1818
+ clearSearch() {
1819
+ this.searchQuery = "";
1820
+ this.matches = [];
1821
+ this.matchIdx = -1;
1822
+ }
1823
+ runSearch() {
1824
+ const lines = this.buildPaneLines(this.paneWidth());
1825
+ const q = this.searchQuery.toLowerCase();
1826
+ this.matches = [];
1827
+ if (!q)
1828
+ return;
1829
+ lines.forEach((l, i) => {
1830
+ if (l.plain.toLowerCase().includes(q))
1831
+ this.matches.push(i);
1832
+ });
1833
+ if (this.matches.length === 0) {
1834
+ this.setMessage(`E486: Pattern not found: ${this.searchQuery}`, "ErrorMsg");
1835
+ this.matchIdx = -1;
1836
+ return;
1837
+ }
1838
+ this.matchIdx = this.matches.length - 1;
1839
+ this.scrollToLine(this.matches[this.matchIdx]);
1840
+ this.announceMatch();
1841
+ }
1842
+ gotoMatch(dir) {
1843
+ if (this.matches.length === 0) {
1844
+ if (this.searchQuery)
1845
+ this.setMessage(`E486: Pattern not found: ${this.searchQuery}`, "ErrorMsg");
1846
+ return;
1847
+ }
1848
+ const step = this.searchBackward ? -dir : dir;
1849
+ this.matchIdx = (this.matchIdx + step + this.matches.length) % this.matches.length;
1850
+ this.scrollToLine(this.matches[this.matchIdx]);
1851
+ this.announceMatch();
1852
+ }
1853
+ announceMatch() {
1854
+ const prefix = this.searchBackward ? "?" : "/";
1855
+ this.setMessage(`${prefix}${this.searchQuery} match ${this.matchIdx + 1}/${this.matches.length}`, "Search");
1856
+ }
1857
+ scrollToLine(lineIdx) {
1858
+ const total = this.buildPaneLines(this.paneWidth()).length;
1859
+ const h = this.contentHeight();
1860
+ const desired = total - lineIdx - Math.floor(h / 2);
1861
+ this.scrollOffset = Math.max(0, Math.min(Math.max(0, total - h), desired - 1));
1862
+ }
1863
+ // ------------------------------------------------------------------ rendering
1864
+ sidebarWidth(width) {
1865
+ return this.ui.sidebar && width >= this.ui.sidebar_width + 40 ? this.ui.sidebar_width : 0;
1866
+ }
1867
+ paneWidth() {
1868
+ const width = this.terminal.columns;
1869
+ const sb = this.sidebarWidth(width);
1870
+ return Math.max(20, width - sb - (sb > 0 ? 1 : 0) - 2);
1871
+ }
1872
+ render(width) {
1873
+ const P = this.painter;
1874
+ const sideH = Math.max(1, this.terminal.rows - 2);
1875
+ const paneH = this.contentHeight();
1876
+ const sbW = this.sidebarWidth(width);
1877
+ const paneW = this.paneWidth();
1878
+ const sidebar = sbW > 0 ? this.renderSidebar(sbW, sideH) : [];
1879
+ const intro = this.sessions.length === 0;
1880
+ const paneLines = intro ? this.introLines(paneW, paneH) : this.buildPaneLines(paneW);
1881
+ const { start } = intro ? { start: 0 } : this.viewRange(paneLines.length);
1882
+ const view = paneLines.slice(start, start + paneH);
1883
+ const [selA, selB] = [Math.min(this.visual.anchor, this.visual.cursor), Math.max(this.visual.anchor, this.visual.cursor)];
1884
+ const visualBg = P.attr("Visual").bg;
1885
+ const out = [];
1886
+ const sep = P.p("WinSeparator", this.icons.sep);
1887
+ const normalBg = P.s("Normal");
1888
+ for (let r = 0; r < sideH; r++) {
1889
+ const left = sbW > 0 ? sidebar[r] + sep : "";
1890
+ if (r === 0) {
1891
+ out.push(left + this.renderWinbar(paneW + 2));
1892
+ continue;
1893
+ }
1894
+ const vi = r - 1 - (paneH - view.length);
1895
+ const line = vi >= 0 ? view[vi] : undefined;
1896
+ const idx = start + vi;
1897
+ let text = line?.text ?? "";
1898
+ let bg = normalBg;
1899
+ if (line && this.mode === "VISUAL" && idx >= selA && idx <= selB) {
1900
+ // like nvim: the Visual background under the line's own colors
1901
+ bg = P.on("Normal", visualBg);
1902
+ text = bg + line.text.split(normalBg).join(bg);
1903
+ }
1904
+ else if (line && this.matches.includes(idx)) {
1905
+ text = P.s("Search") + line.plain;
1906
+ }
1907
+ const pad = Math.max(0, paneW + 2 - 1 - visibleWidth(line?.plain ?? ""));
1908
+ out.push(left + bg + " " + text + bg + " ".repeat(pad) + RESET);
1909
+ }
1910
+ if (this.seq.waiting) {
1911
+ // which-key: what can follow the keys typed so far, over the bottom of the screen
1912
+ const panel = this.whichKeyPanel(width, Math.max(3, Math.floor(sideH / 2)));
1913
+ out.splice(out.length - panel.length, panel.length, ...panel);
1914
+ }
1915
+ out.push(this.renderStatusline(width));
1916
+ out.push(this.renderCmdline(width));
1917
+ return out.map((l) => truncateToWidth(l, width));
1918
+ }
1919
+ /** The dashboard's one-key actions, while no agent is open (like LazyVim's start screen). */
1920
+ dashboardKey(data) {
1921
+ if (this.sessions.length > 0 || this.mode !== "NORMAL" || this.seq.pendingKeys.length > 0)
1922
+ return false;
1923
+ switch (keyFromInput(data)) {
1924
+ case "n":
1925
+ this.mode = "COMMAND";
1926
+ this.cmdBuf = "new ";
1927
+ return true;
1928
+ case "t":
1929
+ this.openThemePicker();
1930
+ return true;
1931
+ case "s":
1932
+ this.openSettings();
1933
+ return true;
1934
+ case "m":
1935
+ this.openModelPicker();
1936
+ return true;
1937
+ case "e":
1938
+ this.openEffortPicker();
1939
+ return true;
1940
+ case "c":
1941
+ this.openHealth();
1942
+ return true;
1943
+ case "?":
1944
+ this.openHelp();
1945
+ return true;
1946
+ case "q":
1947
+ this.exec("q");
1948
+ return true;
1949
+ default:
1950
+ return false; // i and everything else work as usual
1951
+ }
1952
+ }
1953
+ /** which-key.nvim's panel: every key that can follow the pending ones, and what it does. */
1954
+ whichKeyPanel(width, maxRows) {
1955
+ const P = this.painter;
1956
+ const I = PICKER_ICONS[this.ui.icons];
1957
+ const typed = this.seq.pendingKeys;
1958
+ const mode = this.keyMode();
1959
+ const next = new Map();
1960
+ for (const b of this.keymap.bindings(mode)) {
1961
+ if (b.keys.length <= typed.length || !typed.every((k, i) => b.keys[i] === k))
1962
+ continue;
1963
+ if (b.rhs.length === 1 && b.rhs[0].kind === "nop")
1964
+ continue;
1965
+ const key = b.keys[typed.length];
1966
+ if (b.keys.length === typed.length + 1)
1967
+ next.set(key, { label: whichKeyLabel(b.rhs), group: false });
1968
+ else if (!next.has(key))
1969
+ next.set(key, { label: "", group: true });
1970
+ }
1971
+ for (const [key, v] of next) {
1972
+ if (!v.group)
1973
+ continue;
1974
+ const n = this.keymap.bindings(mode).filter((b) => b.keys.length > typed.length + 1 && b.keys[typed.length] === key && typed.every((k, i) => b.keys[i] === k)).length;
1975
+ v.label = `+${n} more`;
1976
+ }
1977
+ const items = [...next.entries()].sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()) || (a < b ? 1 : -1));
1978
+ const bg = P.attr("NormalFloat").bg;
1979
+ const inner = width - 2;
1980
+ const border = (s) => P.on("FloatBorder", bg) + s + RESET;
1981
+ const keyW = Math.max(1, ...items.map(([k]) => visibleWidth(formatKeys([k]))));
1982
+ const colW = Math.min(46, Math.max(24, ...items.map(([k, v]) => visibleWidth(formatKeys([k])) + 3 + visibleWidth(v.label) + 3)));
1983
+ const cols = Math.max(1, Math.floor((inner - 2) / colW));
1984
+ const rowsNeeded = Math.ceil(items.length / cols);
1985
+ const rows = Math.min(rowsNeeded, Math.max(1, maxRows - 2));
1986
+ const lines = [];
1987
+ const title = ` ${formatKeys(typed)} `;
1988
+ lines.push(border(I.tl + I.h) + P.on("FloatTitle", bg) + title + RESET + border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr));
1989
+ for (let r = 0; r < rows; r++) {
1990
+ let content = P.on("NormalFloat", bg) + " ";
1991
+ let used = 1;
1992
+ for (let c = 0; c < cols; c++) {
1993
+ const item = items[c * rows + r];
1994
+ if (!item)
1995
+ break;
1996
+ const [k, v] = item;
1997
+ const keyText = formatKeys([k]);
1998
+ const label = truncateToWidth(v.label, colW - keyW - 4, "…");
1999
+ const cell = `${keyText.padStart(keyW)} → ${label}`;
2000
+ content +=
2001
+ P.on("Special", bg) + keyText.padStart(keyW) + P.on("Comment", bg) + " → " + P.on(v.group ? "Keyword" : "NormalFloat", bg) + label + P.on("NormalFloat", bg) + " ".repeat(Math.max(0, colW - visibleWidth(cell)));
2002
+ used += colW;
2003
+ }
2004
+ lines.push(border(I.v) + content + " ".repeat(Math.max(0, inner - used)) + RESET + border(I.v));
2005
+ }
2006
+ const more = items.length > rows * cols ? ` ${items.length - rows * cols} more: widen the terminal ` : "";
2007
+ const foot = ` Esc cancels${more ? ` ·${more}` : " "}`;
2008
+ lines.push(border(I.bl) + P.on("Comment", bg) + foot + RESET + border(I.h.repeat(Math.max(0, inner - visibleWidth(foot))) + I.br));
2009
+ return lines.map((l) => truncateToWidth(l, width));
2010
+ }
2011
+ /** Neovim-style 'winbar' over the transcript: which agent, its model, where it works. */
2012
+ renderWinbar(w) {
2013
+ const P = this.painter;
2014
+ const bg = P.attr("WinBar").bg ?? P.attr("Normal").bg;
2015
+ const s = this.focusedSession;
2016
+ const left = s ? ` ${this.icons.open_agent} ${s.name}` : ` ${this.icons.open_agent} [no agent]`;
2017
+ const model = s ? ` ${s.engine !== "pi" && s.model !== ENGINE_NAMES[s.engine] ? `${ENGINE_NAMES[s.engine]} · ` : ""}${s.model}${s.effort ? ` · ${s.effort}` : ""}` : "";
2018
+ const where = s?.worktree?.path ? `${this.shortPath(s.worktree.path)}${s.worktree.riftri ? " · riftri" : ""}` : s ? "this checkout" : "";
2019
+ const status = s?.state === "paused" ? " paused" : s?.queue.length ? ` ${this.icons.queued}${s.queue.length} queued` : "";
2020
+ // the path gives way first (from its start: the end names the worktree), always two spaces before it
2021
+ const pathRoom = w - visibleWidth(left) - visibleWidth(model) - visibleWidth(status) - 3;
2022
+ const shownWhere = !where ? "" : visibleWidth(where) <= pathRoom ? where : pathRoom > 4 ? `…${where.slice(where.length - pathRoom + 1)}` : "";
2023
+ const right = shownWhere ? `${shownWhere} ` : "";
2024
+ const room = Math.max(0, w - visibleWidth(left) - visibleWidth(model) - visibleWidth(right));
2025
+ return (P.on("WinBar", bg) +
2026
+ left +
2027
+ RESET +
2028
+ P.on("Comment", bg) +
2029
+ model +
2030
+ P.on("WarningMsg", bg) +
2031
+ status +
2032
+ P.on("Normal", bg) +
2033
+ " ".repeat(Math.max(0, room - visibleWidth(status))) +
2034
+ P.on("Comment", bg) +
2035
+ right +
2036
+ RESET);
2037
+ }
2038
+ /** Shown when no agent is running, like Neovim's intro screen. */
2039
+ introLines(paneW, paneH) {
2040
+ const P = this.painter;
2041
+ const center = (plain) => " ".repeat(Math.max(0, Math.floor((paneW - visibleWidth(plain)) / 2)));
2042
+ const rows = [];
2043
+ const add = (text, plain, indent = center(plain)) => rows.push({ text: indent + text, plain: indent + plain });
2044
+ // LazyVim's dashboard: a big logo, then one key per action
2045
+ const logo = this.ui.icons === "unicode" && paneW >= LOGO[0].length + 2 && paneH >= 22;
2046
+ if (logo) {
2047
+ const groups = ["Function", "Function", "Special", "Special", "Keyword", "Keyword"];
2048
+ LOGO.forEach((l, i) => add(P.p(groups[i] ?? "Title", l), l));
2049
+ }
2050
+ else {
2051
+ const title = "VIGENT";
2052
+ add(P.p("Title", title), title);
2053
+ }
2054
+ const tagline = "coding agents, the vim way · each one in its own git worktree";
2055
+ add(P.p("Comment", tagline), tagline);
2056
+ add("", "");
2057
+ const actions = DASHBOARD.map(([key, icon, label]) => [key, this.ui.icons === "unicode" ? icon : "", label]);
2058
+ const labelW = Math.max(...actions.map(([, , l]) => l.length));
2059
+ const blockW = 2 + labelW + 6;
2060
+ const indent = center("x".repeat(blockW));
2061
+ for (const [key, icon, label] of actions) {
2062
+ const plain = `${icon ? `${icon} ` : " "}${label.padEnd(labelW)} ${key}`;
2063
+ add(P.p("Special", icon ? `${icon} ` : " ") + P.p("Normal", label.padEnd(labelW)) + " " + P.p("Title", key), plain, indent);
2064
+ }
2065
+ if (this.engines) {
2066
+ add("", "");
2067
+ const def = this.defaultEngine();
2068
+ const parts = this.engines.map((e) => `${e.connected ? this.icons.ok : e.installed ? this.icons.warn : this.icons.off} ${e.name}`);
2069
+ const plain = parts.join(" ");
2070
+ const indentE = center(plain);
2071
+ let text = "";
2072
+ this.engines.forEach((e, i) => {
2073
+ const group = e.connected ? "String" : e.installed ? "WarningMsg" : "Comment";
2074
+ text += (i ? " " : "") + P.p(group, parts[i].slice(0, parts[i].indexOf(" ") + 1)) + P.p(e.id === def ? "Title" : "Comment", e.name);
2075
+ });
2076
+ add(text, plain, indentE);
2077
+ const ready = this.engines.filter((e) => e.connected).length;
2078
+ const foot = `${this.ui.icons === "unicode" ? "⚡ " : ""}vigent ${VERSION_TEXT} · ${listThemes().length} themes · ${ready} of ${this.engines.length} engines ready`;
2079
+ add(P.p("Comment", foot), foot);
2080
+ }
2081
+ if (!this.workspace.root) {
2082
+ add("", "");
2083
+ const note = "(not a git repository: agents will share this folder)";
2084
+ add(P.p("WarningMsg", note), note);
2085
+ }
2086
+ const top = Math.max(0, Math.floor((paneH - rows.length) / 2));
2087
+ const normal = P.s("Normal");
2088
+ const lines = rows.map((r) => ({ text: r.text.split(RESET).join(RESET + normal), plain: r.plain }));
2089
+ const bottom = Math.max(0, paneH - top - lines.length);
2090
+ return [...lines, ...Array.from({ length: bottom }, () => ({ text: "", plain: "" }))];
2091
+ }
2092
+ /**
2093
+ * Neovim tree-style sidebar (neo-tree / nvim-tree look):
2094
+ * winbar header, number column (session index, used by 1-9),
2095
+ * tree guides, cursorline on the focused session, ~ filler rows.
2096
+ */
2097
+ renderSidebar(w, h) {
2098
+ const P = this.painter;
2099
+ const base = P.attr("VigentSidebar");
2100
+ const bg = base.bg ?? P.attr("Normal").bg;
2101
+ const clBg = P.attr("CursorLine").bg ?? bg;
2102
+ const I = this.icons;
2103
+ const lines = [];
2104
+ // row = list of [group, text] segments drawn on one background, padded to w
2105
+ const row = (segs, rowBg = bg) => {
2106
+ let out = "";
2107
+ let used = 0;
2108
+ for (const [group, text] of segs) {
2109
+ const t = truncateToWidth(text, Math.max(0, w - used), "…");
2110
+ out += RESET + P.on(group, rowBg) + t;
2111
+ used += visibleWidth(t);
2112
+ }
2113
+ return out + P.on("VigentSidebar", rowBg) + " ".repeat(Math.max(0, w - used)) + RESET;
2114
+ };
2115
+ // winbar
2116
+ const running = this.sessions.filter((x) => x.state === "running").length;
2117
+ const right = running > 0 ? `${running} running ` : `${this.sessions.length} `;
2118
+ const title = " SESSIONS";
2119
+ lines.push(row([
2120
+ ["WinBar", title],
2121
+ ["VigentSidebar", " ".repeat(Math.max(1, w - visibleWidth(title) - visibleWidth(right)))],
2122
+ ["Comment", right],
2123
+ ]));
2124
+ this.sessions.forEach((s, i) => {
2125
+ const last = i === this.sessions.length - 1;
2126
+ const focused = i === this.focusIdx;
2127
+ const rowBg = focused ? clBg : bg;
2128
+ const glyphGroup = s.state === "running"
2129
+ ? "String"
2130
+ : s.state === "waiting"
2131
+ ? "WarningMsg"
2132
+ : s.state === "error"
2133
+ ? "ErrorMsg"
2134
+ : "Comment";
2135
+ const glyph = s.state === "running" ? this.spinner() : I.state[s.state];
2136
+ const num = this.ui.number ? `${String(i + 1).padStart(3)} ` : " ";
2137
+ lines.push(row([
2138
+ [focused ? "CursorLineNr" : "LineNr", num],
2139
+ ["VigentIndent", last ? I.last : I.tee],
2140
+ [glyphGroup, `${glyph} `],
2141
+ [focused ? "Title" : "VigentSidebar", s.name],
2142
+ ["Comment", s.queue.length > 0 ? ` ${I.queued}${s.queue.length}` : ""],
2143
+ ], rowBg));
2144
+ const model = s.model.replace(/^claude-/, "");
2145
+ const indent = [
2146
+ ["LineNr", " ".repeat(num.length)],
2147
+ ["VigentIndent", last ? " " : I.pipe],
2148
+ ];
2149
+ if (s.worktree) {
2150
+ // the +/- counts matter more than the end of a long branch name
2151
+ const stat = this.statSegments(s.worktree.stat);
2152
+ const statWidth = stat.reduce((n, [, t]) => n + visibleWidth(t), 0);
2153
+ const room = w - num.length - 2 - 2 - visibleWidth(I.worktree) - statWidth;
2154
+ const branch = truncateToWidth(s.worktree.branch, Math.max(4, room), "…");
2155
+ lines.push(row([...indent, ["Comment", ` ${I.worktree}${branch}`], ...stat]));
2156
+ }
2157
+ else {
2158
+ lines.push(row([...indent, ["Comment", ` ${model}`]]));
2159
+ }
2160
+ });
2161
+ // engines at the bottom, like a second neo-tree source
2162
+ const engines = this.engines;
2163
+ const block = [];
2164
+ if (engines && h - lines.length >= engines.length + 3) {
2165
+ const ready = engines.filter((e) => e.connected).length;
2166
+ const title = " ENGINES";
2167
+ const count = `${ready}/${engines.length} `;
2168
+ block.push(row([
2169
+ ["WinBar", title],
2170
+ ["VigentSidebar", " ".repeat(Math.max(1, w - visibleWidth(title) - visibleWidth(count)))],
2171
+ ["Comment", count],
2172
+ ]));
2173
+ const def = this.defaultEngine();
2174
+ for (const e of engines) {
2175
+ const glyph = e.connected ? I.ok : e.installed ? I.warn : I.off;
2176
+ const group = e.connected ? "String" : e.installed ? "WarningMsg" : "Comment";
2177
+ const note = e.checking
2178
+ ? "checking…"
2179
+ : e.connected
2180
+ ? e.id === def
2181
+ ? "default"
2182
+ : ""
2183
+ : e.id === "pi"
2184
+ ? "no API key"
2185
+ : e.installed
2186
+ ? (e.connect ?? "not signed in")
2187
+ : "not installed";
2188
+ const left = ` ${glyph} ${e.name}`;
2189
+ const room = w - visibleWidth(left) - 1;
2190
+ const tail = note ? truncateToWidth(note, Math.max(0, room - 1), "…") : "";
2191
+ block.push(row([
2192
+ [group, ` ${glyph} `],
2193
+ [e.connected ? "VigentSidebar" : "Comment", e.name],
2194
+ ["VigentSidebar", " ".repeat(Math.max(1, room - visibleWidth(tail)))],
2195
+ ["Comment", tail],
2196
+ ]));
2197
+ }
2198
+ }
2199
+ while (lines.length < h - block.length)
2200
+ lines.push(row([["EndOfBuffer", this.ui.tilde ? "~" : ""]]));
2201
+ lines.push(...block);
2202
+ return lines.slice(0, h);
2203
+ }
2204
+ /** " +41 −18" in the diff colors; nothing when there are no changes yet. */
2205
+ statSegments(stat) {
2206
+ if (!stat || (stat.added === 0 && stat.removed === 0))
2207
+ return [];
2208
+ return [
2209
+ ["Added", ` +${stat.added}`],
2210
+ ["Removed", ` ${this.icons.minus}${stat.removed}`],
2211
+ ];
2212
+ }
2213
+ buildPaneLines(paneW) {
2214
+ const s = this.focusedSession;
2215
+ const key = `${s ? `${s.id}:${s.version}` : "empty"}:${paneW}:${this.painter.generation}:${this.ui.icons}`;
2216
+ if (this.paneCache.key === key)
2217
+ return this.paneCache.lines;
2218
+ const P = this.painter;
2219
+ const I = this.icons;
2220
+ const lines = [];
2221
+ const normal = P.s("Normal");
2222
+ // a RESET inside a line would drop the theme background; restore it after each one
2223
+ const push = (text, plain, meta = {}) => lines.push({ text: text.split(RESET).join(RESET + normal), plain, ...meta });
2224
+ let nextBlock = 0;
2225
+ (s?.items ?? []).forEach((item, n) => {
2226
+ switch (item.kind) {
2227
+ case "user": {
2228
+ push("", "");
2229
+ wrapTextWithAnsi(item.text, paneW - 2).forEach((l, i) => {
2230
+ push(P.p("VigentUser", I.prompt) + P.s("Normal") + P.p("Keyword", l), I.prompt + stripAnsi(l), {
2231
+ item: n,
2232
+ start: i === 0,
2233
+ });
2234
+ });
2235
+ break;
2236
+ }
2237
+ case "agent": {
2238
+ push("", "");
2239
+ const text = item.text + (item.streaming ? ` ${I.cursor}` : "");
2240
+ const md = renderMarkdown(text, paneW, P, I.bullet, nextBlock);
2241
+ md.lines.forEach((l, i) => push(P.s("Normal") + l.text + RESET, l.plain, { item: n, start: i === 0, block: l.block, label: l.label }));
2242
+ nextBlock += md.blocks;
2243
+ break;
2244
+ }
2245
+ case "tool": {
2246
+ const t = item.tool;
2247
+ const fold = t.folded ? I.folded : I.open;
2248
+ push(P.p("NonText", ` ${fold} `) + P.p("VigentTool", t.name) + P.p("Comment", ` ${t.summary}`), ` ${fold} ${t.name} ${t.summary}`, { item: n });
2249
+ if (!t.folded && t.output) {
2250
+ const block = nextBlock++;
2251
+ const outLines = t.output.split("\n");
2252
+ for (const ol of outLines.slice(0, 12)) {
2253
+ for (const wl of wrapTextWithAnsi(ol, paneW - 4)) {
2254
+ push(` ${P.p("VigentToolOutput", wl)}`, ` ${stripAnsi(wl)}`, { item: n, block });
2255
+ }
2256
+ }
2257
+ if (outLines.length > 12) {
2258
+ push(` ${P.p("NonText", `… ${outLines.length - 12} more lines`)}`, ` … ${outLines.length - 12} more lines`, { item: n });
2259
+ }
2260
+ }
2261
+ break;
2262
+ }
2263
+ case "info": {
2264
+ push("", "");
2265
+ wrapTextWithAnsi(item.text, paneW).forEach((l, i) => push(P.p("Comment", l), stripAnsi(l), { item: n, start: i === 0 }));
2266
+ break;
2267
+ }
2268
+ }
2269
+ });
2270
+ this.paneCache = { lines, key };
2271
+ return lines;
2272
+ }
2273
+ renderStatusline(width) {
2274
+ const P = this.painter;
2275
+ const s = this.focusedSession;
2276
+ const modeBadge = P.p(MODE_GROUP[this.mode], ` ${this.mode} `) + (this.demo ? P.p("VigentModeCommand", " DEMO ") : "");
2277
+ const running = this.sessions.filter((x) => x.state === "running").length;
2278
+ const leftText = s ? ` ${s.name} ` : " [no session] ";
2279
+ const st = s?.worktree?.stat;
2280
+ const changes = st && (st.added || st.removed) ? ` +${st.added} ${this.icons.minus}${st.removed}` : "";
2281
+ const worktree = s?.worktree ? `${this.icons.worktree}${s.worktree.branch}${changes} ` : "";
2282
+ const right = s
2283
+ ? ` ${s.model} ${formatTokens(s.tokens)} ${running}/${this.sessions.length}${running > 0 ? ` ${this.spinner()}` : ""} `
2284
+ : "";
2285
+ const plainLen = visibleWidth(` ${this.mode} `) + (this.demo ? 6 : 0) + visibleWidth(leftText) + visibleWidth(worktree) + visibleWidth(right);
2286
+ const fill = Math.max(0, width - plainLen);
2287
+ return (modeBadge +
2288
+ P.s("StatusLine") +
2289
+ leftText +
2290
+ P.s("StatusLineNC") +
2291
+ worktree +
2292
+ " ".repeat(Math.max(0, fill - (worktree ? 0 : 0))) +
2293
+ P.s("StatusLine") +
2294
+ right +
2295
+ RESET);
2296
+ }
2297
+ renderCmdline(width) {
2298
+ const P = this.painter;
2299
+ const bg = P.s("MsgArea") || P.s("Normal");
2300
+ let content;
2301
+ switch (this.confirm ? "CONFIRM" : this.mode) {
2302
+ case "CONFIRM":
2303
+ content = P.p("Question", this.confirm.question) + P.s("Normal") + " " + P.p("PmenuSel", " ");
2304
+ break;
2305
+ case "COMMAND":
2306
+ content = P.s("Normal") + ":" + this.cmdBuf + P.p("PmenuSel", " ");
2307
+ break;
2308
+ case "SEARCH":
2309
+ content = P.s("Normal") + (this.searchBackward ? "?" : "/") + this.cmdBuf + P.p("PmenuSel", " ");
2310
+ break;
2311
+ case "VISUAL": {
2312
+ const n = Math.abs(this.visual.cursor - this.visual.anchor) + 1;
2313
+ content = P.p("ModeMsg", "-- VISUAL LINE --") + P.p("Comment", ` ${n} line${n === 1 ? "" : "s"} · y yank · ib code block · Esc cancel`);
2314
+ break;
2315
+ }
2316
+ case "INSERT":
2317
+ content =
2318
+ P.p("ModeMsg", "-- INSERT --") +
2319
+ P.s("Normal") +
2320
+ ` ${this.icons.prompt}` +
2321
+ this.inputBuf +
2322
+ P.p("Comment", formatKeys(this.seq.pendingKeys).replace(/<Space>/g, " ")) +
2323
+ P.p("PmenuSel", " ") +
2324
+ (this.sessions.length === 0 && !this.inputBuf
2325
+ ? P.p("Comment", ` type what you want done; Enter starts an agent on ${ENGINE_NAMES[this.defaultEngine()]}`)
2326
+ : "");
2327
+ break;
2328
+ default: {
2329
+ if (this.message)
2330
+ content = P.p(this.messageGroup, this.message);
2331
+ else
2332
+ content = P.p("NonText", `:help for keys`);
2333
+ }
2334
+ }
2335
+ // vim's 'showcmd': keys typed so far for a pending mapping, near the right edge
2336
+ const showcmd = this.mode === "INSERT" ? "" : formatKeys(this.seq.pendingKeys);
2337
+ const showWidth = showcmd ? Math.max(10, visibleWidth(showcmd) + 1) : 0;
2338
+ const pad = Math.max(0, width - visibleWidth(stripAnsi(content)) - showWidth);
2339
+ const right = showcmd ? P.s("Normal") + showcmd.padEnd(showWidth) : "";
2340
+ return bg + content + bg + " ".repeat(pad) + right + RESET;
2341
+ }
2342
+ }
2343
+ function stripAnsi(s) {
2344
+ // biome-ignore lint: control chars intended
2345
+ return s.replace(/\x1b\[[0-9;]*m|\x1b_pi:c\x07/g, "");
2346
+ }
2347
+ const MODE_LETTER = { normal: "n", insert: "i", visual: "x", command: "c" };
2348
+ function mapModes(letter) {
2349
+ if (letter === "i")
2350
+ return ["insert"];
2351
+ if (letter === "c")
2352
+ return ["command"];
2353
+ if (letter === "x" || letter === "v")
2354
+ return ["visual"];
2355
+ return ["normal"];
2356
+ }
2357
+ function groupKeyIsDigits(keys) {
2358
+ return keys.every((k) => /^\d$/.test(k));
2359
+ }
2360
+ /** A key's job, short, for the which-key panel: "Start an agent in its own worktree". */
2361
+ function whichKeyLabel(rhs) {
2362
+ const label = settingsLabel(rhs).replace(/ \(:[^)]*\)$/, "");
2363
+ return label.charAt(0).toUpperCase() + label.slice(1);
2364
+ }
2365
+ /** A key's job, for :settings: what an ex command does (from :help), else the action's description. */
2366
+ function settingsLabel(rhs) {
2367
+ const r = rhs[0];
2368
+ if (rhs.length === 1 && r.kind === "ex") {
2369
+ const word = r.cmd.trim().split(/\s+/)[0];
2370
+ const help = EX_HELP.find(([c]) => c === `:${word}` || c.startsWith(`:${word} `));
2371
+ const cmd = `:${r.cmd.trimEnd()}${r.execute ? "" : " …"}`;
2372
+ return help ? `${help[1].split(" (")[0]} (${cmd})` : cmd;
2373
+ }
2374
+ return describeRhs(rhs);
2375
+ }
2376
+ function describeRhs(rhs) {
2377
+ return rhs
2378
+ .map((r) => {
2379
+ switch (r.kind) {
2380
+ case "action":
2381
+ return ACTIONS[r.name] + (r.arg ? ` (${r.arg})` : "");
2382
+ case "ex":
2383
+ return `:${r.cmd.trimEnd()}${r.execute ? "" : " …"}`;
2384
+ case "keys":
2385
+ return `same as ${formatKeys(r.keys)}`;
2386
+ case "nop":
2387
+ return "disabled";
2388
+ }
2389
+ })
2390
+ .join(", then ");
2391
+ }
2392
+ const EX_HELP = [
2393
+ [":new [task]", "start an agent in its own worktree"],
2394
+ [":kill / :kill!", "stop the agent (keep / remove its worktree)"],
2395
+ [":new! [name]", "agent in this checkout, no worktree"],
2396
+ [":diff / :diff split", "the agent's changes, unified / side by side (s switches)"],
2397
+ [":merge [--no-squash]", "squash-merge the agent's branch into yours"],
2398
+ [":pr", "push the branch and open a pull request"],
2399
+ [":queue <prompt>", "send after the current run (:queue lists)"],
2400
+ [":pause / :resume", "hold the agent and its queue / continue"],
2401
+ [":dash", "all agents at a glance"],
2402
+ [":tree", "the conversation's branches; go back and branch"],
2403
+ [":new [task] --engine claude", "run the agent with Claude Code, codex, opencode or pi"],
2404
+ [":checkhealth", "which engines are installed and signed in"],
2405
+ [":model [name]", "pick the agent's model (no agent: for new agents)"],
2406
+ [":effort [level]", "pick its reasoning effort: low … max (pi: thinking level)"],
2407
+ [":settings", "theme, look, engine, worktrees and keys in one place (w saves)"],
2408
+ [":w [file]", "save the transcript as Markdown"],
2409
+ [":worktrees", "list agent worktrees"],
2410
+ [":attach <branch>", "reopen an agent in a kept worktree"],
2411
+ [":worktree remove <branch>", "delete a worktree (safe; ! forces)"],
2412
+ [":colorscheme [name]", "switch theme (no name: picker)"],
2413
+ [":theme save <name>", "save the theme on screen, :hi changes included"],
2414
+ [":set transparent", "use the terminal's background (notransparent, transparent&)"],
2415
+ [":hi Group guifg=.. guibg=..", "change a highlight group"],
2416
+ [":hi link A B / :hi clear", "link groups / undo :hi"],
2417
+ [":set option / nooption", "change options (:set lists them)"],
2418
+ [":map lhs rhs", "map keys (:nmap :imap :cmap, :unmap)"],
2419
+ [":map", "list your mappings"],
2420
+ [":let mapleader = \",\"", "change the leader key"],
2421
+ [":model id", "switch the focused agent's model"],
2422
+ [":q / :q!", "quit (asks if agents are running) / quit anyway"],
2423
+ ];
2424
+ /** One line of a message for previews: markdown markers and code fences dropped. */
2425
+ function plainPreview(text) {
2426
+ return text
2427
+ .replace(/^\s*(```|~~~)[\w+-]*\s*$/gm, "")
2428
+ .replace(/\*\*|__|`/g, "")
2429
+ .replace(/^\s*[-*+]\s+/gm, "")
2430
+ .replace(/\s+/g, " ")
2431
+ .trim();
2432
+ }
2433
+ function slugifyName(name) {
2434
+ return (name
2435
+ .toLowerCase()
2436
+ .replace(/[^a-z0-9]+/g, "-")
2437
+ .replace(/^-+|-+$/g, "") || "transcript");
2438
+ }
2439
+ function describeAttr(a) {
2440
+ const parts = [];
2441
+ if (a.fg)
2442
+ parts.push(`guifg=${a.fg}`);
2443
+ if (a.bg)
2444
+ parts.push(`guibg=${a.bg}`);
2445
+ const flags = ["bold", "italic", "underline", "reverse"].filter((k) => a[k]);
2446
+ if (flags.length > 0)
2447
+ parts.push(`gui=${flags.join(",")}`);
2448
+ return parts.join(" ") || "cleared";
2449
+ }
2450
+ function formatTokens(n) {
2451
+ if (n >= 1000)
2452
+ return `${(n / 1000).toFixed(1)}k tok`;
2453
+ return `${n} tok`;
2454
+ }
2455
+ /** A short agent name from a prompt: its first words, without punctuation ("Fix the flaky checkout test, please" -> "fix the flaky checkout test"). */
2456
+ export function agentNameFrom(prompt) {
2457
+ const words = prompt
2458
+ .toLowerCase()
2459
+ .replace(/[`'"“”‘’]/g, "")
2460
+ .replace(/[^\p{L}\p{N}/]+/gu, " ")
2461
+ .trim()
2462
+ .split(/\s+/)
2463
+ .filter((w) => !["please", "pls", "can", "you", "could", "would", "the", "a", "an"].includes(w));
2464
+ const name = words.slice(0, 5).join(" ");
2465
+ return name || "agent";
2466
+ }
2467
+ /** The dashboard's logo (ANSI Shadow, like LazyVim's). */
2468
+ const LOGO = [
2469
+ "██╗ ██╗██╗ ██████╗ ███████╗███╗ ██╗████████╗",
2470
+ "██║ ██║██║██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝",
2471
+ "██║ ██║██║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ",
2472
+ "╚██╗ ██╔╝██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ",
2473
+ " ╚████╔╝ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ",
2474
+ " ╚═══╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ",
2475
+ ];
2476
+ /** The dashboard's keys (active while no agent is open): key, icon, label. */
2477
+ const DASHBOARD = [
2478
+ ["i", "✎", "New agent: type a task, Enter"],
2479
+ ["n", "+", "New agent by name"],
2480
+ ["t", "◐", "Theme"],
2481
+ ["s", "⚙", "Settings"],
2482
+ ["m", "◆", "Model"],
2483
+ ["e", "↯", "Effort"],
2484
+ ["c", "✓", "Check engines"],
2485
+ ["?", "⌘", "Keys"],
2486
+ ["q", "×", "Quit"],
2487
+ ];