@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/keys.js ADDED
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Vim-style keymaps.
3
+ *
4
+ * Keys use vim notation: `j`, `gg`, `<C-d>`, `<S-Tab>`, `<M-x>`, `<CR>`,
5
+ * `<Esc>`, `<Space>`, `<leader>`, `<F5>`. A mapping's right-hand side is one
6
+ * of (or a list of):
7
+ *
8
+ * "session.next" an action
9
+ * ":colorscheme nord<CR>" an ex command (without <CR>: opens the command line prefilled)
10
+ * "gg" other keys, replayed through the built-in keymap (like noremap)
11
+ * "<Nop>" or false nothing: unbinds the key
12
+ *
13
+ * Like vim, a key that starts a longer mapping waits `timeoutlen` ms for the
14
+ * rest; that is what makes `jk` = `<Esc>` in insert mode work.
15
+ */
16
+ import { parseKey } from "@mariozechner/pi-tui";
17
+ import { didYouMean } from "./suggest.js";
18
+ export const KEY_MODES = ["normal", "insert", "visual", "command"];
19
+ /** vigent's default <leader>, as in most Neovim configs (vim's own default is "\\") */
20
+ export const DEFAULT_LEADER = "<Space>";
21
+ // ---------------------------------------------------------------------------
22
+ // Actions
23
+ // ---------------------------------------------------------------------------
24
+ export const ACTIONS = {
25
+ "mode.normal": "back to NORMAL mode",
26
+ "mode.insert": "write a prompt to the focused agent (INSERT)",
27
+ "mode.command": "open the : command line",
28
+ "mode.search": "search the transcript",
29
+ "mode.search_back": "search the transcript backward",
30
+ "mode.visual": "select transcript lines (VISUAL)",
31
+ "cursor.down": "move the VISUAL cursor down",
32
+ "cursor.up": "move the VISUAL cursor up",
33
+ "cursor.half_down": "move the VISUAL cursor down half a page",
34
+ "cursor.half_up": "move the VISUAL cursor up half a page",
35
+ "cursor.top": "move the VISUAL cursor to the top",
36
+ "cursor.bottom": "move the VISUAL cursor to the bottom",
37
+ "visual.yank": "copy the selection to the clipboard",
38
+ "visual.inner_block": "select the code block under the cursor",
39
+ "visual.swap": "jump to the other end of the selection",
40
+ "message.next": "jump to the next message",
41
+ "message.prev": "jump to the previous message",
42
+ "yank.reply": "copy the agent's last reply",
43
+ "yank.block": "copy the nearest code block",
44
+ "scroll.down": "scroll down one line",
45
+ "scroll.up": "scroll up one line",
46
+ "scroll.half_down": "scroll down half a page",
47
+ "scroll.half_up": "scroll up half a page",
48
+ "scroll.page_down": "scroll down a page",
49
+ "scroll.page_up": "scroll up a page",
50
+ "scroll.top": "jump to the top of the transcript",
51
+ "scroll.bottom": "jump to the bottom of the transcript",
52
+ "session.next": "next session",
53
+ "session.prev": "previous session",
54
+ "session.alternate": "last focused session",
55
+ "session.goto": "session N (session.goto 3)",
56
+ "session.new": "spawn a new agent",
57
+ "session.kill": "kill the focused agent (asks first)",
58
+ "session.abort": "stop the focused agent's current run",
59
+ "session.pause": "pause the agent and hold its queued prompts",
60
+ "session.resume": "resume a paused agent",
61
+ "session.tree": "the conversation tree: go back to an earlier point",
62
+ "fold.toggle": "fold / unfold tool output",
63
+ "fold.open_all": "unfold all tool output",
64
+ "fold.close_all": "fold all tool output",
65
+ "search.next": "next search match",
66
+ "search.prev": "previous search match",
67
+ "search.clear": "clear search highlighting",
68
+ "edit.submit": "send the prompt / run the command line",
69
+ "edit.backspace": "delete the character before the cursor",
70
+ "edit.delete_word": "delete the word before the cursor",
71
+ "edit.clear": "clear the prompt / command line",
72
+ "theme.picker": "pick a colorscheme",
73
+ "app.help": "show all keys",
74
+ "app.dashboard": "all agents at a glance",
75
+ "app.redraw": "redraw the screen",
76
+ "app.quit": "quit vigent (asks if agents are running)",
77
+ "app.quit_force": "quit even if agents are running",
78
+ "app.quit_hint": "explain how to quit",
79
+ noop: "do nothing",
80
+ };
81
+ /** Built-in keymap, in the same notation users write. */
82
+ export const DEFAULT_KEYS = {
83
+ normal: {
84
+ i: "mode.insert",
85
+ a: "mode.insert",
86
+ o: "mode.insert",
87
+ ":": "mode.command",
88
+ "/": "mode.search",
89
+ "?": "mode.search_back",
90
+ v: "mode.visual",
91
+ V: "mode.visual",
92
+ j: "scroll.down",
93
+ "<Down>": "scroll.down",
94
+ k: "scroll.up",
95
+ "<Up>": "scroll.up",
96
+ "<C-d>": "scroll.half_down",
97
+ "<C-u>": "scroll.half_up",
98
+ "<C-f>": "scroll.page_down",
99
+ "<PageDown>": "scroll.page_down",
100
+ "<C-b>": "scroll.page_up",
101
+ "<PageUp>": "scroll.page_up",
102
+ gg: "scroll.top",
103
+ G: "scroll.bottom",
104
+ "]s": "session.next",
105
+ "[s": "session.prev",
106
+ "<C-^>": "session.alternate",
107
+ ...Object.fromEntries(Array.from({ length: 9 }, (_, i) => [String(i + 1), `session.goto ${i + 1}`])),
108
+ za: "fold.toggle",
109
+ zR: "fold.open_all",
110
+ zM: "fold.close_all",
111
+ n: "search.next",
112
+ N: "search.prev",
113
+ "{": "message.prev",
114
+ "}": "message.next",
115
+ yy: "yank.reply",
116
+ yib: "yank.block",
117
+ dd: "session.kill",
118
+ "<Esc>": "search.clear",
119
+ "<C-c>": "session.abort",
120
+ "<C-l>": "app.redraw",
121
+ "<leader>n": ":new ",
122
+ "<leader>d": ":diff<CR>",
123
+ "<leader>m": ":merge<CR>",
124
+ "<leader>p": ":pr<CR>",
125
+ "<leader>q": ":queue ",
126
+ "<leader>t": "theme.picker",
127
+ "<leader>D": ":dash<CR>",
128
+ "<leader>u": "session.tree",
129
+ "<leader>s": ":settings<CR>",
130
+ "<leader>M": ":model<CR>",
131
+ "<leader>e": ":effort<CR>",
132
+ ZZ: "app.quit",
133
+ ZQ: "app.quit_force",
134
+ q: "app.quit_hint",
135
+ },
136
+ visual: {
137
+ j: "cursor.down",
138
+ "<Down>": "cursor.down",
139
+ k: "cursor.up",
140
+ "<Up>": "cursor.up",
141
+ "<C-d>": "cursor.half_down",
142
+ "<C-u>": "cursor.half_up",
143
+ gg: "cursor.top",
144
+ G: "cursor.bottom",
145
+ y: "visual.yank",
146
+ ib: "visual.inner_block",
147
+ o: "visual.swap",
148
+ "<Esc>": "mode.normal",
149
+ "<C-c>": "mode.normal",
150
+ v: "mode.normal",
151
+ V: "mode.normal",
152
+ },
153
+ insert: {
154
+ "<Esc>": "mode.normal",
155
+ "<C-c>": "mode.normal",
156
+ "<CR>": "edit.submit",
157
+ "<BS>": "edit.backspace",
158
+ "<C-h>": "edit.backspace",
159
+ "<C-w>": "edit.delete_word",
160
+ "<C-u>": "edit.clear",
161
+ },
162
+ command: {
163
+ "<Esc>": "mode.normal",
164
+ "<C-c>": "mode.normal",
165
+ "<CR>": "edit.submit",
166
+ "<BS>": "edit.backspace",
167
+ "<C-h>": "edit.backspace",
168
+ "<C-w>": "edit.delete_word",
169
+ "<C-u>": "edit.clear",
170
+ },
171
+ };
172
+ // ---------------------------------------------------------------------------
173
+ // Notation
174
+ // ---------------------------------------------------------------------------
175
+ const NAMED = {
176
+ space: "Space",
177
+ tab: "Tab",
178
+ cr: "CR",
179
+ enter: "CR",
180
+ return: "CR",
181
+ esc: "Esc",
182
+ escape: "Esc",
183
+ bs: "BS",
184
+ backspace: "BS",
185
+ del: "Del",
186
+ delete: "Del",
187
+ up: "Up",
188
+ down: "Down",
189
+ left: "Left",
190
+ right: "Right",
191
+ home: "Home",
192
+ end: "End",
193
+ pageup: "PageUp",
194
+ pagedown: "PageDown",
195
+ insert: "Insert",
196
+ ...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`f${i + 1}`, `F${i + 1}`])),
197
+ };
198
+ /** Build a canonical token from modifiers + a base key ("Space", "x", "F5"). */
199
+ function token(mods, base) {
200
+ let key = base;
201
+ if (key.length === 1 && mods.has("S") && !mods.has("C") && !mods.has("M") && !mods.has("D")) {
202
+ mods.delete("S");
203
+ key = key.toUpperCase();
204
+ }
205
+ if (key.length === 1 && mods.has("M") && !mods.has("C") && key !== key.toLowerCase()) {
206
+ // Alt keeps case apart, as <M-S-g>, so Esc-then-G stays G
207
+ mods.add("S");
208
+ key = key.toLowerCase();
209
+ }
210
+ if (key.length === 1 && (mods.has("C") || mods.has("M")))
211
+ key = key.toLowerCase();
212
+ if (mods.size === 0) {
213
+ if (key === "<")
214
+ return "<lt>";
215
+ return key.length === 1 ? key : `<${key}>`;
216
+ }
217
+ const order = ["C", "M", "S", "D"].filter((m) => mods.has(m));
218
+ return `<${order.map((m) => `${m}-`).join("")}${key === "<" ? "lt" : key}>`;
219
+ }
220
+ /** Parse the inside of <...>. Returns undefined if it isn't a key name (then `<` is literal). */
221
+ function parseBracket(inner) {
222
+ const lower = inner.toLowerCase();
223
+ if (lower === "lt")
224
+ return "<lt>";
225
+ if (lower === "bar")
226
+ return "|";
227
+ if (lower === "bslash")
228
+ return "\\";
229
+ const m = /^((?:[cmasd]-)+)(.+)$/i.exec(inner);
230
+ const mods = new Set();
231
+ let base = inner;
232
+ if (m) {
233
+ for (const c of m[1].toUpperCase().replace(/-/g, ""))
234
+ mods.add(c === "A" ? "M" : c);
235
+ base = m[2];
236
+ }
237
+ const named = NAMED[base.toLowerCase()];
238
+ if (named)
239
+ return token(mods, named);
240
+ if (base.toLowerCase() === "lt")
241
+ return token(mods, "<");
242
+ if (mods.size > 0 && [...base].length === 1)
243
+ return token(mods, base);
244
+ return undefined;
245
+ }
246
+ /**
247
+ * Parse vim key notation into canonical tokens.
248
+ * `<leader>` expands to the given leader. Throws on an empty sequence.
249
+ */
250
+ export function parseKeys(notation, leader = DEFAULT_LEADER) {
251
+ const out = [];
252
+ const chars = [...notation];
253
+ for (let i = 0; i < chars.length; i++) {
254
+ const ch = chars[i];
255
+ if (ch === "<") {
256
+ const close = chars.indexOf(">", i + 1);
257
+ if (close > i + 1) {
258
+ const inner = chars.slice(i + 1, close).join("");
259
+ if (inner.toLowerCase() === "leader") {
260
+ out.push(...parseKeys(leader, "\\"));
261
+ i = close;
262
+ continue;
263
+ }
264
+ const t = parseBracket(inner);
265
+ if (t) {
266
+ out.push(t);
267
+ i = close;
268
+ continue;
269
+ }
270
+ }
271
+ out.push("<lt>");
272
+ continue;
273
+ }
274
+ out.push(ch === " " ? "<Space>" : ch);
275
+ }
276
+ if (out.length === 0)
277
+ throw new Error("empty key sequence");
278
+ return out;
279
+ }
280
+ /** Display a token sequence the way vim prints mappings. */
281
+ export function formatKeys(keys) {
282
+ return keys.join("");
283
+ }
284
+ const PI_NAMED = {
285
+ space: "Space",
286
+ tab: "Tab",
287
+ enter: "CR",
288
+ return: "CR",
289
+ escape: "Esc",
290
+ esc: "Esc",
291
+ backspace: "BS",
292
+ delete: "Del",
293
+ insert: "Insert",
294
+ up: "Up",
295
+ down: "Down",
296
+ left: "Left",
297
+ right: "Right",
298
+ home: "Home",
299
+ end: "End",
300
+ pageUp: "PageUp",
301
+ pageDown: "PageDown",
302
+ ...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`f${i + 1}`, `F${i + 1}`])),
303
+ };
304
+ /** Convert raw terminal input to a canonical token, or undefined if it isn't a key. */
305
+ export function keyFromInput(data) {
306
+ if (data === "\x1e")
307
+ return "<C-^>";
308
+ const id = parseKey(data);
309
+ if (id === undefined) {
310
+ const chars = [...data];
311
+ if (chars.length === 1 && data >= " " && data !== "\x7f")
312
+ return data === "<" ? "<lt>" : data;
313
+ // Esc + any printable key (pi-tui only names Esc + lowercase letters): Alt + that key
314
+ if (chars.length === 2 && chars[0] === "\x1b" && chars[1] >= " " && chars[1] !== "\x7f") {
315
+ return token(new Set(["M"]), chars[1]);
316
+ }
317
+ return undefined;
318
+ }
319
+ const parts = id.split("+");
320
+ const base = parts.pop();
321
+ const mods = new Set(parts.map((p) => ({ ctrl: "C", alt: "M", shift: "S", super: "D" })[p] ?? "").filter((m) => m !== ""));
322
+ const named = PI_NAMED[base];
323
+ if (named) {
324
+ if (named === "Space" && mods.size === 0)
325
+ return "<Space>";
326
+ return token(mods, named);
327
+ }
328
+ return token(mods, base);
329
+ }
330
+ /** The text a token types in INSERT / command-line mode, if any. */
331
+ export function tokenText(t) {
332
+ if (t === "<Space>")
333
+ return " ";
334
+ if (t === "<lt>")
335
+ return "<";
336
+ if ([...t].length === 1)
337
+ return t;
338
+ return undefined;
339
+ }
340
+ // ---------------------------------------------------------------------------
341
+ // Right-hand sides
342
+ // ---------------------------------------------------------------------------
343
+ /** Parse a mapping's right-hand side. Throws with a readable message on errors. */
344
+ export function parseRhs(value, leader = DEFAULT_LEADER) {
345
+ if (value === false || value === "" || (typeof value === "string" && /^<nop>$/i.test(value.trim()))) {
346
+ return [{ kind: "nop" }];
347
+ }
348
+ if (Array.isArray(value)) {
349
+ if (value.length === 0)
350
+ return [{ kind: "nop" }];
351
+ return value.flatMap((v) => parseRhs(v, leader));
352
+ }
353
+ if (typeof value !== "string")
354
+ throw new Error("should be an action, an ex command, keys, or false");
355
+ const v = value.trim();
356
+ // ":cmd<CR>" runs it; ":cmd " (no <CR>) opens the command line with that text, trailing space kept
357
+ const ex = /^(?::|<cmd>)([\s\S]*?)(<cr>|<enter>|<return>)?$/i.exec(value.trimStart());
358
+ if (ex && /^(:|<cmd>)/i.test(v)) {
359
+ const execute = Boolean(ex[2]) || /^<cmd>/i.test(v);
360
+ return [{ kind: "ex", cmd: execute ? ex[1].trim() : ex[1].trimStart(), execute }];
361
+ }
362
+ const action = /^([a-z_]+(?:\.[a-z_]+)?)(?:\s+(\S+))?$/.exec(v);
363
+ if (action && (action[1].includes(".") || action[1] === "noop")) {
364
+ if (!(action[1] in ACTIONS))
365
+ throw new Error(`unknown action '${action[1]}'${didYouMean(action[1], Object.keys(ACTIONS))}`);
366
+ return [{ kind: "action", name: action[1], arg: action[2] }];
367
+ }
368
+ return [{ kind: "keys", keys: parseKeys(v, leader) }];
369
+ }
370
+ function rhsToText(value) {
371
+ if (value === false)
372
+ return "<Nop>";
373
+ if (Array.isArray(value))
374
+ return value.map(rhsToText).join(", ");
375
+ return String(value);
376
+ }
377
+ export class Keymap {
378
+ leader;
379
+ maps = perMode(() => new Map());
380
+ defaults = perMode(() => new Map());
381
+ prefixes = perMode(() => new Set());
382
+ defaultPrefixes = perMode(() => new Set());
383
+ constructor(leader = DEFAULT_LEADER) {
384
+ this.leader = leader;
385
+ for (const mode of KEY_MODES) {
386
+ for (const [lhs, rhs] of Object.entries(DEFAULT_KEYS[mode])) {
387
+ const b = { keys: parseKeys(lhs, leader), rhs: parseRhs(rhs, leader), rhsText: rhs, user: false };
388
+ this.defaults[mode].set(b.keys.join(""), b);
389
+ this.maps[mode].set(b.keys.join(""), b);
390
+ }
391
+ this.defaultPrefixes[mode] = computePrefixes(this.defaults[mode]);
392
+ this.prefixes[mode] = computePrefixes(this.maps[mode]);
393
+ }
394
+ }
395
+ /** Add or replace a mapping. Throws on bad notation or an unknown action. */
396
+ set(mode, lhs, rhs) {
397
+ const keys = parseKeys(lhs, this.leader);
398
+ const b = { keys, rhs: parseRhs(rhs, this.leader), rhsText: rhsToText(rhs), user: true };
399
+ this.maps[mode].set(keys.join(""), b);
400
+ this.prefixes[mode] = computePrefixes(this.maps[mode]);
401
+ return b;
402
+ }
403
+ /** Remove a mapping (built-in or user). Returns false if nothing was mapped. */
404
+ unset(mode, lhs) {
405
+ const removed = this.maps[mode].delete(parseKeys(lhs, this.leader).join(""));
406
+ this.prefixes[mode] = computePrefixes(this.maps[mode]);
407
+ return removed;
408
+ }
409
+ lookup(mode, keys, builtinOnly = false) {
410
+ const map = builtinOnly ? this.defaults[mode] : this.maps[mode];
411
+ const prefixes = builtinOnly ? this.defaultPrefixes[mode] : this.prefixes[mode];
412
+ const k = keys.join("");
413
+ return { exact: map.get(k), prefix: prefixes.has(k) };
414
+ }
415
+ /** All active bindings for a mode, in a stable order. */
416
+ bindings(mode) {
417
+ return [...this.maps[mode].values()];
418
+ }
419
+ }
420
+ function perMode(make) {
421
+ return Object.fromEntries(KEY_MODES.map((m) => [m, make()]));
422
+ }
423
+ function computePrefixes(map) {
424
+ const out = new Set();
425
+ for (const b of map.values()) {
426
+ for (let i = 1; i < b.keys.length; i++)
427
+ out.add(b.keys.slice(0, i).join(""));
428
+ }
429
+ return out;
430
+ }
431
+ /** Build a keymap from config: `leader` plus `[keys.normal]`, `[keys.insert]`, `[keys.command]`. */
432
+ export function buildKeymap(leader, keys) {
433
+ const warnings = [];
434
+ let leaderNotation = DEFAULT_LEADER;
435
+ if (leader !== undefined) {
436
+ try {
437
+ parseKeys(leader);
438
+ leaderNotation = leader;
439
+ }
440
+ catch {
441
+ warnings.push(`config: leader '${leader}' is not a key`);
442
+ }
443
+ }
444
+ const keymap = new Keymap(leaderNotation);
445
+ for (const mode of KEY_MODES) {
446
+ for (const [lhs, rhs] of Object.entries(keys[mode] ?? {})) {
447
+ try {
448
+ keymap.set(mode, lhs, rhs);
449
+ }
450
+ catch (err) {
451
+ warnings.push(`config [keys.${mode}] "${lhs}": ${err.message}`);
452
+ }
453
+ }
454
+ }
455
+ return { keymap, warnings };
456
+ }
457
+ export class KeySequencer {
458
+ keymap;
459
+ host;
460
+ pending = [];
461
+ timer;
462
+ hintTimer;
463
+ /** a which-key hint is showing: wait for the next key, however long */
464
+ holding = false;
465
+ constructor(keymap, host) {
466
+ this.keymap = keymap;
467
+ this.host = host;
468
+ }
469
+ get pendingKeys() {
470
+ return this.pending;
471
+ }
472
+ feed(key) {
473
+ this.cancelTimer();
474
+ // Esc while waiting on a prefix: drop it, like which-key
475
+ if (key === "<Esc>" && this.pending.length > 0 && !this.keymap.lookup(this.host.mode(), this.pending).exact) {
476
+ this.pending = [];
477
+ this.host.changed();
478
+ return;
479
+ }
480
+ this.pending.push(key);
481
+ const r = this.keymap.lookup(this.host.mode(), this.pending);
482
+ if (r.prefix) {
483
+ this.timer = setTimeout(() => this.timeout(), this.host.timeoutlen());
484
+ if (!r.exact && this.host.hint) {
485
+ const delay = Math.min(this.host.hintDelay?.() ?? 300, Math.max(0, this.host.timeoutlen() - 50));
486
+ this.hintTimer = setTimeout(() => {
487
+ this.hintTimer = undefined;
488
+ if (this.pending.length > 0 && this.host.hint?.(this.pending)) {
489
+ this.holding = true;
490
+ if (this.timer)
491
+ clearTimeout(this.timer);
492
+ this.timer = undefined;
493
+ }
494
+ }, delay);
495
+ }
496
+ this.host.changed();
497
+ return;
498
+ }
499
+ const keys = this.pending;
500
+ this.pending = [];
501
+ if (r.exact) {
502
+ this.host.run(r.exact, keys);
503
+ }
504
+ else {
505
+ this.resolveUnmatched(keys);
506
+ }
507
+ this.host.changed();
508
+ }
509
+ /** Like vim: when the wait runs out, use the shorter mapping (or the keys themselves). */
510
+ timeout() {
511
+ this.timer = undefined;
512
+ const keys = this.pending;
513
+ this.pending = [];
514
+ const r = this.keymap.lookup(this.host.mode(), keys);
515
+ if (r.exact)
516
+ this.host.run(r.exact, keys);
517
+ else
518
+ this.resolveUnmatched(keys);
519
+ this.host.changed();
520
+ }
521
+ /**
522
+ * The keys matched nothing. The first key(s) may still be a shorter mapping
523
+ * (`g` mapped, then `gx` typed); run the longest one that matches and feed the rest.
524
+ */
525
+ resolveUnmatched(keys) {
526
+ if (keys.length === 1) {
527
+ this.host.run(undefined, keys);
528
+ return;
529
+ }
530
+ for (let n = keys.length - 1; n >= 1; n--) {
531
+ const r = this.keymap.lookup(this.host.mode(), keys.slice(0, n));
532
+ if (r.exact) {
533
+ this.host.run(r.exact, keys.slice(0, n));
534
+ for (const k of keys.slice(n))
535
+ this.feed(k);
536
+ return;
537
+ }
538
+ }
539
+ this.host.run(undefined, [keys[0]]);
540
+ for (const k of keys.slice(1))
541
+ this.feed(k);
542
+ }
543
+ /** Drop pending keys (e.g. when a picker opens). */
544
+ reset() {
545
+ this.cancelTimer();
546
+ this.pending = [];
547
+ }
548
+ cancelTimer() {
549
+ if (this.timer)
550
+ clearTimeout(this.timer);
551
+ this.timer = undefined;
552
+ if (this.hintTimer)
553
+ clearTimeout(this.hintTimer);
554
+ this.hintTimer = undefined;
555
+ this.holding = false;
556
+ }
557
+ /** a which-key hint is up, waiting for the next key */
558
+ get waiting() {
559
+ return this.holding && this.pending.length > 0;
560
+ }
561
+ }
562
+ /**
563
+ * Replay keys through the built-in keymap only (non-recursive, like noremap).
564
+ * Used for mappings whose right-hand side is other keys.
565
+ */
566
+ export function replayKeys(keymap, host, keys) {
567
+ let i = 0;
568
+ while (i < keys.length) {
569
+ const mode = host.mode();
570
+ let matched = 0;
571
+ let binding;
572
+ for (let n = keys.length - i; n >= 1; n--) {
573
+ const r = keymap.lookup(mode, keys.slice(i, i + n), true);
574
+ if (r.exact) {
575
+ matched = n;
576
+ binding = r.exact;
577
+ break;
578
+ }
579
+ }
580
+ if (binding) {
581
+ host.run(binding, keys.slice(i, i + matched));
582
+ i += matched;
583
+ }
584
+ else {
585
+ host.run(undefined, [keys[i]]);
586
+ i += 1;
587
+ }
588
+ }
589
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Just enough markdown for agent replies, in vim's highlight groups:
3
+ * headings (Title), **bold**, `code` and fenced code blocks (VigentCode),
4
+ * bullet lists, and > quotes (Comment). Code blocks are numbered so
5
+ * VISUAL-mode `ib` and `yib` can select them.
6
+ */
7
+ import { wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
+ import { RESET } from "./theme.js";
9
+ function inline(P, text) {
10
+ let out = "";
11
+ const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)/g;
12
+ let last = 0;
13
+ let m;
14
+ while ((m = re.exec(text))) {
15
+ out += text.slice(last, m.index);
16
+ const tok = m[0];
17
+ if (tok.startsWith("`"))
18
+ out += P.p("VigentCode", tok.slice(1, -1));
19
+ else
20
+ out += P.s("Normal") + "\x1b[1m" + tok.slice(2, -2) + RESET;
21
+ last = m.index + tok.length;
22
+ }
23
+ return out + text.slice(last);
24
+ }
25
+ function strip(s) {
26
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
27
+ }
28
+ /** Render markdown into wrapped, styled lines. `bullet` is the list marker glyph. */
29
+ export function renderMarkdown(text, width, P, bullet = "•", startBlock = 0) {
30
+ const lines = [];
31
+ let block = startBlock;
32
+ let inFence = false;
33
+ const push = (styled) => {
34
+ for (const l of wrapTextWithAnsi(styled, Math.max(10, width)))
35
+ lines.push({ text: l, plain: strip(l) });
36
+ };
37
+ for (const raw of text.split("\n")) {
38
+ const fence = /^\s*(```|~~~)\s*([\w+-]*)\s*$/.exec(raw);
39
+ if (fence) {
40
+ if (!inFence) {
41
+ inFence = true;
42
+ lines.push({ text: P.p("NonText", ` ${fence[2] || "code"}`), plain: ` ${fence[2] || "code"}`, block, label: true });
43
+ }
44
+ else {
45
+ inFence = false;
46
+ block++;
47
+ }
48
+ continue;
49
+ }
50
+ if (inFence) {
51
+ // code is not wrapped on words; long lines are cut by the pane
52
+ lines.push({ text: ` ${P.p("VigentCode", raw)}`, plain: ` ${raw}`, block });
53
+ continue;
54
+ }
55
+ const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
56
+ if (heading) {
57
+ push(P.p("Title", heading[2]));
58
+ continue;
59
+ }
60
+ const item = /^(\s*)[-*+]\s+(.*)$/.exec(raw);
61
+ if (item) {
62
+ const indent = item[1].length > 0 ? " " : "";
63
+ const body = wrapTextWithAnsi(inline(P, item[2]), Math.max(10, width - 2 - indent.length));
64
+ body.forEach((l, i) => {
65
+ const prefix = i === 0 ? `${indent}${P.p("Special", bullet)} ` : `${indent} `;
66
+ lines.push({ text: prefix + l, plain: strip(prefix + l) });
67
+ });
68
+ continue;
69
+ }
70
+ const quote = /^>\s?(.*)$/.exec(raw);
71
+ if (quote) {
72
+ push(P.p("Comment", `▏${quote[1]}`));
73
+ continue;
74
+ }
75
+ push(inline(P, raw));
76
+ }
77
+ if (inFence)
78
+ block++;
79
+ return { lines, blocks: block - startBlock };
80
+ }