@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.
@@ -0,0 +1,460 @@
1
+ import { flattenTree } from "./tree.js";
2
+ import { diffStat } from "./worktree.js";
3
+ export const STATE_GLYPH = {
4
+ running: "●",
5
+ waiting: "◐",
6
+ idle: "○",
7
+ paused: "‖",
8
+ error: "✗",
9
+ };
10
+ let nextId = 1;
11
+ export class Session {
12
+ name;
13
+ model;
14
+ worktree;
15
+ id = nextId++;
16
+ /** what runs this agent */
17
+ engine = "pi";
18
+ /** reasoning effort / thinking level; undefined = the engine's default */
19
+ effort;
20
+ state = "idle";
21
+ items = [];
22
+ tokens = 0;
23
+ version = 0; // bumped on every change, used for render caching
24
+ listeners = new Set();
25
+ statTimer;
26
+ statRunning = false;
27
+ constructor(name, model, worktree) {
28
+ this.name = name;
29
+ this.model = model;
30
+ this.worktree = worktree;
31
+ }
32
+ /** Recount the worktree's changes (throttled; runs git in the background). */
33
+ refreshStat(delayMs = 300) {
34
+ const wt = this.worktree;
35
+ if (!wt?.path || this.statTimer)
36
+ return;
37
+ this.statTimer = setTimeout(async () => {
38
+ this.statTimer = undefined;
39
+ if (this.statRunning)
40
+ return this.refreshStat(delayMs);
41
+ this.statRunning = true;
42
+ try {
43
+ wt.stat = await diffStat(wt);
44
+ this.emit();
45
+ }
46
+ catch {
47
+ // the worktree may have been removed; keep the last numbers
48
+ }
49
+ finally {
50
+ this.statRunning = false;
51
+ }
52
+ }, delayMs);
53
+ }
54
+ /** Stop background work (stat refresh). Subclasses also stop the agent. */
55
+ dispose() {
56
+ if (this.statTimer)
57
+ clearTimeout(this.statTimer);
58
+ this.statTimer = undefined;
59
+ }
60
+ onChange(cb) {
61
+ this.listeners.add(cb);
62
+ return () => this.listeners.delete(cb);
63
+ }
64
+ emit() {
65
+ this.version++;
66
+ for (const cb of this.listeners)
67
+ cb();
68
+ }
69
+ pushUser(text) {
70
+ this.items.push({ kind: "user", text });
71
+ this.emit();
72
+ }
73
+ pushInfo(text) {
74
+ this.items.push({ kind: "info", text });
75
+ this.emit();
76
+ }
77
+ /** prompts waiting for the agent to finish its current run */
78
+ queue = [];
79
+ setState(s) {
80
+ if (this.state === "paused" && s === "idle")
81
+ return; // an aborted run settling while paused
82
+ this.state = s;
83
+ this.emit();
84
+ if ((s === "idle" || s === "waiting") && this.queue.length > 0) {
85
+ // next queued prompt, after the current event has been handled
86
+ setTimeout(() => this.drainQueue(), 0);
87
+ }
88
+ }
89
+ drainQueue() {
90
+ if (this.state === "running" || this.state === "paused" || this.queue.length === 0)
91
+ return;
92
+ const next = this.queue.shift();
93
+ this.emit();
94
+ this.send(next);
95
+ }
96
+ /**
97
+ * Send a prompt now, or queue it if the agent is busy (or paused).
98
+ * Returns how many prompts are waiting after this one, or 0 if it was sent.
99
+ */
100
+ submit(text) {
101
+ if (this.state === "running" || this.state === "paused") {
102
+ this.queue.push(text);
103
+ this.emit();
104
+ return this.queue.length;
105
+ }
106
+ this.send(text);
107
+ return 0;
108
+ }
109
+ /** Stop the current run and hold queued prompts until resume(). */
110
+ pause() {
111
+ if (this.state === "running")
112
+ this.abort();
113
+ this.state = "paused";
114
+ this.emit();
115
+ }
116
+ /** Continue: the next queued prompt, or ask the agent to pick up where it stopped. */
117
+ resume() {
118
+ if (this.state !== "paused")
119
+ return;
120
+ this.state = "idle";
121
+ this.emit();
122
+ if (this.queue.length > 0)
123
+ this.drainQueue();
124
+ else
125
+ this.send("Continue where you left off.");
126
+ }
127
+ toggleFolds() {
128
+ const tools = this.items.filter((i) => i.kind === "tool" && i.tool);
129
+ if (tools.length === 0)
130
+ return;
131
+ const anyFolded = tools.some((i) => i.tool.folded);
132
+ for (const i of tools)
133
+ i.tool.folded = !anyFolded;
134
+ this.emit();
135
+ }
136
+ /** Use this model from the next prompt on. */
137
+ setModel(model) {
138
+ if (model)
139
+ this.model = model;
140
+ this.emit();
141
+ }
142
+ /** Use this reasoning effort (thinking level) from the next prompt on. */
143
+ setEffort(effort) {
144
+ this.effort = effort && effort !== "default" ? effort : undefined;
145
+ this.emit();
146
+ }
147
+ /** The conversation tree (`:tree`), or undefined if this agent doesn't keep one. */
148
+ tree(_glyphs) {
149
+ return undefined;
150
+ }
151
+ /**
152
+ * Go back to an earlier point. Picking a prompt rewinds to just before it and
153
+ * returns its text to edit and resend; picking a reply continues from there.
154
+ */
155
+ async navigate(_id) {
156
+ throw new Error("this agent doesn't keep a conversation tree");
157
+ }
158
+ }
159
+ export class DemoSession extends Session {
160
+ timer;
161
+ script = [];
162
+ play(steps) {
163
+ this.script = [...steps];
164
+ this.next();
165
+ }
166
+ next() {
167
+ const step = this.script.shift();
168
+ if (!step)
169
+ return;
170
+ this.timer = setTimeout(() => this.run(step), step.delay);
171
+ }
172
+ run(step) {
173
+ switch (step.kind) {
174
+ case "state":
175
+ this.setState(step.state);
176
+ break;
177
+ case "info":
178
+ this.pushInfo(step.text ?? "");
179
+ break;
180
+ case "tool": {
181
+ this.items.push({
182
+ kind: "tool",
183
+ text: "",
184
+ tool: { ...step.tool, folded: true },
185
+ });
186
+ this.tokens += Math.ceil((step.tool.output.length + step.tool.summary.length) / 4);
187
+ this.emit();
188
+ break;
189
+ }
190
+ case "agent": {
191
+ const words = (step.text ?? "").split(" ");
192
+ if (!step.streamMs) {
193
+ this.items.push({ kind: "agent", text: step.text ?? "" });
194
+ this.tokens += Math.ceil((step.text ?? "").length / 4);
195
+ this.emit();
196
+ break;
197
+ }
198
+ const item = { kind: "agent", text: "", streaming: true };
199
+ this.items.push(item);
200
+ let i = 0;
201
+ const tick = () => {
202
+ if (i >= words.length) {
203
+ item.streaming = false;
204
+ this.emit();
205
+ this.next();
206
+ return;
207
+ }
208
+ item.text += (i > 0 ? " " : "") + words[i++];
209
+ this.tokens += 1;
210
+ this.emit();
211
+ this.timer = setTimeout(tick, step.streamMs);
212
+ };
213
+ tick();
214
+ return; // next() is called when streaming finishes
215
+ }
216
+ }
217
+ this.next();
218
+ }
219
+ send(text) {
220
+ this.pushUser(text);
221
+ this.setState("running");
222
+ this.play([
223
+ { delay: 500, kind: "agent", streamMs: 60, text: "Got it — I'll take a look and get back to you. (demo mode: no model behind this session)" },
224
+ { delay: 200, kind: "state", state: "idle" },
225
+ ]);
226
+ }
227
+ abort() {
228
+ if (this.timer)
229
+ clearTimeout(this.timer);
230
+ this.script = [];
231
+ this.setState("idle");
232
+ }
233
+ }
234
+ export class PiSession extends Session {
235
+ opts;
236
+ session;
237
+ ready;
238
+ cwd;
239
+ constructor(name, model, opts) {
240
+ super(name, model, opts.worktree);
241
+ this.opts = opts;
242
+ this.cwd = opts.cwd;
243
+ this.effort = opts.effort;
244
+ this.ready = this.init();
245
+ }
246
+ async init() {
247
+ const sdk = await import("@mariozechner/pi-coding-agent");
248
+ const sessionManager = this.opts.sessionDir
249
+ ? sdk.SessionManager.continueRecent(this.cwd, this.opts.sessionDir)
250
+ : sdk.SessionManager.inMemory();
251
+ const resuming = sessionManager.buildSessionContext().messages.length > 0;
252
+ let model = this.opts.modelObject;
253
+ if (!model && !resuming) {
254
+ model = await piModel(this.model); // undefined: let pi pick its default
255
+ }
256
+ // a resumed conversation keeps the model it was saved with, unless one is given
257
+ const { session } = await sdk.createAgentSession({
258
+ cwd: this.cwd,
259
+ ...(this.effort ? { thinkingLevel: this.effort } : {}),
260
+ ...(model ? { model: model } : {}),
261
+ sessionManager,
262
+ ...(this.opts.authStorage ? { authStorage: this.opts.authStorage } : {}),
263
+ ...(this.opts.agentDir ? { agentDir: this.opts.agentDir } : {}),
264
+ });
265
+ this.session = session;
266
+ if (resuming) {
267
+ const restored = itemsFromMessages(session.messages ?? []);
268
+ this.items.push(...restored.items);
269
+ this.tokens += restored.tokens;
270
+ this.pushInfo(`resumed: ${restored.items.filter((i) => i.kind === "user").length} earlier prompts`);
271
+ this.refreshStat(0);
272
+ }
273
+ session.subscribe((event) => this.onEvent(event));
274
+ }
275
+ onEvent(event) {
276
+ switch (event.type) {
277
+ case "agent_start":
278
+ this.setState("running");
279
+ break;
280
+ case "agent_end":
281
+ this.setState("idle");
282
+ this.refreshStat(0);
283
+ break;
284
+ case "message_start": {
285
+ if (event.message?.role === "assistant") {
286
+ this.items.push({ kind: "agent", text: "", streaming: true });
287
+ this.emit();
288
+ }
289
+ break;
290
+ }
291
+ case "message_update": {
292
+ const last = this.items[this.items.length - 1];
293
+ if (last?.kind === "agent" && event.message?.content) {
294
+ last.text = extractText(event.message.content);
295
+ this.emit();
296
+ }
297
+ break;
298
+ }
299
+ case "message_end": {
300
+ const last = this.items[this.items.length - 1];
301
+ if (last?.kind === "agent") {
302
+ if (event.message?.content)
303
+ last.text = extractText(event.message.content);
304
+ last.streaming = false;
305
+ const usage = event.message?.usage;
306
+ if (usage)
307
+ this.tokens += (usage.input ?? 0) + (usage.output ?? 0);
308
+ if (!last.text)
309
+ this.items.pop(); // a turn that only called tools
310
+ this.emit();
311
+ }
312
+ break;
313
+ }
314
+ case "tool_execution_start": {
315
+ this.items.push({
316
+ kind: "tool",
317
+ text: "",
318
+ tool: { name: event.toolName, summary: summarizeArgs(event.args), output: "", folded: true },
319
+ });
320
+ this.emit();
321
+ break;
322
+ }
323
+ case "tool_execution_end": {
324
+ const item = [...this.items].reverse().find((i) => i.kind === "tool" && i.tool?.name === event.toolName);
325
+ if (item?.tool) {
326
+ item.tool.output = extractText(event.result?.content ?? []) || JSON.stringify(event.result ?? "");
327
+ this.emit();
328
+ }
329
+ this.refreshStat();
330
+ break;
331
+ }
332
+ }
333
+ }
334
+ async setModel(model) {
335
+ if (!model || model === "default")
336
+ return;
337
+ const m = await piModel(model);
338
+ if (!m)
339
+ throw new Error(`pi doesn't know the model '${model}' (provider/model, e.g. anthropic/claude-sonnet-4-5)`);
340
+ await this.ready;
341
+ await this.session.setModel(m);
342
+ this.model = model;
343
+ this.emit();
344
+ }
345
+ async setEffort(effort) {
346
+ await this.ready;
347
+ const level = !effort || effort === "default" ? "off" : effort;
348
+ this.session.setThinkingLevel(level);
349
+ this.effort = level === "off" ? undefined : level;
350
+ this.emit();
351
+ }
352
+ tree(glyphs) {
353
+ const sm = this.session?.sessionManager;
354
+ if (!sm)
355
+ return undefined;
356
+ return flattenTree(sm.getTree(), sm.getLeafId(), glyphs);
357
+ }
358
+ async navigate(id) {
359
+ await this.ready;
360
+ if (this.state === "running")
361
+ throw new Error(`'${this.name}' is running; wait for it or :pause it first`);
362
+ await this.session.agent?.waitForIdle?.();
363
+ const r = await this.session.navigateTree(id);
364
+ if (r.cancelled)
365
+ throw new Error("cancelled");
366
+ // the transcript shows the branch the agent is on now
367
+ this.items = itemsFromMessages(this.session.messages ?? []).items;
368
+ this.emit();
369
+ return { editorText: r.editorText };
370
+ }
371
+ send(text) {
372
+ this.pushUser(text);
373
+ this.setState("running");
374
+ this.ready
375
+ // pi reports agent_end a moment before it accepts the next prompt (queued prompts hit this)
376
+ .then(() => this.session.agent?.waitForIdle?.())
377
+ .then(() => this.session.prompt(text))
378
+ .catch((err) => {
379
+ this.pushInfo(`error: ${err?.message ?? err}`);
380
+ this.setState("error");
381
+ });
382
+ }
383
+ abort() {
384
+ this.session?.abort?.();
385
+ this.setState("idle");
386
+ }
387
+ dispose() {
388
+ super.dispose();
389
+ this.session?.dispose?.();
390
+ }
391
+ }
392
+ /** Rebuild the transcript from pi's saved messages: prompts, replies, and tool calls with their output. */
393
+ export function itemsFromMessages(messages) {
394
+ const items = [];
395
+ const byCallId = new Map();
396
+ let tokens = 0;
397
+ for (const m of messages) {
398
+ if (m?.role === "user") {
399
+ const text = extractText(m.content);
400
+ if (text)
401
+ items.push({ kind: "user", text });
402
+ }
403
+ else if (m?.role === "assistant") {
404
+ const blocks = Array.isArray(m.content) ? m.content : [];
405
+ const text = extractText(blocks);
406
+ if (text)
407
+ items.push({ kind: "agent", text });
408
+ for (const b of blocks) {
409
+ if (b?.type !== "toolCall")
410
+ continue;
411
+ const item = {
412
+ kind: "tool",
413
+ text: "",
414
+ tool: { name: b.name, summary: summarizeArgs(b.arguments), output: "", folded: true },
415
+ };
416
+ items.push(item);
417
+ if (b.id)
418
+ byCallId.set(b.id, item);
419
+ }
420
+ if (m.usage)
421
+ tokens += (m.usage.input ?? 0) + (m.usage.output ?? 0);
422
+ }
423
+ else if (m?.role === "toolResult") {
424
+ const item = byCallId.get(m.toolCallId);
425
+ if (item?.tool)
426
+ item.tool.output = extractText(m.content);
427
+ }
428
+ }
429
+ return { items, tokens };
430
+ }
431
+ function extractText(content) {
432
+ if (typeof content === "string")
433
+ return content;
434
+ if (!Array.isArray(content))
435
+ return "";
436
+ return content
437
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
438
+ .map((b) => b.text)
439
+ .join("\n");
440
+ }
441
+ function summarizeArgs(args) {
442
+ if (!args)
443
+ return "";
444
+ if (typeof args === "string")
445
+ return args.slice(0, 80);
446
+ const v = args.command ?? args.path ?? args.file_path ?? args.filePath ?? JSON.stringify(args);
447
+ return String(v).slice(0, 80);
448
+ }
449
+ /** A pi-ai model from "provider/model", or a bare id (Anthropic's). Undefined when pi doesn't know it. */
450
+ export async function piModel(id) {
451
+ const ai = await import("@mariozechner/pi-ai");
452
+ const slash = id.indexOf("/");
453
+ const [provider, modelId] = slash > 0 ? [id.slice(0, slash), id.slice(slash + 1)] : ["anthropic", id];
454
+ try {
455
+ return ai.getModel(provider, modelId) ?? undefined;
456
+ }
457
+ catch {
458
+ return undefined;
459
+ }
460
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `:settings`: every option in one float, changed in place.
3
+ *
4
+ * j/k move · Enter or Space change · h/l step back or forward ·
5
+ * w save to config.toml · q / Esc close
6
+ *
7
+ * Changes apply at once. A key row waits for the new keys: type them, then
8
+ * Enter (Esc cancels).
9
+ */
10
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import { keyFromInput } from "./keys.js";
12
+ import { RESET } from "./theme.js";
13
+ export class SettingsFloat {
14
+ painter;
15
+ icons;
16
+ items;
17
+ height;
18
+ onClose;
19
+ onSave;
20
+ requestRender;
21
+ index = 0;
22
+ scroll = 0;
23
+ capturing;
24
+ message = "";
25
+ constructor(painter, icons, items, height, onClose, onSave, requestRender) {
26
+ this.painter = painter;
27
+ this.icons = icons;
28
+ this.items = items;
29
+ this.height = height;
30
+ this.onClose = onClose;
31
+ this.onSave = onSave;
32
+ this.requestRender = requestRender;
33
+ }
34
+ /** rows: section headers and items, in order */
35
+ rows() {
36
+ const out = [];
37
+ let section = "";
38
+ this.items.forEach((item, i) => {
39
+ if (item.section !== section) {
40
+ section = item.section;
41
+ out.push({ header: section });
42
+ }
43
+ out.push({ item, i });
44
+ });
45
+ return out;
46
+ }
47
+ handleInput(data) {
48
+ const key = keyFromInput(data);
49
+ if (this.capturing) {
50
+ if (key === "<Esc>") {
51
+ this.capturing = undefined;
52
+ this.message = "cancelled";
53
+ }
54
+ else if (key === "<CR>") {
55
+ const keys = this.capturing;
56
+ this.capturing = undefined;
57
+ if (keys.length === 0)
58
+ this.message = "no keys typed";
59
+ else
60
+ this.message = this.items[this.index].bind?.(keys) ?? "";
61
+ }
62
+ else if (key === "<BS>") {
63
+ this.capturing.pop();
64
+ }
65
+ else if (key) {
66
+ this.capturing.push(key);
67
+ }
68
+ this.requestRender();
69
+ return;
70
+ }
71
+ const item = this.items[this.index];
72
+ switch (key) {
73
+ case "q":
74
+ case "<Esc>":
75
+ case "<C-c>":
76
+ this.onClose();
77
+ return;
78
+ case "j":
79
+ case "<Down>":
80
+ case "<Tab>":
81
+ case "<C-n>":
82
+ this.move(1);
83
+ break;
84
+ case "k":
85
+ case "<Up>":
86
+ case "<S-Tab>":
87
+ case "<C-p>":
88
+ this.move(-1);
89
+ break;
90
+ case "g":
91
+ this.index = 0;
92
+ break;
93
+ case "G":
94
+ this.index = this.items.length - 1;
95
+ break;
96
+ case "l":
97
+ case "<Right>":
98
+ item.change?.(1);
99
+ this.message = "";
100
+ break;
101
+ case "h":
102
+ case "<Left>":
103
+ item.change?.(-1);
104
+ this.message = "";
105
+ break;
106
+ case "<CR>":
107
+ case "<Space>":
108
+ this.message = "";
109
+ if (item.open) {
110
+ item.open();
111
+ return;
112
+ }
113
+ if (item.bind) {
114
+ this.capturing = [];
115
+ break;
116
+ }
117
+ item.change?.(1);
118
+ break;
119
+ case "w":
120
+ this.message = this.onSave();
121
+ break;
122
+ }
123
+ this.requestRender();
124
+ }
125
+ move(delta) {
126
+ this.index = (this.index + delta + this.items.length) % this.items.length;
127
+ }
128
+ invalidate() { }
129
+ render(width) {
130
+ const P = this.painter;
131
+ const I = this.icons;
132
+ const inner = Math.max(30, width) - 2;
133
+ const bg = P.attr("NormalFloat").bg;
134
+ const border = (s) => P.on("FloatBorder", bg) + s + RESET;
135
+ const row = (content, used, rowBg = bg) => border(I.v) + content + P.on("NormalFloat", rowBg) + " ".repeat(Math.max(0, inner - used)) + RESET + border(I.v);
136
+ const title = " Settings ";
137
+ 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)];
138
+ const rows = this.rows();
139
+ const selectedRow = rows.findIndex((r) => "item" in r && r.i === this.index);
140
+ const room = Math.max(5, this.height() - 6);
141
+ if (selectedRow < this.scroll)
142
+ this.scroll = Math.max(0, selectedRow - 1);
143
+ if (selectedRow >= this.scroll + room)
144
+ this.scroll = selectedRow - room + 1;
145
+ for (const r of rows.slice(this.scroll, this.scroll + room)) {
146
+ if ("header" in r) {
147
+ lines.push(row(P.on("Title", bg) + ` ${r.header}` + RESET, r.header.length + 1));
148
+ continue;
149
+ }
150
+ const selected = r.i === this.index;
151
+ const rowBg = selected ? (P.attr("PmenuSel").bg ?? P.attr("Visual").bg) : bg;
152
+ const group = selected ? "PmenuSel" : "NormalFloat";
153
+ const value = selected && this.capturing
154
+ ? `${this.capturing.join("") || "…"} (Enter keeps, Esc cancels)`
155
+ : r.item.value();
156
+ const labelW = Math.min(34, inner - 4);
157
+ const label = truncateToWidth(r.item.label, labelW, "…");
158
+ const valueText = truncateToWidth(value, Math.max(4, inner - labelW - 5), "…");
159
+ const arrows = r.item.change && !r.item.bind ? "‹ › " : "";
160
+ lines.push(row(P.on(group, rowBg) + ` ${label}${" ".repeat(Math.max(0, labelW - visibleWidth(label)))} ` + P.on(selected ? "PmenuSel" : "Special", rowBg) + valueText + P.on("Comment", rowBg) + (selected ? ` ${arrows}` : ""), 3 + labelW + 1 + visibleWidth(valueText) + (selected ? 1 + arrows.length : 0), rowBg));
161
+ }
162
+ while (lines.length < room + 1)
163
+ lines.push(row("", 0));
164
+ const item = this.items[this.index];
165
+ const hint = this.capturing
166
+ ? "type the new keys, then Enter · Backspace removes one · Esc cancels"
167
+ : this.message || item.hint || "";
168
+ lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
169
+ lines.push(row(P.on(this.message && !this.capturing ? "ModeMsg" : "Comment", bg) + ` ${truncateToWidth(hint, inner - 2, "…")}` + RESET, Math.min(inner, hint.length + 1)));
170
+ const keys = " j/k move · Enter/Space change · h/l back/forward · w save to config.toml · q close ";
171
+ lines.push(border(I.bl) + P.on("Comment", bg) + truncateToWidth(keys, inner, "") + RESET + border(I.h.repeat(Math.max(0, inner - visibleWidth(truncateToWidth(keys, inner, "")))) + I.br));
172
+ return lines;
173
+ }
174
+ }
@@ -0,0 +1,31 @@
1
+ /** "Did you mean ...?" for typos in action and theme names. */
2
+ function distance(a, b) {
3
+ const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
4
+ for (let i = 1; i <= a.length; i++) {
5
+ let prev = dp[0];
6
+ dp[0] = i;
7
+ for (let j = 1; j <= b.length; j++) {
8
+ const tmp = dp[j];
9
+ dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
10
+ prev = tmp;
11
+ }
12
+ }
13
+ return dp[b.length];
14
+ }
15
+ /** The closest candidate within a few edits, or undefined. */
16
+ export function closest(name, candidates) {
17
+ let best;
18
+ let bestScore = Math.max(2, Math.floor(name.length / 3)) + 1;
19
+ for (const c of candidates) {
20
+ const d = distance(name.toLowerCase(), c.toLowerCase());
21
+ if (d < bestScore) {
22
+ best = c;
23
+ bestScore = d;
24
+ }
25
+ }
26
+ return best;
27
+ }
28
+ export function didYouMean(name, candidates) {
29
+ const c = closest(name, candidates);
30
+ return c ? ` (did you mean '${c}'?)` : "";
31
+ }