@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.
- package/LICENSE +202 -0
- package/README.md +10 -1
- package/dist/app.d.ts +135 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/apply-input.d.ts +6 -0
- package/dist/apply-input.d.ts.map +1 -0
- package/dist/complete.d.ts +15 -0
- package/dist/complete.d.ts.map +1 -0
- package/dist/compose.d.ts +55 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/editor.d.ts +58 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/exit-chord.d.ts +4 -0
- package/dist/exit-chord.d.ts.map +1 -0
- package/dist/history.d.ts +41 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1103 -0
- package/dist/key-spec.d.ts +3 -0
- package/dist/key-spec.d.ts.map +1 -0
- package/dist/keys.d.ts +57 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/module.d.ts +266 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/overlay-DQUbfIKu.js +1011 -0
- package/dist/overlay.d.ts +27 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/palette.d.ts +38 -0
- package/dist/palette.d.ts.map +1 -0
- package/dist/pure.d.ts +16 -0
- package/dist/pure.d.ts.map +1 -0
- package/dist/pure.js +3 -0
- package/dist/route-input.d.ts +28 -0
- package/dist/route-input.d.ts.map +1 -0
- package/dist/sanitise.d.ts +3 -0
- package/dist/sanitise.d.ts.map +1 -0
- package/dist/terminal.d.ts +70 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.d.ts.map +1 -0
- package/package.json +54 -4
|
@@ -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 };
|