@vigent/cli 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The `:review` window: the agent's changes hunk by hunk.
3
+ *
4
+ * j/k ]c/[c next / previous hunk J/K next / previous file
5
+ * x drop the hunk (take it out of the worktree)
6
+ * X drop the whole file u undo the last drop
7
+ * q / Esc close
8
+ */
9
+ import { truncateToWidth, visibleWidth } from "./tui.js";
10
+ import { patchFor } from "./hunks.js";
11
+ import { keyFromInput } from "./keys.js";
12
+ import { RESET } from "./theme.js";
13
+ export class HunkReview {
14
+ painter;
15
+ icons;
16
+ title;
17
+ files;
18
+ height;
19
+ host;
20
+ flat = [];
21
+ index = 0;
22
+ top = 0;
23
+ undo = [];
24
+ dropped = 0;
25
+ message = "";
26
+ busy = false;
27
+ constructor(painter, icons, title, files, height, host) {
28
+ this.painter = painter;
29
+ this.icons = icons;
30
+ this.title = title;
31
+ this.files = files;
32
+ this.height = height;
33
+ this.host = host;
34
+ this.flatten();
35
+ }
36
+ flatten() {
37
+ this.flat = this.files.flatMap((file, fileIdx) => file.hunks.map((hunk) => ({ file, hunk, fileIdx })));
38
+ this.index = Math.min(this.index, Math.max(0, this.flat.length - 1));
39
+ }
40
+ rows(width) {
41
+ const out = [];
42
+ this.files.forEach((file, fi) => {
43
+ const n = file.hunks.length;
44
+ out.push({ text: `${file.path} ${file.binary ? "(binary: X drops it)" : `${n} hunk${n === 1 ? "" : "s"}`}`, group: "Title", hunk: -1, header: true });
45
+ for (const hunk of file.hunks) {
46
+ const idx = this.flat.findIndex((h) => h.hunk === hunk && h.fileIdx === fi);
47
+ out.push({ text: hunk.header, group: "Special", hunk: idx, header: true });
48
+ for (const l of hunk.lines) {
49
+ out.push({
50
+ text: truncateToWidth(l.replace(/\t/g, " "), width, "…"),
51
+ group: l.startsWith("+") ? "Added" : l.startsWith("-") ? "Removed" : l.startsWith("\\") ? "Comment" : "NormalFloat",
52
+ hunk: idx,
53
+ });
54
+ }
55
+ }
56
+ });
57
+ return out;
58
+ }
59
+ handleInput(data) {
60
+ const key = keyFromInput(data);
61
+ if (this.busy)
62
+ return;
63
+ switch (key) {
64
+ case "q":
65
+ case "<Esc>":
66
+ case "<C-c>":
67
+ this.host.close();
68
+ return;
69
+ case "j":
70
+ case "n":
71
+ case "<Down>":
72
+ case "<Tab>":
73
+ this.move(1);
74
+ break;
75
+ case "k":
76
+ case "N":
77
+ case "<Up>":
78
+ case "<S-Tab>":
79
+ this.move(-1);
80
+ break;
81
+ case "]":
82
+ case "[":
83
+ this.pendingBracket = key;
84
+ return;
85
+ case "c":
86
+ if (this.pendingBracket)
87
+ this.move(this.pendingBracket === "]" ? 1 : -1);
88
+ break;
89
+ case "J":
90
+ this.moveFile(1);
91
+ break;
92
+ case "K":
93
+ this.moveFile(-1);
94
+ break;
95
+ case "g":
96
+ this.index = 0;
97
+ break;
98
+ case "G":
99
+ this.index = Math.max(0, this.flat.length - 1);
100
+ break;
101
+ case "x":
102
+ this.dropHunk();
103
+ return;
104
+ case "X":
105
+ this.dropFile();
106
+ return;
107
+ case "u":
108
+ this.undoLast();
109
+ return;
110
+ }
111
+ this.pendingBracket = undefined;
112
+ this.host.requestRender();
113
+ }
114
+ pendingBracket;
115
+ move(d) {
116
+ if (this.flat.length === 0)
117
+ return;
118
+ this.index = Math.max(0, Math.min(this.flat.length - 1, this.index + d));
119
+ }
120
+ moveFile(d) {
121
+ const cur = this.flat[this.index]?.fileIdx ?? 0;
122
+ const target = d > 0 ? this.flat.findIndex((h) => h.fileIdx > cur) : this.flat.findIndex((h) => h.fileIdx === Math.max(0, cur - 1));
123
+ if (target >= 0)
124
+ this.index = target;
125
+ }
126
+ run(patch, label, action) {
127
+ this.busy = true;
128
+ this.message = action === "drop" ? "dropping…" : "restoring…";
129
+ this.host.requestRender();
130
+ (action === "drop" ? this.host.drop(patch) : this.host.restore(patch))
131
+ .then((files) => {
132
+ if (action === "drop") {
133
+ this.undo.push({ patch, label });
134
+ this.dropped++;
135
+ this.message = `dropped ${label} · u undoes`;
136
+ }
137
+ else {
138
+ this.dropped--;
139
+ this.message = `restored ${label}`;
140
+ }
141
+ this.files = files;
142
+ this.flatten();
143
+ })
144
+ .catch((err) => {
145
+ this.message = `git couldn't ${action === "drop" ? "drop" : "restore"} ${label}: ${err.message}`;
146
+ })
147
+ .finally(() => {
148
+ this.busy = false;
149
+ this.host.requestRender();
150
+ });
151
+ }
152
+ dropHunk() {
153
+ const cur = this.flat[this.index];
154
+ if (!cur)
155
+ return;
156
+ const n = cur.file.hunks.indexOf(cur.hunk) + 1;
157
+ this.run(patchFor(cur.file, [cur.hunk]), `hunk ${n} of ${cur.file.path}`, "drop");
158
+ }
159
+ dropFile() {
160
+ const cur = this.flat[this.index];
161
+ const file = cur?.file ?? this.files[0];
162
+ if (!file)
163
+ return;
164
+ if (file.binary && file.hunks.length === 0) {
165
+ this.message = "binary files can't be dropped here yet";
166
+ this.host.requestRender();
167
+ return;
168
+ }
169
+ this.run(patchFor(file), file.path, "drop");
170
+ }
171
+ undoLast() {
172
+ const last = this.undo.pop();
173
+ if (!last) {
174
+ this.message = "nothing to undo";
175
+ this.host.requestRender();
176
+ return;
177
+ }
178
+ this.run(last.patch, last.label, "restore");
179
+ }
180
+ invalidate() { }
181
+ render(width) {
182
+ const P = this.painter;
183
+ const I = this.icons;
184
+ const inner = Math.max(30, width) - 2;
185
+ const bg = P.attr("NormalFloat").bg;
186
+ const sel = P.attr("PmenuSel").bg ?? P.attr("Visual").bg;
187
+ const border = (s) => P.on("FloatBorder", bg) + s + RESET;
188
+ const row = (content, used, rowBg = bg) => border(I.v) + content + P.on("NormalFloat", rowBg) + " ".repeat(Math.max(0, inner - used)) + RESET + border(I.v);
189
+ const total = this.flat.length;
190
+ const title = ` ${this.title} · ${total} hunk${total === 1 ? "" : "s"} left${this.dropped ? ` · ${this.dropped} dropped` : ""} `;
191
+ const lines = [border(I.tl + I.h) + P.on("FloatTitle", bg) + truncateToWidth(title, inner - 2, "…") + RESET + border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(truncateToWidth(title, inner - 2, "…")))) + I.tr)];
192
+ const body = Math.max(3, this.height() - 4);
193
+ const rows = this.rows(inner - 3);
194
+ if (total === 0) {
195
+ rows.length = 0;
196
+ rows.push({ text: this.dropped ? "every change is dropped: nothing would be merged (u undoes)" : "no changes", group: "Comment", hunk: -1 });
197
+ }
198
+ // keep the selected hunk's header in view, a little below the top
199
+ const at = rows.findIndex((r) => r.hunk === this.index && r.header);
200
+ if (at >= 0 && (at < this.top || at >= this.top + body - 3))
201
+ this.top = Math.max(0, at - 2);
202
+ this.top = Math.min(this.top, Math.max(0, rows.length - body));
203
+ for (const r of rows.slice(this.top, this.top + body)) {
204
+ const on = r.hunk === this.index && total > 0;
205
+ const rowBg = on && r.header ? sel : bg;
206
+ const bar = on ? P.on("Title", rowBg) + "▌ " : P.on("NormalFloat", rowBg) + " ";
207
+ const text = truncateToWidth(r.text, inner - 3, "…");
208
+ lines.push(row(bar + P.on(on && r.header ? "PmenuSel" : r.group, rowBg) + text, 2 + visibleWidth(text), rowBg));
209
+ }
210
+ while (lines.length < body + 1)
211
+ lines.push(row("", 0));
212
+ const msg = this.message || "x drop hunk · X drop file · u undo · j/k hunks · J/K files · q close · :merge takes what's left";
213
+ lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
214
+ lines.push(row(P.on(this.message ? "ModeMsg" : "Comment", bg) + ` ${truncateToWidth(msg, inner - 2, "…")}`, Math.min(inner, visibleWidth(msg) + 1)));
215
+ lines.push(border(I.bl + I.h.repeat(inner) + I.br));
216
+ return lines;
217
+ }
218
+ }
package/dist/hunks.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `:review`: keep or drop the agent's changes one hunk at a time.
3
+ *
4
+ * The diff is the agent's whole change, fork point → working tree (commits,
5
+ * uncommitted edits and new files). Dropping a hunk reverse-applies just that
6
+ * hunk to the worktree (`git apply -R`), so `:merge` / `:pr`, which commit
7
+ * what's in the worktree, no longer carry it. Undo applies it again.
8
+ */
9
+ import { execFile } from "node:child_process";
10
+ /** Split `git diff` output into files and hunks. */
11
+ export function parseDiff(text) {
12
+ const files = [];
13
+ let file;
14
+ let hunk;
15
+ for (const line of text.replace(/\n$/, "").split("\n")) {
16
+ if (line.startsWith("diff --git ")) {
17
+ const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
18
+ file = { path: m ? m[2] : line.slice(11), header: [line], hunks: [], binary: false };
19
+ files.push(file);
20
+ hunk = undefined;
21
+ continue;
22
+ }
23
+ if (!file)
24
+ continue;
25
+ if (line.startsWith("@@")) {
26
+ hunk = { header: line, lines: [] };
27
+ file.hunks.push(hunk);
28
+ continue;
29
+ }
30
+ if (hunk) {
31
+ // "… N more lines" from a truncated diff is not part of any hunk
32
+ if (/^[ +\-\\]/.test(line) || line === "")
33
+ hunk.lines.push(line);
34
+ continue;
35
+ }
36
+ if (/^Binary files /.test(line) || line === "GIT binary patch")
37
+ file.binary = true;
38
+ file.header.push(line);
39
+ }
40
+ return files;
41
+ }
42
+ /** A patch with one file's header and just these of its hunks. */
43
+ export function patchFor(file, hunks = file.hunks) {
44
+ // a new file's single hunk must create the whole file; a partial patch can't say "new file"
45
+ return `${[...file.header, ...hunks.flatMap((h) => [h.header, ...h.lines])].join("\n")}\n`;
46
+ }
47
+ function gitApply(cwd, patch, reverse) {
48
+ return new Promise((resolve, reject) => {
49
+ const child = execFile("git", ["apply", ...(reverse ? ["-R"] : []), "--whitespace=nowarn", "--recount", "-"], { cwd }, (err, _stdout, stderr) => (err ? reject(new Error(String(stderr).trim().split("\n")[0].replace(/^error: /, "") || err.message)) : resolve()));
50
+ child.stdin?.end(patch);
51
+ });
52
+ }
53
+ /** Take a change out of the agent's worktree. */
54
+ export function dropPatch(worktree, patch) {
55
+ return gitApply(worktree, patch, true);
56
+ }
57
+ /** Put a dropped change back. */
58
+ export function restorePatch(worktree, patch) {
59
+ return gitApply(worktree, patch, false);
60
+ }
package/dist/icons.js CHANGED
@@ -31,6 +31,7 @@ export const NERD = {
31
31
  list: "", // nf-fa-list
32
32
  caretRight: "", // nf-fa-caret_right
33
33
  caretDown: "", // nf-fa-caret_down
34
+ bell: "", // nf-fa-bell
34
35
  };
