@mnmal-ai/surface-core 0.0.0 → 0.14.6

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,1011 @@
1
+ import { DEFAULT_GROUND } from "@mnmal-ai/surface-theme";
2
+
3
+ //#region src/complete.ts
4
+ const TIMEOUT_MS = 150;
5
+ const LIMIT = 8;
6
+ async function within(work, ms) {
7
+ let timer;
8
+ const timeout = new Promise((resolve) => {
9
+ timer = setTimeout(() => resolve(null), ms);
10
+ });
11
+ try {
12
+ return await Promise.race([work, timeout]);
13
+ } catch {
14
+ return null;
15
+ } finally {
16
+ clearTimeout(timer);
17
+ }
18
+ }
19
+ /** Every completer at once; a slow or failing one is dropped, never waited on. */
20
+ async function mergeCompletions(completers, draft, cursor, opts = {}) {
21
+ const ms = opts.timeoutMs ?? TIMEOUT_MS;
22
+ const results = await Promise.all(completers.map((c) => within(Promise.resolve().then(() => c.complete(draft, cursor)), ms)));
23
+ const byInsert = /* @__PURE__ */ new Map();
24
+ for (const list of results) for (const s of list ?? []) {
25
+ const key = s.replace ? `${s.insert}\0${s.replace[0]},${s.replace[1]}` : s.insert;
26
+ const prior = byInsert.get(key);
27
+ if (prior === void 0 || (s.score ?? 0) > (prior.score ?? 0)) byInsert.set(key, s);
28
+ }
29
+ const merged = [...byInsert.values()];
30
+ merged.sort((a, b) => (b.score ?? 0) - (a.score ?? 0) || a.label.localeCompare(b.label));
31
+ return merged.slice(0, opts.limit ?? LIMIT);
32
+ }
33
+ function validSpan(span, length) {
34
+ if (span === void 0) return false;
35
+ const [start, end] = span;
36
+ return Number.isInteger(start) && Number.isInteger(end) && 0 <= start && start <= end && end <= length;
37
+ }
38
+ /**
39
+ * Replace the suggestion's `replace` span with its insert, or, without a valid one, the
40
+ * whitespace-delimited token ending at `cursor`. The cursor lands after the insert.
41
+ */
42
+ function applySuggestion(draft, cursor, s) {
43
+ if (validSpan(s.replace, draft.length)) {
44
+ const [start, end] = s.replace;
45
+ return {
46
+ text: draft.slice(0, start) + s.insert + draft.slice(end),
47
+ cursor: start + s.insert.length
48
+ };
49
+ }
50
+ const start = draft.slice(0, cursor).search(/\S+$/);
51
+ const from = start < 0 ? cursor : start;
52
+ return {
53
+ text: draft.slice(0, from) + s.insert + draft.slice(cursor),
54
+ cursor: from + s.insert.length
55
+ };
56
+ }
57
+
58
+ //#endregion
59
+ //#region src/key-spec.ts
60
+ const NAMED = /* @__PURE__ */ new Set([
61
+ "escape",
62
+ "enter",
63
+ "tab",
64
+ "up",
65
+ "down",
66
+ "left",
67
+ "right",
68
+ "pageup",
69
+ "pagedown",
70
+ "home",
71
+ "end",
72
+ "backspace",
73
+ "delete"
74
+ ]);
75
+ const ALIASES = {
76
+ esc: "escape",
77
+ return: "enter",
78
+ pgup: "pageup",
79
+ pgdn: "pagedown",
80
+ space: " "
81
+ };
82
+ const single = (s) => [...s].length === 1;
83
+ /** Maps a binding string to the canonical form a key event is resolved to. Throws if no event can produce it. */
84
+ function normaliseKeySpec(spec) {
85
+ if (spec === " ") return " ";
86
+ const s = spec.trim();
87
+ const lower = s.toLowerCase();
88
+ const named = ALIASES[lower] ?? lower;
89
+ if (NAMED.has(named) || named === " ") return named;
90
+ const mod = /^(ctrl|meta)\+(.+)$/i.exec(s);
91
+ if (mod?.[1] && mod[2] !== void 0 && single(mod[2])) return `${mod[1].toLowerCase()}+${mod[2].toLowerCase()}`;
92
+ if (single(s)) return s;
93
+ throw new Error(`key spec '${spec}' can never be produced by a key event`);
94
+ }
95
+
96
+ //#endregion
97
+ //#region src/compose.ts
98
+ /** Keys a module may not bind globally: the exit chord, submit, cancel, complete. */
99
+ const RESERVED_GLOBAL_KEYS = [
100
+ "ctrl+c",
101
+ "enter",
102
+ "escape",
103
+ "tab"
104
+ ];
105
+ /** Keys a panel may not bind: only these are taken before a focused panel's keymap (keys.ts). */
106
+ const RESERVED_PANEL_KEYS = ["ctrl+c", "escape"];
107
+ /** One printable character: what a confirm key must be, since it is typed into the draft. */
108
+ const PRINTABLE = /^[^\p{C}\s]$/u;
109
+ var CompositionError = class extends Error {
110
+ problems;
111
+ constructor(problems) {
112
+ super(`modules do not compose:\n ${problems.join("\n ")}`);
113
+ this.problems = problems;
114
+ this.name = "CompositionError";
115
+ }
116
+ };
117
+ function composeModules(modules) {
118
+ const problems = [];
119
+ const ids = /* @__PURE__ */ new Set();
120
+ const turnIds = /* @__PURE__ */ new Map();
121
+ const commands = /* @__PURE__ */ new Map();
122
+ const panels = /* @__PURE__ */ new Map();
123
+ const globals = /* @__PURE__ */ new Map();
124
+ const renderers = /* @__PURE__ */ new Map();
125
+ const completers = [];
126
+ const turns = [];
127
+ const rail = [];
128
+ const status = [];
129
+ let presence;
130
+ for (const m of modules) {
131
+ if (ids.has(m.id)) {
132
+ problems.push(`module id '${m.id}' is used twice`);
133
+ continue;
134
+ }
135
+ ids.add(m.id);
136
+ for (const c of m.commands ?? []) {
137
+ const prior = commands.get(c.name);
138
+ if (prior) problems.push(`command '/${c.name}' is registered by ${prior.module} and ${m.id}`);
139
+ else commands.set(c.name, {
140
+ module: m.id,
141
+ spec: c
142
+ });
143
+ }
144
+ const canon = (spec) => {
145
+ try {
146
+ return normaliseKeySpec(spec);
147
+ } catch (e) {
148
+ problems.push(`${m.id}: ${e.message}`);
149
+ return;
150
+ }
151
+ };
152
+ for (const [panel, rawDef] of Object.entries(m.panels ?? {})) {
153
+ const key = `${m.id}/${panel}`;
154
+ const def = rawDef;
155
+ const seen = /* @__PURE__ */ new Set();
156
+ const keymap = def.keymap?.flatMap((k) => {
157
+ const c = canon(k.key);
158
+ if (c === void 0) return [];
159
+ if (RESERVED_PANEL_KEYS.includes(c)) {
160
+ problems.push(`${key} binds '${k.key}', but '${c}' is reserved by the core`);
161
+ return [];
162
+ }
163
+ if (seen.has(c)) {
164
+ problems.push(`${key} binds '${c}' twice`);
165
+ return [];
166
+ }
167
+ seen.add(c);
168
+ return [{
169
+ ...k,
170
+ key: c
171
+ }];
172
+ });
173
+ const bound = new Set((keymap ?? []).map((k) => k.key));
174
+ const confirmed = /* @__PURE__ */ new Set();
175
+ for (const c of def.confirm ?? []) {
176
+ if (!PRINTABLE.test(c.key)) problems.push(`${key} confirms '${c.key}', which is not one printable character`);
177
+ else if (bound.has(c.key)) problems.push(`${key} binds '${c.key}' in both its keymap and confirm`);
178
+ else if (confirmed.has(c.key)) problems.push(`${key} confirms '${c.key}' twice`);
179
+ confirmed.add(c.key);
180
+ if (def.on?.[c.act] === void 0) problems.push(`${key} confirms '${c.key}' with act '${c.act}', which it has no handler for`);
181
+ }
182
+ if (def.escape !== void 0 && def.submit === void 0) problems.push(`${key} handles escape without submit, which only an input panel may`);
183
+ if (def.mask === true && def.submit === void 0) problems.push(`${key} masks its input without submit, which only an input panel may`);
184
+ if (def.mask === true && (def.confirm?.length ?? 0) > 0) problems.push(`${key} masks its input but declares confirm keys, which would arm on a secret's characters`);
185
+ if (def.mask === true) {
186
+ for (const k of keymap ?? []) if ([...k.key].length === 1) problems.push(`${key} masks its input but binds '${k.key}', which would make that character untypeable in the secret`);
187
+ }
188
+ if (panels.has(key)) problems.push(`panel '${key}' is registered twice`);
189
+ else panels.set(key, {
190
+ module: m.id,
191
+ panel,
192
+ def: keymap ? {
193
+ ...def,
194
+ keymap
195
+ } : def
196
+ });
197
+ }
198
+ for (const b of m.keymap ?? []) {
199
+ const key = canon(b.key);
200
+ if (key === void 0) continue;
201
+ if (RESERVED_GLOBAL_KEYS.includes(key)) {
202
+ problems.push(`${m.id} binds '${b.key}', but '${key}' is reserved by the core`);
203
+ continue;
204
+ }
205
+ if ([...key].length === 1) {
206
+ problems.push(`${m.id} binds '${b.key}' globally, which would make that character untypeable`);
207
+ continue;
208
+ }
209
+ const prior = globals.get(key);
210
+ if (prior) problems.push(`global key '${key}' is bound by ${prior.module} and ${m.id}`);
211
+ else globals.set(key, {
212
+ module: m.id,
213
+ binding: {
214
+ ...b,
215
+ key
216
+ }
217
+ });
218
+ }
219
+ for (const [kind, render] of Object.entries(m.renderers ?? {})) {
220
+ const prior = renderers.get(kind);
221
+ if (prior) problems.push(`renderer '${kind}' is registered by ${prior.module} and ${m.id}`);
222
+ else renderers.set(kind, {
223
+ module: m.id,
224
+ render
225
+ });
226
+ }
227
+ if (m.presence !== void 0) {
228
+ if (presence) problems.push(`presence is declared by ${presence.module} and ${m.id}; only one may`);
229
+ else presence = {
230
+ module: m.id,
231
+ spec: m.presence
232
+ };
233
+ }
234
+ for (const c of m.completers ?? []) completers.push({
235
+ module: m.id,
236
+ completer: c
237
+ });
238
+ for (const h of m.turns ?? []) {
239
+ const prior = turnIds.get(h.id);
240
+ if (prior) problems.push(`turn handler '${h.id}' is registered by ${prior} and ${m.id}`);
241
+ else turnIds.set(h.id, m.id);
242
+ turns.push({
243
+ module: m.id,
244
+ handler: h
245
+ });
246
+ }
247
+ for (const item of m.rail ?? []) rail.push({
248
+ module: m.id,
249
+ item
250
+ });
251
+ for (const item of m.status ?? []) status.push({
252
+ module: m.id,
253
+ item
254
+ });
255
+ }
256
+ if (problems.length > 0) throw new CompositionError(problems);
257
+ return {
258
+ modules,
259
+ commands,
260
+ panels,
261
+ globals,
262
+ completers,
263
+ turns,
264
+ renderers,
265
+ rail,
266
+ status,
267
+ ...presence ? { presence } : {}
268
+ };
269
+ }
270
+ /** One line in an app's tests: a composition conflict becomes a CI failure. */
271
+ function assertComposes(modules) {
272
+ composeModules(modules);
273
+ }
274
+
275
+ //#endregion
276
+ //#region src/apply-input.ts
277
+ /** A typed or pasted chunk applied to the draft: complete lines are submitted, the rest kept. */
278
+ function applyInput(draft, chunk) {
279
+ if (!/[\r\n]/.test(chunk)) return {
280
+ draft: draft + chunk,
281
+ submit: []
282
+ };
283
+ const parts = (draft + chunk).split(/\r\n|\r|\n/);
284
+ return {
285
+ draft: parts.pop() ?? "",
286
+ submit: parts.map((p) => p.trim()).filter((p) => p !== "")
287
+ };
288
+ }
289
+
290
+ //#endregion
291
+ //#region src/editor.ts
292
+ const EMPTY_DRAFT = {
293
+ text: "",
294
+ cursor: 0
295
+ };
296
+ /** A whole new line with the cursor at its end — a completion, or a cleared input. */
297
+ function atEnd(text) {
298
+ return {
299
+ text,
300
+ cursor: text.length
301
+ };
302
+ }
303
+ const isWord = (ch) => ch !== void 0 && /[\p{L}\p{N}_'-]/u.test(ch);
304
+ const SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
305
+ /** The text as grapheme clusters with their code-unit bounds. The cursor is always one of these,
306
+ * so a step or a delete never leaves half an emoji or a bare combining mark behind. */
307
+ const clusters = (text) => {
308
+ const out = [];
309
+ for (const s of SEGMENTER.segment(text)) out.push({
310
+ input: s.segment,
311
+ index: s.index,
312
+ end: s.index + s.segment.length
313
+ });
314
+ return out;
315
+ };
316
+ /** Start of the word before the cursor, skipping the spaces in between. */
317
+ function wordLeft(text, from) {
318
+ const cs = clusters(text);
319
+ let j = 0;
320
+ while (j < cs.length && (cs[j]?.end ?? text.length) <= from) j++;
321
+ while (j > 0 && !isWord(cs[j - 1]?.input)) j--;
322
+ while (j > 0 && isWord(cs[j - 1]?.input)) j--;
323
+ return j < cs.length ? cs[j]?.index ?? 0 : text.length;
324
+ }
325
+ /** End of the word after the cursor, skipping the spaces in between. */
326
+ function wordRight(text, from) {
327
+ const cs = clusters(text);
328
+ let j = 0;
329
+ while (j < cs.length && (cs[j]?.end ?? text.length) <= from) j++;
330
+ while (j < cs.length && !isWord(cs[j]?.input)) j++;
331
+ while (j < cs.length && isWord(cs[j]?.input)) j++;
332
+ return j < cs.length ? cs[j]?.index ?? text.length : text.length;
333
+ }
334
+ /**
335
+ * A key that moves or deletes, applied — or null when the key is not an editing key, so the
336
+ * caller carries on to typing and submitting.
337
+ *
338
+ * Backspace and Delete both delete before the cursor. Terminals disagree about which byte the
339
+ * Backspace key sends, and Ink reports the common one (0x7f) as `delete`; treating it as a
340
+ * forward delete would make the key nearly everyone presses delete the wrong character. Deleting
341
+ * under the cursor is Ctrl-D, which no terminal sends by accident.
342
+ */
343
+ function editKey(d, input, key) {
344
+ const { text, cursor } = d;
345
+ const to = (c) => ({
346
+ text,
347
+ cursor: Math.max(0, Math.min(text.length, c))
348
+ });
349
+ const cut = (from, until) => ({
350
+ text: text.slice(0, from) + text.slice(until),
351
+ cursor: from
352
+ });
353
+ const byWord = key.ctrl === true || key.meta === true;
354
+ const cs = clusters(text);
355
+ /** The boundary before this one: the start of the cluster the cursor sits behind. */
356
+ const before = (c) => {
357
+ let i = 0;
358
+ while (i < cs.length && (cs[i]?.end ?? text.length) <= c) i++;
359
+ return i > 0 ? cs[i - 1]?.index ?? 0 : 0;
360
+ };
361
+ /** The boundary after this one: the end of the cluster under the cursor. */
362
+ const after = (c) => {
363
+ let i = 0;
364
+ while (i < cs.length && (cs[i]?.end ?? text.length) <= c) i++;
365
+ return i < cs.length ? cs[i]?.end ?? text.length : text.length;
366
+ };
367
+ if (key.leftArrow) return to(byWord ? wordLeft(text, cursor) : before(cursor));
368
+ if (key.rightArrow) return to(byWord ? wordRight(text, cursor) : after(cursor));
369
+ if (key.home || key.ctrl && input === "a") return to(0);
370
+ if (key.end || key.ctrl && input === "e") return to(text.length);
371
+ if (key.meta && input === "b") return to(wordLeft(text, cursor));
372
+ if (key.meta && input === "f") return to(wordRight(text, cursor));
373
+ if (key.backspace || key.delete) return cursor === 0 ? d : cut(before(cursor), cursor);
374
+ if (key.ctrl && input === "d") return cursor === text.length ? d : cut(cursor, after(cursor));
375
+ if (key.ctrl && input === "u") return cut(0, cursor);
376
+ if (key.ctrl && input === "k") return cut(cursor, text.length);
377
+ if (key.ctrl && input === "w") return cut(wordLeft(text, cursor), cursor);
378
+ return null;
379
+ }
380
+ /**
381
+ * Typed or piped text, inserted at the cursor. Whole lines are submitted, as `applyInput` does.
382
+ *
383
+ * Enter sends the whole line wherever the cursor is: correcting a typo mid-line does not mean
384
+ * splitting the question. A chunk containing newlines is inserted at the cursor and then cut into
385
+ * lines, and the text that followed the cursor stays after it on the unsent line.
386
+ */
387
+ function insertChunk(d, chunk) {
388
+ if (chunk === "\n" || chunk === "\r") {
389
+ const next = applyInput(d.text, "\n");
390
+ return {
391
+ draft: atEnd(next.draft),
392
+ submit: next.submit
393
+ };
394
+ }
395
+ const before = d.text.slice(0, d.cursor);
396
+ const after = d.text.slice(d.cursor);
397
+ if (!/[\r\n]/.test(chunk)) return {
398
+ draft: {
399
+ text: before + chunk + after,
400
+ cursor: d.cursor + chunk.length
401
+ },
402
+ submit: []
403
+ };
404
+ const next = applyInput("", before + chunk + after);
405
+ return {
406
+ draft: {
407
+ text: next.draft,
408
+ cursor: Math.max(0, next.draft.length - after.length)
409
+ },
410
+ submit: next.submit
411
+ };
412
+ }
413
+ /**
414
+ * A paste becomes one line of draft, and is never sent.
415
+ *
416
+ * `insertChunk` submits every complete line — right for a driver script piping `question\n`,
417
+ * wrong for a human's multi-line paste. Bracketed paste (Ink's `usePaste`) routes a terminal
418
+ * paste here instead. Line breaks fold to a single space because the draft is one line; the
419
+ * operator edits and presses Enter. Nothing about a paste may be the Enter.
420
+ */
421
+ function pasteToLine(text) {
422
+ return text.replace(/[\t\f\v]+/g, " ").replace(/\s+/g, (run) => /[\r\n]/.test(run) ? " " : run).trim();
423
+ }
424
+ /** The grapheme cluster at a cursor boundary, or '' at the end of the line. */
425
+ function graphemeAt(text, at) {
426
+ for (const s of SEGMENTER.segment(text)) if (s.index >= at) return s.segment;
427
+ return "";
428
+ }
429
+ /** The draft as a masked panel draws it: one `•` per grapheme, the cursor on the same one. */
430
+ function maskDraft(d) {
431
+ const cs = clusters(d.text);
432
+ let before = 0;
433
+ for (const c of cs) {
434
+ if (c.end > d.cursor) break;
435
+ before += 1;
436
+ }
437
+ return {
438
+ text: "•".repeat(cs.length),
439
+ cursor: before
440
+ };
441
+ }
442
+
443
+ //#endregion
444
+ //#region src/exit-chord.ts
445
+ const CTRL_C_WINDOW_MS = 1500;
446
+ /** What a ctrl-c press does, given when (if ever) the previous one armed the exit. */
447
+ function ctrlC(armedAt, now, windowMs = CTRL_C_WINDOW_MS) {
448
+ return armedAt !== null && now - armedAt <= windowMs ? "exit" : "arm";
449
+ }
450
+
451
+ //#endregion
452
+ //#region src/history.ts
453
+ /**
454
+ * ↑/↓ walk the operator's previous turns, like a shell.
455
+ *
456
+ * - a bounded ring (HISTORY_MAX), for this session only;
457
+ * - only conversation turns are kept, not slash commands, which would stand between the
458
+ * operator and the turn they want to redo;
459
+ * - a recalled turn lands in the line editable, and sending it sends a new turn;
460
+ * - the draft being typed is kept at the bottom, so walking up and back loses nothing;
461
+ * - in a multi-line draft, ↑ walks only from the first line and ↓ only from the last, so the
462
+ * arrows still move the cursor inside a long paste.
463
+ *
464
+ * Pure: the component holds a `HistoryState` and asks these functions what to do.
465
+ */
466
+ const HISTORY_MAX = 100;
467
+ const EMPTY_HISTORY = {
468
+ entries: [],
469
+ at: null,
470
+ stash: ""
471
+ };
472
+ /**
473
+ * Remember a sent line. Blank lines, slash commands and a straight repeat of the last one are not
474
+ * kept — but sending any of them still ends a walk, so the next ↑ starts from the newest turn.
475
+ */
476
+ function remember(h, line) {
477
+ const text = line.trim();
478
+ return {
479
+ entries: text === "" || text.startsWith("/") || h.entries[h.entries.length - 1] === text ? h.entries : [...h.entries, text].slice(-100),
480
+ at: null,
481
+ stash: ""
482
+ };
483
+ }
484
+ /** Whether an arrow may walk history from here, rather than move the cursor in a long draft. */
485
+ function mayWalk(draft, cursor, dir) {
486
+ if (!draft.includes("\n")) return true;
487
+ return dir === "up" ? !draft.slice(0, cursor).includes("\n") : !draft.slice(cursor).includes("\n");
488
+ }
489
+ /**
490
+ * One step. Returns the new state and the text the line should now hold, or null when there is
491
+ * nowhere to go (↓ from the draft, ↑ past the oldest entry, or no history at all).
492
+ */
493
+ function walk(h, dir, draft) {
494
+ if (h.entries.length === 0) return null;
495
+ if (dir === "up") {
496
+ if (h.at === null) {
497
+ const at = h.entries.length - 1;
498
+ return {
499
+ state: {
500
+ ...h,
501
+ at,
502
+ stash: draft
503
+ },
504
+ text: h.entries[at] ?? ""
505
+ };
506
+ }
507
+ if (h.at === 0) return null;
508
+ const at = h.at - 1;
509
+ return {
510
+ state: {
511
+ ...h,
512
+ at
513
+ },
514
+ text: h.entries[at] ?? ""
515
+ };
516
+ }
517
+ if (h.at === null) return null;
518
+ if (h.at >= h.entries.length - 1) return {
519
+ state: {
520
+ ...h,
521
+ at: null,
522
+ stash: ""
523
+ },
524
+ text: h.stash
525
+ };
526
+ const at = h.at + 1;
527
+ return {
528
+ state: {
529
+ ...h,
530
+ at
531
+ },
532
+ text: h.entries[at] ?? ""
533
+ };
534
+ }
535
+ /** The recalled line was edited: it is a draft now, no longer a position in the history. */
536
+ const settle = (h) => h.at === null ? h : {
537
+ ...h,
538
+ at: null,
539
+ stash: ""
540
+ };
541
+
542
+ //#endregion
543
+ //#region src/keys.ts
544
+ function keySpecOf(k) {
545
+ if (k.escape) return "escape";
546
+ if (k.return) return "enter";
547
+ if (k.tab) return "tab";
548
+ if (k.upArrow) return "up";
549
+ if (k.downArrow) return "down";
550
+ if (k.leftArrow) return "left";
551
+ if (k.rightArrow) return "right";
552
+ if (k.pageUp) return "pageup";
553
+ if (k.pageDown) return "pagedown";
554
+ if (k.home) return "home";
555
+ if (k.end) return "end";
556
+ if (k.backspace) return "backspace";
557
+ if (k.delete) return "delete";
558
+ if (k.ctrl) return `ctrl+${k.input.toLowerCase()}`;
559
+ if (k.meta) return `meta+${k.input.toLowerCase()}`;
560
+ return [...k.input].length === 1 ? k.input : null;
561
+ }
562
+ /** Pure: a key resolves to data, and only then does a handler run. Precedence per spec §3. */
563
+ function resolveKey(state, key, registry) {
564
+ const spec = keySpecOf(key);
565
+ const focused = state.overlay === null ? void 0 : registry.panels.get(`${state.overlay.module}/${state.overlay.panel}`);
566
+ const live = (focused?.def.confirm?.length ?? 0) > 0 || focused?.def.submit !== void 0;
567
+ if (spec === null) return state.menu === null && state.overlay !== null && !live ? { kind: "ignore" } : { kind: "edit" };
568
+ if (spec === "ctrl+c") return { kind: "exit-chord" };
569
+ if (state.menu !== null) {
570
+ if (spec === "up") return {
571
+ kind: "menu",
572
+ act: "move",
573
+ delta: -1
574
+ };
575
+ if (spec === "down") return {
576
+ kind: "menu",
577
+ act: "move",
578
+ delta: 1
579
+ };
580
+ if (spec === "tab") return {
581
+ kind: "menu",
582
+ act: "accept"
583
+ };
584
+ if (spec === "enter") return {
585
+ kind: "menu",
586
+ act: state.menu === "palette" ? "run" : "accept"
587
+ };
588
+ if (spec === "escape") return {
589
+ kind: "menu",
590
+ act: "close"
591
+ };
592
+ return { kind: "edit" };
593
+ }
594
+ if (state.overlay !== null) {
595
+ if (spec === "escape") return { kind: "panel-close" };
596
+ const hit = focused?.def.keymap?.find((b) => b.key === spec);
597
+ if (hit) return {
598
+ kind: "panel",
599
+ module: state.overlay.module,
600
+ panel: state.overlay.panel,
601
+ act: hit.act
602
+ };
603
+ const g = registry.globals.get(spec);
604
+ if (g) return {
605
+ kind: "global",
606
+ module: g.module,
607
+ key: spec
608
+ };
609
+ if (!live) return { kind: "ignore" };
610
+ if (spec === "enter") {
611
+ const armed = focused?.def.confirm?.find((c) => c.key === state.armed);
612
+ return armed === void 0 ? { kind: "submit" } : {
613
+ kind: "confirm",
614
+ module: state.overlay.module,
615
+ panel: state.overlay.panel,
616
+ act: armed.act
617
+ };
618
+ }
619
+ }
620
+ const global = registry.globals.get(spec);
621
+ if (global) return {
622
+ kind: "global",
623
+ module: global.module,
624
+ key: spec
625
+ };
626
+ if (spec === "escape") return state.draftEmpty ? { kind: "cancel-turn" } : { kind: "ignore" };
627
+ if (spec === "up") return {
628
+ kind: "history",
629
+ dir: "up"
630
+ };
631
+ if (spec === "down") return {
632
+ kind: "history",
633
+ dir: "down"
634
+ };
635
+ if (spec === "enter") return { kind: "submit" };
636
+ if (spec === "tab") return { kind: "complete" };
637
+ return { kind: "edit" };
638
+ }
639
+
640
+ //#endregion
641
+ //#region src/palette.ts
642
+ /** The palette's filter text: the draft after a leading '/', until the name ends. */
643
+ function paletteQuery(draft) {
644
+ return /^\/[a-z0-9-]*$/i.test(draft) ? draft.slice(1) : null;
645
+ }
646
+ /** Prefix matches first, then substring-only, each group alphabetical. Case-insensitive. */
647
+ function paletteRows(commands, query) {
648
+ const q = query.toLowerCase();
649
+ const byName = (a, b) => a.cmd.name.localeCompare(b.cmd.name);
650
+ const rows = [];
651
+ for (const cmd of commands) {
652
+ const name = cmd.name.toLowerCase();
653
+ if (name.startsWith(q)) rows.push({
654
+ cmd,
655
+ prefix: true
656
+ });
657
+ else if (name.includes(q)) rows.push({
658
+ cmd,
659
+ prefix: false
660
+ });
661
+ }
662
+ return [...rows.filter((r) => r.prefix).sort(byName), ...rows.filter((r) => !r.prefix).sort(byName)];
663
+ }
664
+ function paletteMatches(commands, query) {
665
+ return paletteRows(commands, query).map((r) => r.cmd);
666
+ }
667
+ /**
668
+ * The command Enter runs, or null when the line should be sent as typed. A substring-only row
669
+ * runs only after the user moved onto it: Enter on a typo must never run a different command.
670
+ */
671
+ function enterRuns(rows, selected, moved) {
672
+ const row = rows[clampSelection(selected, rows.length)];
673
+ if (row === void 0) return null;
674
+ return row.prefix || moved ? row.cmd : null;
675
+ }
676
+ function moveSelection(selected, delta, count) {
677
+ if (count <= 0) return 0;
678
+ return ((selected + delta) % count + count) % count;
679
+ }
680
+ function clampSelection(selected, count) {
681
+ return count <= 0 ? 0 : Math.min(Math.max(0, selected), count - 1);
682
+ }
683
+ function completeDraft(c) {
684
+ return c.args.length > 0 ? `/${c.name} ` : `/${c.name}`;
685
+ }
686
+ function commandUsage(c) {
687
+ const args = c.args.map((a) => a.optional ? `[${a.name}]` : `<${a.name}>`);
688
+ return [`/${c.name}`, ...args].join(" ");
689
+ }
690
+ function parseCommandLine(line) {
691
+ const m = /^\/([a-z][a-z0-9-]*)(?:\s+(.*))?$/is.exec(line.trim());
692
+ if (m === null) return null;
693
+ const args = (m[2] ?? "").trim().split(/\s+/).filter((a) => a !== "");
694
+ return {
695
+ name: m[1].toLowerCase(),
696
+ args
697
+ };
698
+ }
699
+ /** Width of the palette's name column. */
700
+ const MENU_NAME_WIDTH = 22;
701
+ /**
702
+ * The slice of a menu `height` rows tall, centred on the selection so moving keeps it in view, and
703
+ * the position to show beside ↑↓ when some rows are out of view (empty when every row fits).
704
+ */
705
+ function menuWindow(count, selected, height) {
706
+ const h = Math.max(1, Math.floor(height));
707
+ if (count <= h) return {
708
+ start: 0,
709
+ end: count,
710
+ position: ""
711
+ };
712
+ const start = Math.min(Math.max(0, selected - Math.floor(h / 2)), count - h);
713
+ return {
714
+ start,
715
+ end: start + h,
716
+ position: ` · ${start + 1}–${start + h} of ${count}`
717
+ };
718
+ }
719
+
720
+ //#endregion
721
+ //#region src/route-input.ts
722
+ /** A handler whose `claims` throws is treated as not claiming, so one bad module cannot block input. */
723
+ function claimOf(handler, line) {
724
+ try {
725
+ return handler.claims(line);
726
+ } catch {
727
+ return false;
728
+ }
729
+ }
730
+ /**
731
+ * A command's `unavailable` reason, or null. A throwing thunk, or an empty reason, is unavailable
732
+ * with the reason `unavailable`; an undefined one (a loose JS consumer) is available.
733
+ */
734
+ function unavailableReason(spec) {
735
+ if (spec.unavailable === void 0) return null;
736
+ let reason;
737
+ try {
738
+ reason = spec.unavailable() ?? null;
739
+ } catch {
740
+ return "unavailable";
741
+ }
742
+ return reason === "" ? "unavailable" : reason;
743
+ }
744
+ function routeInput(line, registry, defaultTurn) {
745
+ const cmd = parseCommandLine(line);
746
+ if (cmd !== null) {
747
+ const hit = registry.commands.get(cmd.name);
748
+ if (!hit) return {
749
+ kind: "unknown-command",
750
+ name: cmd.name
751
+ };
752
+ const reason = unavailableReason(hit.spec);
753
+ if (reason !== null) return {
754
+ kind: "unavailable-command",
755
+ name: cmd.name,
756
+ reason
757
+ };
758
+ return {
759
+ kind: "command",
760
+ module: hit.module,
761
+ spec: hit.spec,
762
+ args: cmd.args
763
+ };
764
+ }
765
+ let best = null;
766
+ for (const t of registry.turns) {
767
+ const score = claimOf(t.handler, line);
768
+ if (score === false) continue;
769
+ if (best === null || score > best.score) best = {
770
+ ...t,
771
+ score
772
+ };
773
+ }
774
+ if (best !== null) return {
775
+ kind: "turn",
776
+ module: best.module,
777
+ handler: best.handler
778
+ };
779
+ if (defaultTurn !== void 0) {
780
+ const d = registry.turns.find((t) => `${t.module}:${t.handler.id}` === defaultTurn);
781
+ if (d) return {
782
+ kind: "turn",
783
+ module: d.module,
784
+ handler: d.handler
785
+ };
786
+ }
787
+ return { kind: "no-handler" };
788
+ }
789
+
790
+ //#endregion
791
+ //#region src/sanitise.ts
792
+ const OSC = /(?:\u001b\]|\u009d)[^\u0007\u001b\u009c]*(?:\u0007|\u009c|\u001b\\)?/g;
793
+ const CSI = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]?/g;
794
+ const ESC_PAIR = /\u001b[ -~]?/g;
795
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
796
+ /** Strips ESC sequences and C0/C1 control characters; keeps `\n`, and a tab becomes a space. */
797
+ function sanitise(text) {
798
+ return text.replace(/\t/g, " ").replace(OSC, "").replace(CSI, "").replace(ESC_PAIR, "").replace(CONTROL, "");
799
+ }
800
+
801
+ //#endregion
802
+ //#region src/terminal.ts
803
+ /**
804
+ * OSC 11 sets the terminal's default background, so every cell written without one — prose and
805
+ * the empty space around it — sits on it; Ink could only colour what it draws. `none` (or an
806
+ * empty value) leaves the terminal's background alone. Only `#rrggbb` is accepted: anything else
807
+ * inside an OSC is an escape-injection seam into the operator's terminal, so it is dropped.
808
+ */
809
+ function groundSequence(ground) {
810
+ const g = (ground ?? DEFAULT_GROUND).trim();
811
+ if (g === "" || g.toLowerCase() === "none") return null;
812
+ if (!/^#[0-9a-fA-F]{6}$/.test(g)) return null;
813
+ return `\x1b]11;${g}\x07`;
814
+ }
815
+ /** OSC 111: restore the terminal's configured background. */
816
+ const RESET_GROUND = "\x1B]111\x07";
817
+ /** Erase the visible pane (2J) and its scrollback (3J), cursor home. */
818
+ const CLEAR_PANE = "\x1B[2J\x1B[3J\x1B[H";
819
+ /**
820
+ * Blank lines that put the cursor on the last row before the first frame, so the input is
821
+ * bottom-anchored: Ink draws its live region where the cursor is, and printed turns then push the
822
+ * pane up rather than the input down.
823
+ */
824
+ function anchorPadding(rows) {
825
+ if (rows === void 0 || !Number.isFinite(rows) || rows < 2) return "";
826
+ return "\n".repeat(Math.floor(rows) - 1);
827
+ }
828
+ /**
829
+ * What a re-lay writes before the transcript is printed again: the pane and its scrollback wiped,
830
+ * then the input anchored to the last row. A resize cannot be patched, only redone — what `Static`
831
+ * printed was laid out at the old width — and a closed panel left the conversation scrolled up,
832
+ * which a terminal cannot scroll back down. The cost: scrollback from before the session goes too.
833
+ */
834
+ function reflowSequence(rows) {
835
+ return CLEAR_PANE + anchorPadding(rows);
836
+ }
837
+ /**
838
+ * Rows the live region just gave up with nothing printed above it — rows that would otherwise open
839
+ * up below the input. Ink redraws its live region from where it began, so when it shrinks the input
840
+ * rises; printing that many blank rows above it scrolls the pane back. Zero when something was
841
+ * printed in the same step (the print scrolled the pane already) or after a re-lay (which anchors
842
+ * on its own).
843
+ */
844
+ function liveShrink(prev, now) {
845
+ if (prev === null || now.committed !== prev.committed || now.epoch !== prev.epoch) return 0;
846
+ return Math.max(0, prev.height - now.height);
847
+ }
848
+ /** The bytes {@link prepareTerminal} writes, in order. */
849
+ function prepareSequence(rows, opts = {}) {
850
+ return (groundSequence(opts.ground) ?? "") + (opts.clear === false ? "" : CLEAR_PANE) + (opts.anchor === false ? "" : anchorPadding(rows));
851
+ }
852
+ /**
853
+ * Ground, clear and anchor the terminal before `render`. Returns a restore function that puts the
854
+ * terminal's background back. It is also registered on `exit`, and SIGHUP / SIGTERM / SIGINT exit
855
+ * through it, since their default action kills the process with no `exit` event (under Ink's raw
856
+ * mode ctrl+c is input, so SIGINT only arrives from outside: `kill -INT`, a supervisor, tmux).
857
+ * A terminal left black is worse than any rendering bug. A no-op off a TTY.
858
+ */
859
+ function prepareTerminal(out, opts = {}, proc = process) {
860
+ if (out.isTTY !== true) return () => {};
861
+ out.write(prepareSequence(out.rows, opts));
862
+ if (groundSequence(opts.ground) === null) return () => {};
863
+ let restored = false;
864
+ const restore = () => {
865
+ if (restored) return;
866
+ restored = true;
867
+ out.write(RESET_GROUND);
868
+ };
869
+ proc.once("exit", restore);
870
+ proc.once("SIGHUP", () => proc.exit(129));
871
+ proc.once("SIGTERM", () => proc.exit(143));
872
+ proc.once("SIGINT", () => proc.exit(130));
873
+ return restore;
874
+ }
875
+
876
+ //#endregion
877
+ //#region src/transcript.ts
878
+ function emptyTranscript() {
879
+ return {
880
+ committed: [],
881
+ flights: [],
882
+ nextMark: 1
883
+ };
884
+ }
885
+ function sendTurn(t, id, question, emphasis) {
886
+ const flight = {
887
+ id,
888
+ question,
889
+ mark: null,
890
+ ...emphasis === void 0 ? {} : { emphasis }
891
+ };
892
+ return {
893
+ ...t,
894
+ flights: [...t.flights, flight]
895
+ };
896
+ }
897
+ function askOf(f, mark) {
898
+ return {
899
+ key: `ask:${f.id}`,
900
+ kind: "ask",
901
+ question: f.question,
902
+ mark,
903
+ ...f.emphasis === void 0 ? {} : { emphasis: f.emphasis }
904
+ };
905
+ }
906
+ function arriveTurn(t, id, reply) {
907
+ const index = t.flights.findIndex((f) => f.id === id);
908
+ if (index < 0) return t;
909
+ const arriving = t.flights[index];
910
+ const committed = [...t.committed];
911
+ const flights = [];
912
+ let nextMark = t.nextMark;
913
+ t.flights.forEach((f, i) => {
914
+ if (i === index) return;
915
+ if (i < index && f.mark === null) {
916
+ const mark = nextMark;
917
+ nextMark += 1;
918
+ committed.push(askOf(f, mark));
919
+ flights.push({
920
+ ...f,
921
+ mark
922
+ });
923
+ return;
924
+ }
925
+ flights.push(f);
926
+ });
927
+ const answer = {
928
+ key: `answer:${id}`,
929
+ kind: "answer",
930
+ question: arriving.question,
931
+ reply,
932
+ mark: arriving.mark
933
+ };
934
+ if (arriving.mark === null) committed.push(askOf(arriving, null), answer);
935
+ else committed.push(answer);
936
+ return {
937
+ committed,
938
+ flights,
939
+ nextMark
940
+ };
941
+ }
942
+ function heldAsks(t) {
943
+ return t.flights.filter((f) => f.mark === null);
944
+ }
945
+ function markedFlights(t) {
946
+ return t.flights.filter((f) => f.mark !== null);
947
+ }
948
+ function commitEntry(t, entry) {
949
+ return {
950
+ ...t,
951
+ committed: [...t.committed, entry]
952
+ };
953
+ }
954
+ /** The last answer printed, or the one carrying mark `n`. */
955
+ function answerEntry(t, mark) {
956
+ for (let i = t.committed.length - 1; i >= 0; i -= 1) {
957
+ const e = t.committed[i];
958
+ if (e !== void 0 && e.kind === "answer" && (mark === null || e.mark === mark)) return e;
959
+ }
960
+ return null;
961
+ }
962
+ function addGap(t, lines, epoch) {
963
+ if (lines <= 0) return t;
964
+ return commitEntry(t, {
965
+ key: `gap:${t.committed.length}`,
966
+ kind: "gap",
967
+ lines,
968
+ epoch
969
+ });
970
+ }
971
+
972
+ //#endregion
973
+ //#region src/module.ts
974
+ const MODULE_ID = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
975
+ function defineModule(id, spec) {
976
+ if (!MODULE_ID.test(id)) throw new Error(`module id '${id}' must be lower-case dotted, e.g. 'app.ask'`);
977
+ return {
978
+ id,
979
+ ...spec
980
+ };
981
+ }
982
+
983
+ //#endregion
984
+ //#region src/overlay.ts
985
+ /** A panel's state when it is the one open, else `null`. */
986
+ function panelOf(o, kind) {
987
+ return o !== null && o.kind === kind ? o.panel : null;
988
+ }
989
+ /**
990
+ * The overlay after one panel's setter ran. The updater sees this panel's state, or `null`
991
+ * when another panel (or none) is open, exactly as a per-panel `useState` would have shown it.
992
+ *
993
+ * - A value opens this panel and replaces whatever was open.
994
+ * - `null` closes this panel only if it is the one open. Closing a panel that is not open
995
+ * must not close the one that is.
996
+ * - An updater returning what it was given changes nothing. That is how a slow read landing
997
+ * for a panel that has since closed, or been replaced, is dropped.
998
+ */
999
+ function setPanel(o, kind, next) {
1000
+ const current = panelOf(o, kind);
1001
+ const value = typeof next === "function" ? next(current) : next;
1002
+ if (value === current) return o;
1003
+ if (value === null) return current === null ? o : null;
1004
+ return {
1005
+ kind,
1006
+ panel: value
1007
+ };
1008
+ }
1009
+
1010
+ //#endregion
1011
+ export { wordRight as $, moveSelection as A, remember as B, unavailableReason as C, completeDraft as D, commandUsage as E, keySpecOf as F, EMPTY_DRAFT as G, walk as H, resolveKey as I, graphemeAt as J, atEnd as K, EMPTY_HISTORY as L, paletteQuery as M, paletteRows as N, enterRuns as O, parseCommandLine as P, wordLeft as Q, HISTORY_MAX as R, routeInput as S, clampSelection as T, CTRL_C_WINDOW_MS as U, settle as V, ctrlC as W, maskDraft as X, insertChunk as Y, pasteToLine as Z, liveShrink as _, answerEntry as a, normaliseKeySpec as at, reflowSequence as b, emptyTranscript as c, sendTurn as d, applyInput as et, CLEAR_PANE as f, groundSequence as g, anchorPadding as h, addGap as i, composeModules as it, paletteMatches as j, menuWindow as k, heldAsks as l, RESET_GROUND as m, setPanel as n, RESERVED_GLOBAL_KEYS as nt, arriveTurn as o, applySuggestion as ot, DEFAULT_GROUND as p, editKey as q, defineModule as r, assertComposes as rt, commitEntry as s, mergeCompletions as st, panelOf as t, CompositionError as tt, markedFlights as u, prepareSequence as v, MENU_NAME_WIDTH as w, sanitise as x, prepareTerminal as y, mayWalk as z };