@vecteur/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/folders.js ADDED
@@ -0,0 +1,270 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join, relative, sep, parse } from "node:path";
5
+ import { credentialsPath } from "./credentials.js";
6
+ import { CliError } from "./errors.js";
7
+ import { readStore, withStoreLock, writeStore } from "./store.js";
8
+
9
+ export const UNSIGNED_ACCOUNT = "local:unsigned";
10
+
11
+ const MAX_DRAFT_BYTES = 256 * 1024;
12
+ const DRAFT_LIFETIME_MS = 30 * 24 * 60 * 60 * 1_000;
13
+ const identity = path => {
14
+ const real = realpathSync(path);
15
+ const stat = statSync(real, { bigint: true });
16
+ if (!stat.isDirectory()) throw new CliError("folder_invalid", "The session needs a directory");
17
+ return { path: real, device: String(stat.dev), inode: String(stat.ino) };
18
+ };
19
+ export function broadRoot(path, home, { platform = process.platform, mounts, stat = statSync } = {}) {
20
+ const folder = realpathSync(path);
21
+ if (folder === realpathSync(home)) return "home";
22
+ if (folder === parse(folder).root) return "filesystem root";
23
+ if (platform === "linux") {
24
+ const text = mounts ?? readFileSync("/proc/self/mountinfo", "utf8");
25
+ const roots = text.split("\n").map(line => line.split(" ")[4]?.replace(/\\([0-7]{3})/g,
26
+ (_, octal) => String.fromCharCode(parseInt(octal, 8))));
27
+ if (roots.includes(folder)) return "mount root";
28
+ }
29
+ return stat(folder).dev !== stat(dirname(folder)).dev ? "mount root" : null;
30
+ }
31
+
32
+ const contains = (root, child) => {
33
+ const path = relative(root, child);
34
+ return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`));
35
+ };
36
+
37
+ function readRegistry(file) {
38
+ const value = readStore(file);
39
+ if (value === null) return { version: 1, folders: {} };
40
+ if (value.version !== 1) throw new CliError("store_version_unsupported",
41
+ "Folder registry version is unsupported; install the matching CLI. Existing bytes were preserved.");
42
+ if (!value.folders || typeof value.folders !== "object" || Array.isArray(value.folders)
43
+ || Object.values(value.folders).some(row => !row || typeof row !== "object"
44
+ || typeof row.origin !== "string" || typeof row.accountId !== "string"
45
+ || typeof row.folder?.path !== "string" || typeof row.folder?.device !== "string"
46
+ || typeof row.folder?.inode !== "string")
47
+ || (value.logins !== undefined && (!value.logins || typeof value.logins !== "object" || Array.isArray(value.logins)
48
+ || Object.entries(value.logins).some(([key, id]) => !/^[a-f0-9]{64}$/.test(key) || typeof id !== "string" || !id)))) {
49
+ throw new CliError("store_unreadable", "Folder registry is invalid; existing bytes were preserved");
50
+ }
51
+ for (const row of Object.values(value.folders)) {
52
+ row.drafts ??= {};
53
+ if (row.draft && row.draftAt > Date.now() - DRAFT_LIFETIME_MS) {
54
+ row.drafts.legacy ??= { text: row.draft, at: row.draftAt, revision: String(row.draftAt) };
55
+ }
56
+ delete row.draft; delete row.draftAt;
57
+ if (!row.drafts || typeof row.drafts !== "object" || Array.isArray(row.drafts)
58
+ || Object.values(row.drafts).some(draft => !draft || typeof draft.text !== "string"
59
+ || typeof draft.revision !== "string" || !Number.isFinite(draft.at))) {
60
+ throw new CliError("store_unreadable", "Saved drafts are invalid; existing bytes were preserved");
61
+ }
62
+ for (const [id, draft] of Object.entries(row.drafts)) {
63
+ if (!draft.text || draft.at <= Date.now() - DRAFT_LIFETIME_MS) delete row.drafts[id];
64
+ }
65
+ }
66
+ return value;
67
+ }
68
+
69
+
70
+ /** Metadata only: a Project binding is never a local filesystem grant. */
71
+ export class FolderRegistry {
72
+ constructor({ origin, accountId, cwd = process.cwd(), env = process.env }) {
73
+ if (!origin || !accountId) throw new CliError("folder_identity_invalid", "Origin and account are required");
74
+ this.origin = origin;
75
+ this.accountId = accountId;
76
+ this.folder = identity(cwd);
77
+ this.home = env.HOME || homedir();
78
+ this.file = join(dirname(credentialsPath(env)), "folders.json");
79
+ this.key = createHash("sha256").update(JSON.stringify([origin, accountId, this.folder.path])).digest("hex");
80
+ this.lastDraft = ""; this.draftRef = null;
81
+ try {
82
+ const newest = this.state().drafts[0];
83
+ if (newest) this.selectDraft(newest.id);
84
+ } catch (error) { if (error.code !== "folder_replaced") throw error; }
85
+ }
86
+
87
+ static forCredential({ origin, token, env = process.env, cwd = process.cwd() }) {
88
+ const file = join(dirname(credentialsPath(env)), "folders.json");
89
+ const accountId = readRegistry(file).logins?.[createHash("sha256").update(JSON.stringify([origin, token])).digest("hex")];
90
+ return typeof accountId === "string" && accountId
91
+ ? new FolderRegistry({ origin, accountId, cwd, env }) : null;
92
+ }
93
+
94
+ requireAccount() {
95
+ if (this.accountId === UNSIGNED_ACCOUNT) throw new CliError("login_required", "Unsigned drafts cannot bind a Project or authorize work");
96
+ }
97
+
98
+ rememberLogin(token) {
99
+ this.requireAccount();
100
+ withStoreLock(this.file, () => {
101
+ const store = this.#read();
102
+ store.logins ??= {};
103
+ store.logins[createHash("sha256").update(JSON.stringify([this.origin, token])).digest("hex")] = this.accountId;
104
+ writeStore(this.file, store);
105
+ });
106
+ }
107
+
108
+ #read() { return readRegistry(this.file); }
109
+
110
+ #update(change) {
111
+ return withStoreLock(this.file, () => {
112
+ const store = this.#read();
113
+ const row = store.folders[this.key] ?? { origin: this.origin, accountId: this.accountId, folder: this.folder };
114
+ if (row.folder.device !== this.folder.device || row.folder.inode !== this.folder.inode) {
115
+ throw new CliError("folder_replaced", "This directory was replaced. Unlink its old identity before continuing.");
116
+ }
117
+ const result = change(row, store);
118
+ store.folders[this.key] = row;
119
+ writeStore(this.file, store);
120
+ return result;
121
+ });
122
+ }
123
+
124
+ binding() {
125
+ if (this.accountId === UNSIGNED_ACCOUNT) return null;
126
+ const entries = Object.values(this.#read().folders).filter(row =>
127
+ row.origin === this.origin && row.accountId === this.accountId && (row.projectId || row.unlinked)
128
+ && contains(row.folder.path, this.folder.path));
129
+ entries.sort((a, b) => b.folder.path.length - a.folder.path.length);
130
+ const row = entries[0];
131
+ if (!row || row.unlinked) return null;
132
+ let current;
133
+ try { current = identity(row.folder.path); } catch { return { ...row, state: "folder_missing" }; }
134
+ return { ...row, state: current.device === row.folder.device && current.inode === row.folder.inode
135
+ ? "bound" : "folder_replaced" };
136
+ }
137
+
138
+ broadRoot() { return broadRoot(this.folder.path, this.home); }
139
+
140
+ bind(projectId, { confirmHome = false, confirmBroad = confirmHome } = {}) {
141
+ this.requireAccount();
142
+ if (typeof projectId !== "string" || !projectId) throw new CliError("project_invalid", "Choose a Project first");
143
+ const broad = this.broadRoot();
144
+ if (broad && !confirmBroad) {
145
+ throw new CliError(broad === "home" ? "home_binding_confirmation" : "root_binding_confirmation",
146
+ `Linking this ${broad} applies to its descendants. Type ${this.folder.path} to confirm.`);
147
+ }
148
+ return this.#update(row => {
149
+ if (row.pendingCreate || row.pendingRun) {
150
+ throw new CliError("operation_unresolved", "Resolve the pending submission before relinking");
151
+ }
152
+ row.projectId = projectId;
153
+ delete row.unlinked;
154
+ delete row.pendingCreate;
155
+ });
156
+ }
157
+
158
+ unlink() {
159
+ return withStoreLock(this.file, () => {
160
+ const store = this.#read();
161
+ const row = store.folders[this.key];
162
+ if (row?.pendingCreate || row?.pendingRun) throw new CliError("operation_unresolved",
163
+ "Reconcile the pending submission before unlinking; its identity must survive");
164
+ store.folders[this.key] = { ...row, origin: this.origin, accountId: this.accountId, folder: this.folder, unlinked: true };
165
+ delete store.folders[this.key].projectId;
166
+ writeStore(this.file, store);
167
+ });
168
+ }
169
+
170
+ reserveCreation(name, workspaceId) {
171
+ this.requireAccount();
172
+ return this.#update(row => {
173
+ if (row.projectId) return { projectId: row.projectId };
174
+ // Both first senders share the already frozen payload, even if their asks differ.
175
+ row.pendingCreate ??= { key: randomUUID(), name, workspaceId };
176
+ return structuredClone(row.pendingCreate);
177
+ });
178
+ }
179
+
180
+ completeCreation(key, projectId) {
181
+ this.#update(row => {
182
+ if (row.projectId === projectId && !row.pendingCreate) return;
183
+ if (row.pendingCreate?.key !== key || typeof projectId !== "string" || !projectId) {
184
+ throw new CliError("creation_mismatch", "Project creation identity changed");
185
+ }
186
+ row.projectId = projectId;
187
+ delete row.unlinked;
188
+ delete row.pendingCreate;
189
+ });
190
+ }
191
+
192
+ state() {
193
+ const row = this.#read().folders[this.key];
194
+ if (row && (row.folder.device !== this.folder.device || row.folder.inode !== this.folder.inode)) {
195
+ throw new CliError("folder_replaced", "Directory identity changed; /unlink acknowledges the new folder. Pending submissions must be resolved from the original folder.");
196
+ }
197
+ const drafts = Object.entries(row?.drafts ?? {}).map(([id, value]) => ({ id, ...value }))
198
+ .sort((a, b) => b.at - a.at);
199
+ return { draft: this.lastDraft, drafts,
200
+ pendingCreate: row?.pendingCreate ?? null, pendingRun: row?.pendingRun ?? null };
201
+ }
202
+
203
+ transferDraftTo(destination, text) {
204
+ destination.requireAccount();
205
+ if (this.accountId !== UNSIGNED_ACCOUNT || destination.origin !== this.origin
206
+ || destination.folder.path !== this.folder.path) throw new CliError("draft_scope_invalid", "Draft transfer scope differs");
207
+ this.saveDraft(text);
208
+ destination.newDraft(); destination.saveDraft(text);
209
+ this.saveDraft(""); // Conditional revision consumption preserves a concurrent newer source edit.
210
+ }
211
+
212
+ newDraft() { this.draftRef = null; this.lastDraft = ""; }
213
+
214
+ selectDraft(id) {
215
+ const selected = this.state().drafts.find(row => row.id === id);
216
+ if (!selected) throw new CliError("draft_unavailable", "This saved draft changed; restart to reload it");
217
+ this.draftRef = { id, revision: selected.revision }; this.lastDraft = selected.text;
218
+ return selected.text;
219
+ }
220
+
221
+ saveDraft(text) {
222
+ if (typeof text !== "string" || Buffer.byteLength(text) > MAX_DRAFT_BYTES) {
223
+ throw new CliError("draft_too_large", `Draft exceeds ${MAX_DRAFT_BYTES} bytes; attach a file instead`);
224
+ }
225
+ if (text === this.lastDraft) return;
226
+ const next = this.#update(row => {
227
+ row.drafts ??= {};
228
+ const matches = this.draftRef && row.drafts[this.draftRef.id]?.revision === this.draftRef.revision;
229
+ if (!text) {
230
+ if (matches) delete row.drafts[this.draftRef.id];
231
+ return null;
232
+ }
233
+ if (!matches && Object.keys(row.drafts).length >= 20) throw new CliError("drafts_full",
234
+ "20 saved drafts already exist. Recover and clear an older draft before saving another.");
235
+ const id = matches ? this.draftRef.id : randomUUID(), revision = randomUUID();
236
+ row.drafts[id] = { text, revision, at: Date.now() };
237
+ return { id, revision };
238
+ });
239
+ this.draftRef = next; this.lastDraft = text;
240
+ }
241
+
242
+ reserveRun(projectId, body, draftRef = this.draftRef) {
243
+ this.requireAccount();
244
+ return this.#update(row => {
245
+ if (row.pendingRun) throw new CliError("submission_pending", "Another submission needs reconciliation before a new ask");
246
+ row.pendingRun = { key: randomUUID(), projectId, body: structuredClone(body), draftRef: structuredClone(draftRef) };
247
+ return structuredClone(row.pendingRun);
248
+ });
249
+ }
250
+
251
+ finishRunAdmission(key) {
252
+ this.#update(row => {
253
+ if (!row.pendingRun) return; // Another terminal may have persisted the same validated ACK.
254
+ if (row.pendingRun?.key !== key) throw new CliError("submission_mismatch", "Submission identity changed");
255
+ const ref = row.pendingRun.draftRef;
256
+ if (ref && row.drafts?.[ref.id]?.revision === ref.revision) delete row.drafts[ref.id];
257
+ if (ref && this.draftRef?.id === ref.id && this.draftRef.revision === ref.revision) {
258
+ this.lastDraft = ""; this.draftRef = null;
259
+ }
260
+ delete row.pendingRun;
261
+ });
262
+ }
263
+
264
+ rejectRunAdmission(key) {
265
+ this.#update(row => {
266
+ if (row.pendingRun?.key !== key) throw new CliError("submission_mismatch", "Submission identity changed");
267
+ delete row.pendingRun;
268
+ });
269
+ }
270
+ }
package/src/format.js CHANGED
@@ -4,6 +4,10 @@ import {
4
4
  } from "./contract.js";
5
5
  import { CliError } from "./errors.js";
6
6
 
7
+ /** Terminal presentation only; exact copy and JSON paths retain the original bytes. */
8
+ export const terminalText = value => String(value ?? "").replace(/[\x00-\x08\x0b\x0c\x0d\x0e-\x1f\x7f-\x9f]/g,
9
+ char => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`).replace(/\t/g, " ");
10
+
7
11
  export function canonicalJson(value) {
8
12
  validateNumbers(value);
9
13
  return `${JSON.stringify(sortValue(value))}\n`;
@@ -241,11 +245,11 @@ export function assumptionLines(events) {
241
245
  });