35
36
  /** An icon for a tool call, by the tool's name (Claude Code, Codex, OpenCode and pi names). */
36
37
  export function toolIcon(name) {
package/dist/keys.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * Like vim, a key that starts a longer mapping waits `timeoutlen` ms for the
14
14
  * rest; that is what makes `jk` = `<Esc>` in insert mode work.
15
15
  */
16
- import { parseKey } from "@mariozechner/pi-tui";
16
+ import { parseKey } from "./tui.js";
17
17
  import { didYouMean } from "./suggest.js";
18
18
  export const KEY_MODES = ["normal", "insert", "visual", "command"];
19
19
  /** vigent's default <leader>, as in most Neovim configs (vim's own default is "\\") */
@@ -127,6 +127,8 @@ export const DEFAULT_KEYS = {
127
127
  "<leader>D": ":dash<CR>",
128
128
  "<leader>u": "session.tree",
129
129
  "<leader>s": ":settings<CR>",
130
+ "<leader>r": ":review<CR>",
131
+ "<leader>T": ":templates<CR>",
130
132
  "<leader>M": ":model<CR>",
131
133
  "<leader>e": ":effort<CR>",
132
134
  "<leader>E": ":engine<CR>",
package/dist/main.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `vigent` command. Before anything else loads, it turns on Node's
4
+ * compile cache (Node 22.1 and later): the compiled code of vigent and its
5
+ * packages is kept on disk, so later starts skip compiling it again. That
6
+ * matters most for the pi engine's SDK, which is large. Set
7
+ * NODE_DISABLE_COMPILE_CACHE=1 to turn it off.
8
+ */
9
+ import module from "node:module";
10
+ try {
11
+ module.enableCompileCache?.();
12
+ }
13
+ catch {
14
+ // no cache: a read-only temp folder, say. Starting matters more.
15
+ }
16
+ await import("./cli.js");
package/dist/markdown.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * bullet lists, and > quotes (Comment). Code blocks are numbered so
5
5
  * VISUAL-mode `ib` and `yib` can select them.
6
6
  */
7
- import { wrapTextWithAnsi } from "@mariozechner/pi-tui";
7
+ import { wrapTextWithAnsi } from "./tui.js";
8
8
  import { RESET } from "./theme.js";
9
9
  function inline(P, text) {
10
10
  let out = "";
package/dist/notify.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Telling you an agent is done while you look elsewhere: the terminal bell,
3
+ * and a desktop notification in terminals that show one for OSC 9 (iTerm2,
4
+ * WezTerm, Ghostty, kitty). Inside tmux the notification is passed through
5
+ * to the outer terminal.
6
+ */
7
+ export const NOTIFY_MODES = ["auto", "bell", "desktop", "off"];
8
+ /** Does this terminal turn OSC 9 into a desktop notification? */
9
+ export function showsDesktopNotifications(env = process.env) {
10
+ const program = env.TERM_PROGRAM ?? "";
11
+ return (["iTerm.app", "WezTerm", "ghostty"].includes(program) ||
12
+ Boolean(env.KITTY_WINDOW_ID) ||
13
+ Boolean(env.GHOSTTY_RESOURCES_DIR) ||
14
+ Boolean(env.WEZTERM_PANE) ||
15
+ /kitty|ghostty/.test(env.TERM ?? ""));
16
+ }
17
+ /** The bytes to write for a notification: a bell, an OSC 9 notification, both, or nothing. */
18
+ export function alertSequence(text, mode, env = process.env) {
19
+ if (mode === "off")
20
+ return "";
21
+ const desktop = mode === "desktop" || (mode === "auto" && showsDesktopNotifications(env));
22
+ let out = mode === "desktop" ? "" : "\x07";
23
+ if (desktop) {
24
+ // no control characters inside the notification text
25
+ const osc = `\x1b]9;${text.replace(/[\x00-\x1f\x7f]/g, " ")}\x07`;
26
+ // tmux swallows OSC 9 unless it's wrapped in a passthrough
27
+ out += env.TMUX ? `\x1bPtmux;${osc.replace(/\x1b/g, "\x1b\x1b")}\x1b\\` : osc;
28
+ }
29
+ return out;
30
+ }
31
+ /** Ask the terminal to report focus changes (\x1b[I / \x1b[O), so a visible agent doesn't notify. */
32
+ export const FOCUS_REPORTING_ON = "\x1b[?1004h";
33
+ export const FOCUS_REPORTING_OFF = "\x1b[?1004l";
package/dist/pi-sdk.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The pi engine's packages, loaded only when a pi agent needs them.
3
+ *
4
+ * They're the bulk of an install (about 200 MB with every model provider's
5
+ * SDK), and optional: the light install (`npm install --prefix ~/.vigent
6
+ * --omit=optional @vigent/cli`) leaves them out for people who only run Claude
7
+ * Code, Codex or OpenCode. Then pi shows as not installed, and nothing else
8
+ * changes. (npm ignores --omit=optional with -g, hence the folder.)
9
+ */
10
+ export const PI_PACKAGE = "@mariozechner/pi-coding-agent";
11
+ export const PI_INSTALL = "install vigent again without --omit=optional to add it";
12
+ /** Whether the pi engine is installed next to vigent (found the way an import would find it). */
13
+ export function piInstalled(resolve = (s) => import.meta.resolve(s)) {
14
+ try {
15
+ resolve(PI_PACKAGE);
16
+ resolve("@mariozechner/pi-ai");
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function missing(err) {
24
+ const code = err?.code;
25
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
26
+ }
27
+ const NOT_INSTALLED = `pi isn't installed (this is vigent's light install): ${PI_INSTALL}`;
28
+ /** pi's agent SDK. */
29
+ export async function loadPiSdk() {
30
+ try {
31
+ return await import("@mariozechner/pi-coding-agent");
32
+ }
33
+ catch (err) {
34
+ throw missing(err) ? new Error(NOT_INSTALLED) : err;
35
+ }
36
+ }
37
+ /** pi's model catalog and providers. */
38
+ export async function loadPiAi() {
39
+ try {
40
+ return await import("@mariozechner/pi-ai");
41
+ }
42
+ catch (err) {
43
+ throw missing(err) ? new Error(NOT_INSTALLED) : err;
44
+ }
45
+ }
package/dist/pi-ui.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * pi extensions inside vigent.
3
+ *
4
+ * pi loads extensions (~/.pi/agent/extensions/, .pi/extensions/), skills and
5
+ * prompt templates by itself. What it needs from the program around it is a
6
+ * UI: an extension that asks before `rm -rf` calls ctx.ui.confirm(), a
7
+ * command answers with ctx.ui.notify(). This is that UI, drawn the vigent way:
8
+ * confirm is a y/n question, select and input are pickers, notify goes to the
9
+ * transcript and the message line, setStatus shows in the winbar. The parts
10
+ * that only make sense in pi's own TUI (custom editors, widgets, headers and
11
+ * footers) do nothing here, as they do in pi's print mode.
12
+ */
13
+ /**
14
+ * pi's ExtensionUIContext over a host that may come later: until vigent
15
+ * attaches one (or in tests without a screen), dialogs answer like pi's print
16
+ * mode: no selection, no confirmation, no input.
17
+ */
18
+ export function extensionUi(host, theme) {
19
+ const nothing = () => { };
20
+ return {
21
+ select: async (title, options) => host()?.select(title, options),
22
+ confirm: async (title, message) => (await host()?.confirm(title, message)) ?? false,
23
+ input: async (title, placeholder) => host()?.input(title, placeholder),
24
+ editor: async (title, prefill) => host()?.input(title, prefill),
25
+ notify: (message, type = "info") => host()?.notify(String(message), type),
26
+ setStatus: (key, text) => host()?.setStatus(key, text),
27
+ onTerminalInput: () => nothing,
28
+ setWorkingMessage: nothing,
29
+ setWorkingVisible: nothing,
30
+ setWorkingIndicator: nothing,
31
+ setHiddenThinkingLabel: nothing,
32
+ setWidget: nothing,
33
+ setFooter: nothing,
34
+ setHeader: nothing,
35
+ setTitle: nothing,
36
+ custom: async () => undefined,
37
+ pasteToEditor: nothing,
38
+ setEditorText: nothing,
39
+ getEditorText: () => "",
40
+ addAutocompleteProvider: nothing,
41
+ setEditorComponent: nothing,
42
+ getEditorComponent: () => undefined,
43
+ get theme() {
44
+ return theme();
45
+ },
46
+ getAllThemes: () => [],
47
+ getTheme: () => undefined,
48
+ setTheme: () => ({ success: false, error: "vigent draws its own theme (:colorscheme)" }),
49
+ getToolsExpanded: () => false,
50
+ setToolsExpanded: nothing,
51
+ };
52
+ }
53
+ /** Read what pi loaded from a live AgentSession. Anything missing reads as empty. */
54
+ export function piResources(session) {
55
+ const loader = session?.resourceLoader;
56
+ const safe = (fn, fallback) => {
57
+ try {
58
+ return fn() ?? fallback;
59
+ }
60
+ catch {
61
+ return fallback;
62
+ }
63
+ };
64
+ const ext = safe(() => loader.getExtensions(), { extensions: [], errors: [] });
65
+ return {
66
+ extensions: [
67
+ ...(ext.extensions ?? []).map((e) => ({ path: String(e.resolvedPath ?? e.path ?? "") })),
68
+ ...(ext.errors ?? []).map((e) => ({ path: String(e.path ?? ""), error: String(e.error ?? "failed to load") })),
69
+ ],
70
+ commands: safe(() => session.extensionRunner.getRegisteredCommands(), []).map((c) => ({
71
+ name: String(c.invocationName ?? c.name),
72
+ description: c.description,
73
+ })),
74
+ skills: safe(() => loader.getSkills().skills, []).map((s) => ({ name: String(s.name), description: s.description, path: s.filePath })),
75
+ prompts: safe(() => session.promptTemplates, []).map((p) => ({ name: String(p.name), description: p.description })),
76
+ contextFiles: safe(() => loader.getAgentsFiles().agentsFiles, []).map((f) => String(f.path)),
77
+ };
78
+ }
package/dist/picker.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Type to filter, arrows / Ctrl-n / Ctrl-p / Tab to move (each move previews
5
5
  * the theme live), Enter to keep it, Esc to go back to the previous theme.
6
6
  */
7
- import { fuzzyFilter, parseKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+ import { fuzzyFilter, parseKey, truncateToWidth, visibleWidth } from "./tui.js";
8
8
  import { RESET, sgr } from "./theme.js";
9
9
  export const PICKER_ICONS = {
10
10
  unicode: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", lt: "├", rt: "┤", current: "●", prompt: "❯" },
@@ -227,7 +227,9 @@ export class ChoicePicker {
227
227
  scroll = 0;
228
228
  items = [];
229
229
  loading = true;
230
- constructor(painter, icons, title, items, current, custom, onDone, requestRender) {
230
+ onBackOut;
231
+ rows;
232
+ constructor(painter, icons, title, items, current, custom, onDone, requestRender, opts = {}) {
231
233
  this.painter = painter;
232
234
  this.icons = icons;
233
235
  this.title = title;
@@ -235,12 +237,15 @@ export class ChoicePicker {
235
237
  this.custom = custom;
236
238
  this.onDone = onDone;
237
239
  this.requestRender = requestRender;
240
+ this.searching = opts.search ?? false;
241
+ this.rows = opts.rows ?? VISIBLE;
242
+ this.onBackOut = opts.onBackOut;
238
243
  Promise.resolve(items)
239
244
  .then((list) => {
240
245
  this.items = list;
241
246
  this.loading = false;
242
247
  this.index = Math.max(0, list.findIndex((c) => c.value === current));
243
- this.scroll = Math.max(0, this.index - VISIBLE + 1);
248
+ this.scroll = Math.max(0, this.index - this.rows + 1);
244
249
  this.requestRender();
245
250
  })
246
251
  .catch(() => {
@@ -276,6 +281,8 @@ export class ChoicePicker {
276
281
  else if (key === "backspace") {
277
282
  if (this.query)
278
283
  this.query = this.query.slice(0, -1);
284
+ else if (this.onBackOut)
285
+ return this.onBackOut();
279
286
  else
280
287
  this.searching = false;
281
288
  this.index = 0;
@@ -321,8 +328,8 @@ export class ChoicePicker {
321
328
  }
322
329
  if (this.index < this.scroll)
323
330
  this.scroll = this.index;
324
- if (this.index >= this.scroll + VISIBLE)
325
- this.scroll = this.index - VISIBLE + 1;
331
+ if (this.index >= this.scroll + this.rows)
332
+ this.scroll = this.index - this.rows + 1;
326
333
  this.requestRender();
327
334
  }
328
335
  invalidate() { }
@@ -334,7 +341,7 @@ export class ChoicePicker {
334
341
  const border = (s) => P.on("FloatBorder", bg) + s + RESET;
335
342
  const row = (content, used, rowBg = bg) => border(I.v) + content + P.on("NormalFloat", rowBg) + " ".repeat(Math.max(0, inner - used)) + RESET + border(I.v);
336
343
  const items = this.filtered();
337
- const title = ` ${this.title} `;
344
+ const title = truncateToWidth(` ${this.title} `, Math.max(0, inner - 2), "…");
338
345
  const lines = [border(I.tl + I.h) + P.on("FloatTitle", bg) + title + RESET + border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr)];
339
346
  if (this.searching) {
340
347
  lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(this.query) + 4));
@@ -345,15 +352,15 @@ export class ChoicePicker {
345
352
  lines.push(row(P.on("Comment", bg) + ` ${I.prompt} ${text}` + RESET, visibleWidth(text) + 3));
346
353
  }
347
354
  lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
348
- const shown = items.slice(this.scroll, this.scroll + VISIBLE);
349
- for (let i = 0; i < VISIBLE; i++) {
355
+ const shown = items.slice(this.scroll, this.scroll + this.rows);
356
+ for (let i = 0; i < this.rows; i++) {
350
357
  const c = shown[i];
351
358
  if (!c) {
352
359
  let text = "";
353
360
  if (i === 0 && this.loading)
354
361
  text = " loading…";
355
362
  else if (i === 0 && items.length === 0)
356
- text = this.custom && this.query.trim() ? ` Enter uses '${this.query.trim()}'` : " no match";
363
+ text = this.custom && this.query.trim() ? ` Enter uses '${this.query.trim()}'` : this.custom && this.items.length === 0 ? " type it, then Enter" : " no match";
357
364
  lines.push(row(P.on("Comment", bg) + truncateToWidth(text, inner, "…") + RESET, Math.min(inner, visibleWidth(text))));
358
365
  continue;
359
366
  }
@@ -361,9 +368,12 @@ export class ChoicePicker {
361
368
  const rowBg = selected ? (P.attr("PmenuSel").bg ?? P.attr("Visual").bg) : bg;
362
369
  const group = selected ? "PmenuSel" : "NormalFloat";
363
370
  const marker = c.value === this.current ? `${I.current} ` : " ";
364
- const name = truncateToWidth(`${marker}${c.value}`, Math.max(10, inner - 4), "…");
371
+ // notes line up in a column after the longest name (up to a third of the width)
372
+ const nameW = Math.min(Math.floor(inner / 3), Math.max(...items.map((x) => visibleWidth(x.value))));
373
+ const padded = c.note ? c.value + " ".repeat(Math.max(0, nameW - visibleWidth(c.value))) : c.value;
374
+ const name = truncateToWidth(`${marker}${padded}`, Math.max(10, inner - 4), "…");
365
375
  const note = c.note ? truncateToWidth(` ${c.note}`, Math.max(0, inner - 2 - visibleWidth(name)), "…") : "";
366
- lines.push(row(P.on(group, rowBg) + ` ${name}` + P.on("Comment", rowBg) + note + RESET, 1 + visibleWidth(name) + visibleWidth(note), rowBg));
376
+ lines.push(row(P.on(group, rowBg) + ` ${name}` + P.on(selected ? "PmenuSel" : "Comment", rowBg) + note + RESET, 1 + visibleWidth(name) + visibleWidth(note), rowBg));
367
377
  }
368
378
  const keys = this.searching ? "type to filter · Esc done" : "j/k move · Enter pick · q close";
369
379
  const count = ` ${items.length ? this.index + 1 : 0}/${items.length} · ${keys} `;