242
246
  }
243
247
 
244
- function shellQuote(value) {
248
+ export function shellQuote(value) {
245
249
  return `'${String(value).replaceAll("'", `'"'"'`)}'`;
246
250
  }
247
251
 
248
- function safeArtifactName(artifact) {
252
+ export function safeArtifactName(artifact) {
249
253
  const leaf = artifact.filename.replaceAll("\\", "/").split("/").at(-1);
250
254
  if (!leaf || leaf === "." || leaf === ".." || leaf.startsWith("-") || leaf.startsWith(".")) {
251
255
  return `artifact-${artifact.cas_ref.slice("sha256:".length, "sha256:".length + 12)}`;
@@ -345,7 +349,8 @@ export function currentTransferTables(events) {
345
349
  });
346
350
  }
347
351
 
348
- export function humanRun(value, { projectId, cost, currentTransferIds } = {}) {
352
+ /** One projection of a Run record; plain text and the rich terminal both render these sections. */
353
+ export function runSections(value, { projectId, currentTransferIds } = {}) {
349
354
  const terminal = value.terminal === "" ? null : terminalPresentation(value);
350
355
  const origins = value.events.flatMap(event => event.kind === "step_finished"
351
356
  ? inputLines(event.payload?.step?.inputs ?? event.payload?.inputs) : []);
@@ -353,42 +358,47 @@ export function humanRun(value, { projectId, cost, currentTransferIds } = {}) {
353
358
  if (!artifacts.every(validateArtifact)) {
354
359
  throw new CliError("artifact_invalid", "Run carries malformed artifact metadata");
355
360
  }
356
- const tasks = taskLines(value.events);
357
- const assumptions = assumptionLines(value.events);
358
- const currentTables = currentTransferTables(value.events).filter(projection =>
359
- currentTransferIds?.has(projection.projection_id));
360
- const lines = [];
361
- if (terminal?.answer !== undefined) lines.push(terminal.answer, "");
362
- if (tasks.length) lines.push("Steps:", ...tasks, "");
363
- if (origins.length) lines.push("Inputs and origins:", ...origins, "");
364
- if (assumptions.length) lines.push("Assumptions:", ...assumptions, "");
365
- for (const projection of currentTables) {
361
+ const project = projectId ? ` --project ${shellQuote(projectId)}` : "";
362
+ const tables = currentTransferTables(value.events).filter(projection =>
363
+ currentTransferIds?.has(projection.projection_id)).map(projection => {
366
364
  if (typeof projection.search_status !== "string" || projection.origin !== "unattributed"
367
365
  || typeof projection.validation_status !== "string" || typeof projection.withheld !== "boolean") {
368
366
  throw new CliError("projection_invalid", "Transfer table metadata is malformed");
369
367
  }
370
- lines.push(`Transfer table: ${plainLine(projection.search_status)} · input origins ${projection.origin}`
368
+ return [`Transfer table: ${plainLine(projection.search_status)} · input origins ${projection.origin}`
371
369
  + ` · validation ${plainLine(projection.validation_status)}`
372
- + ` · path ${projection.withheld ? "withheld" : "shown"}`);
373
- const project = projectId ? ` --project ${shellQuote(projectId)}` : "";
374
- lines.push(` vecteur projection get ${shellQuote(value.run_id)} ${shellQuote(projection.projection_id)}`
375
- + ` --output ${shellQuote("transfer-table.json")}${project}`, "");
370
+ + ` · path ${projection.withheld ? "withheld" : "shown"}`,
371
+ ` vecteur projection get ${shellQuote(value.run_id)} ${shellQuote(projection.projection_id)}`
372
+ + ` --output ${shellQuote("transfer-table.json")}${project}`];
373
+ });
374
+ const tail = [`Run: ${plainLine(value.run_id)}`];
375
+ if (terminal) {
376
+ tail.push(`Terminal: ${terminal.state}`);
377
+ if (terminal.defect !== undefined) tail.push(`Cause: ${terminal.defect}`);
378
+ tail.push(...terminal.recovery.map(recoveryLine));
376
379
  }
377
- if (artifacts.length) {
380
+ return { answer: terminal?.answer, state: terminal?.state ?? "", tasks: taskLines(value.events), origins,
381
+ assumptions: assumptionLines(value.events), tables, artifacts: artifacts.map(artifact => ({ artifact,
382
+ command: `vecteur artifact get ${shellQuote(artifact.cas_ref)} --output ${shellQuote(safeArtifactName(artifact))}${project}` })),
383
+ tail };
384
+ }
385
+
386
+ export function humanRun(value, { projectId, cost, currentTransferIds } = {}) {
387
+ const run = runSections(value, { projectId, currentTransferIds });
388
+ const lines = [];
389
+ if (run.answer !== undefined) lines.push(run.answer, "");
390
+ if (run.tasks.length) lines.push("Steps:", ...run.tasks, "");
391
+ if (run.origins.length) lines.push("Inputs and origins:", ...run.origins, "");
392
+ if (run.assumptions.length) lines.push("Assumptions:", ...run.assumptions, "");
393
+ for (const table of run.tables) lines.push(...table, "");
394
+ if (run.artifacts.length) {
378
395
  lines.push("Artifacts:");
379
- for (const artifact of artifacts) {
380
- lines.push(` ${artifact.filename} (${artifact.kind}, ${artifact.id})`);
381
- const project = projectId ? ` --project ${shellQuote(projectId)}` : "";
382
- lines.push(` vecteur artifact get ${shellQuote(artifact.cas_ref)} --output ${shellQuote(safeArtifactName(artifact))}${project}`);
396
+ for (const { artifact, command } of run.artifacts) {
397
+ lines.push(` ${artifact.filename} (${artifact.kind}, ${artifact.id})`, ` ${command}`);
383
398
  }
384
399
  lines.push("");
385
400
  }
386
- lines.push(`Run: ${plainLine(value.run_id)}`);
387
- if (terminal) {
388
- lines.push(`Terminal: ${terminal.state}`);
389
- if (terminal.defect !== undefined) lines.push(`Cause: ${terminal.defect}`);
390
- lines.push(...terminal.recovery.map(recoveryLine));
391
- }
401
+ lines.push(...run.tail);
392
402
  if (cost) lines.push("", humanCost(cost).trimEnd());
393
403
  return `${lines.join("\n")}\n`;
394
404
  }
package/src/input.js ADDED
@@ -0,0 +1,148 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ const START = "\x1b[200~", END = "\x1b[201~";
5
+ const MAX_BYTES = 256 * 1024;
6
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
7
+
8
+ /** Stream decoder shared by both terminal views; paste is always one edit, never a send. */
9
+ export class ComposerInput extends EventEmitter {
10
+ constructor() {
11
+ super(); this.text = ""; this.cursor = 0; this.pending = ""; this.pasting = false;
12
+ this.decoder = new StringDecoder("utf8"); this.pasted = "";
13
+ this.undo = []; this.killed = ""; this.focusEpoch = 0;
14
+ }
15
+ set(text) { this.text = text; this.cursor = text.length; this.emit("change", this.text); }
16
+ checkpoint() {
17
+ if (this.undo.at(-1)?.text === this.text) return;
18
+ this.undo.push({ text: this.text, cursor: this.cursor });
19
+ if (this.undo.length > 12) this.undo.shift();
20
+ }
21
+ restore() {
22
+ const previous = this.undo.pop();
23
+ if (previous) { Object.assign(this, previous); this.emit("change", this.text); }
24
+ }
25
+ lineStart() { return this.cursor === 0 ? 0 : this.text.lastIndexOf("\n", this.cursor - 1) + 1; }
26
+ lineEnd() { const end = this.text.indexOf("\n", this.cursor); return end < 0 ? this.text.length : end; }
27
+ replace(from, to, value = "", cut = false) {
28
+ this.checkpoint(); if (cut) this.killed = this.text.slice(from, to);
29
+ this.text = this.text.slice(0, from) + value + this.text.slice(to); this.cursor = from + value.length;
30
+ this.emit("change", this.text);
31
+ }
32
+ word(direction) {
33
+ if (direction < 0) return this.text.slice(0, this.cursor).search(/[^\s]*\s*$/u);
34
+ return this.cursor + (/^\s*\S*/u.exec(this.text.slice(this.cursor))?.[0].length ?? 0);
35
+ }
36
+ vertical(direction) {
37
+ const start = this.lineStart(), end = this.lineEnd(), column = this.cursor - start;
38
+ if (direction < 0 && start > 0) {
39
+ const previous = this.text.lastIndexOf("\n", start - 2) + 1;
40
+ this.cursor = Math.min(start - 1, previous + column);
41
+ } else if (direction > 0 && end < this.text.length) {
42
+ const nextEnd = this.text.indexOf("\n", end + 1);
43
+ this.cursor = Math.min(nextEnd < 0 ? this.text.length : nextEnd, end + 1 + column);
44
+ } else { this.emit("history", direction); return; }
45
+ // Clamp vertical movement to a grapheme boundary, never inside a surrogate/emoji.
46
+ const boundary = [...[...graphemes.segment(this.text)].map(row => row.index), this.text.length].findLast(index => index <= this.cursor);
47
+ this.cursor = boundary ?? 0;
48
+ }
49
+ boundary(direction) {
50
+ if (this.segmentedText !== this.text) {
51
+ this.segmentedText = this.text;
52
+ this.boundaries = [...graphemes.segment(this.text)].map(item => item.index);
53
+ this.boundaries.push(this.text.length);
54
+ }
55
+ if (direction < 0) return this.boundaries.findLast(index => index < this.cursor) ?? 0;
56
+ return this.boundaries.find(index => index > this.cursor) ?? this.text.length;
57
+ }
58
+ insert(text) {
59
+ const value = this.text.slice(0, this.cursor) + text + this.text.slice(this.cursor);
60
+ if (Buffer.byteLength(value) > MAX_BYTES) { this.emit("notice", "Draft is too large; attach a file instead."); return; }
61
+ this.checkpoint();
62
+ this.text = value; this.cursor += text.length; this.emit("change", this.text);
63
+ }
64
+ feed(bytes) {
65
+ clearTimeout(this.escapeTimer);
66
+ const epoch = this.focusEpoch;
67
+ this.pending += typeof bytes === "string" ? bytes : this.decoder.write(bytes);
68
+ while (this.pending) {
69
+ if (this.pasting) {
70
+ const end = this.pending.indexOf(END);
71
+ if (end < 0) {
72
+ const take = Math.max(0, this.pending.length - END.length + 1);
73
+ this.pasted += this.pending.slice(0, take); this.pending = this.pending.slice(take);
74
+ if (Buffer.byteLength(this.pasted) > MAX_BYTES) { this.pasted = ""; this.pasteOverflow = true; }
75
+ return;
76
+ }
77
+ this.pasted += this.pending.slice(0, end); this.pending = this.pending.slice(end + END.length);
78
+ if (!this.pasteOverflow) this.insert(this.pasted.replace(/\r\n?/g, "\n"));
79
+ else this.emit("notice", "Paste exceeds the draft limit; attach the file instead.");
80
+ this.pasted = ""; this.pasting = false; this.pasteOverflow = false; continue;
81
+ }
82
+ if (this.pending.startsWith(START)) { this.pasting = true; this.pending = this.pending.slice(START.length); continue; }
83
+ if (this.pending.startsWith("\x1b")) {
84
+ if (this.pending.startsWith("\x1b\x1b")) {
85
+ // Escape followed by an arrow/Alt-Enter keeps that encoded key intact.
86
+ const encodedTail = /^\x1b\x1b(?:\[|\r|\n)/.test(this.pending);
87
+ this.pending = this.pending.slice(encodedTail ? 1 : 2);
88
+ if (epoch === this.focusEpoch) {
89
+ this.emit("cancel"); if (!encodedTail && epoch === this.focusEpoch) this.emit("cancel");
90
+ }
91
+ continue;
92
+ }
93
+ if (this.pending.startsWith("\x1b\r") || this.pending.startsWith("\x1b\n")) {
94
+ this.pending = this.pending.slice(2); this.insert("\n"); continue;
95
+ }
96
+ if (/^\x1b[bf]/.test(this.pending)) {
97
+ const direction = this.pending[1] === "b" ? -1 : 1;
98
+ this.pending = this.pending.slice(2); this.cursor = this.word(direction); this.emit("change", this.text); continue;
99
+ }
100
+ const escape = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(this.pending)?.[0];
101
+ if (escape) {
102
+ this.pending = this.pending.slice(escape.length);
103
+ if (this.handleKey?.(escape)) continue;
104
+ if (escape === "\x1b[D") this.cursor = this.boundary(-1);
105
+ else if (escape === "\x1b[C") this.cursor = this.boundary(1);
106
+ else if (["\x1b[H", "\x1b[1~"].includes(escape)) this.cursor = this.lineStart();
107
+ else if (["\x1b[F", "\x1b[4~"].includes(escape)) this.cursor = this.lineEnd();
108
+ else if (escape === "\x1b[3~") this.replace(this.cursor, this.boundary(1));
109
+ else if (escape === "\x1b[A") this.vertical(-1);
110
+ else if (escape === "\x1b[B") this.vertical(1);
111
+ else if (escape === "\x1b[13;2u" || escape === "\x1b[27;2;13~") this.insert("\n");
112
+ this.emit("change", this.text); continue;
113
+ }
114
+ this.escapeTimer = setTimeout(() => {
115
+ if (this.pending === "\x1b") { this.pending = ""; if (epoch === this.focusEpoch) this.emit("cancel"); }
116
+ else { this.pending = ""; this.emit("notice", "Unsupported terminal key sequence"); }
117
+ }, 40);
118
+ return;
119
+ }
120
+ const char = String.fromCodePoint(this.pending.codePointAt(0)); this.pending = this.pending.slice(char.length);
121
+ if (epoch !== this.focusEpoch && /[\r\x03\x04]/.test(char)) continue;
122
+ if (this.handleKey?.(char)) continue;
123
+ if (char === "\r") {
124
+ if (this.text[this.cursor - 1] === "\\") this.replace(this.cursor - 1, this.cursor, "\n");
125
+ else this.emit("submit", this.text);
126
+ }
127
+ else if (char === "\n") this.insert("\n");
128
+ else if (char === "\x03") this.emit("interrupt");
129
+ else if (char === "\x04") { if (!this.text) this.emit("exit"); else this.replace(this.cursor, this.boundary(1)); }
130
+ else if (char === "\x0f") this.emit("details");
131
+ else if (char === "\x15") this.replace(this.lineStart(), this.cursor, "", true);
132
+ else if (char === "\x0b") this.replace(this.cursor, this.lineEnd(), "", true);
133
+ else if (char === "\x17") this.replace(this.word(-1), this.cursor, "", true);
134
+ else if (char === "\x19") this.insert(this.killed);
135
+ else if (char === "\x1f") this.restore();
136
+ else if (char === "\x12") this.emit("search");
137
+ else if (char === "\x0c") this.emit("redraw");
138
+ else if (char === "\x01") { this.cursor = this.lineStart(); this.emit("change", this.text); }
139
+ else if (char === "\x05") { this.cursor = this.lineEnd(); this.emit("change", this.text); }
140
+ else if (char === "\x7f" || char === "\b") {
141
+ const next = this.boundary(-1);
142
+ this.replace(next, this.cursor);
143
+ } else if (char === "\t") { if (this.listenerCount("complete")) this.emit("complete"); else this.insert(" "); }
144
+ else if (char >= " " && char !== "\x7f") this.insert(char);
145
+ }
146
+ }
147
+ close() { clearTimeout(this.escapeTimer); }
148
+ }