@nanhara/hara 0.122.6 → 0.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export const TASK_SCHEMA_VERSION = 1;
3
+ export const MAX_TASK_OBJECTIVE_CHARS = 4096;
4
+ export const MAX_TASK_STEERING_CHARS = 4096;
5
+ export const MAX_TASK_STEERING_ENTRIES = 24;
6
+ function iso(at = new Date()) {
7
+ return typeof at === "string" ? at : at.toISOString();
8
+ }
9
+ function boundedText(value, max) {
10
+ const normalized = value.replace(/\r\n?/g, "\n").trim();
11
+ return (normalized || "(image-only task)").slice(0, max);
12
+ }
13
+ function validId(value) {
14
+ return typeof value === "string" && value.length > 0 && value.length <= 220 && !/[\\/\0]/.test(value);
15
+ }
16
+ function validTimestamp(value) {
17
+ return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
18
+ }
19
+ export function newTurnInteraction() {
20
+ return { kind: "turn", turnId: randomUUID() };
21
+ }
22
+ export function newSteerInteraction(expectedTurnId) {
23
+ return { kind: "steer", expectedTurnId, turnId: randomUUID() };
24
+ }
25
+ export function createTaskExecution(objective, turnId, at = new Date()) {
26
+ const now = iso(at);
27
+ return {
28
+ schemaVersion: TASK_SCHEMA_VERSION,
29
+ id: randomUUID(),
30
+ objective: boundedText(objective, MAX_TASK_OBJECTIVE_CHARS),
31
+ status: "running",
32
+ turnId,
33
+ createdAt: now,
34
+ updatedAt: now,
35
+ startedAt: now,
36
+ };
37
+ }
38
+ export function continueTaskExecution(task, interaction, at = new Date()) {
39
+ if (!task)
40
+ return { ok: false, reason: "there is no task to steer" };
41
+ if (task.turnId !== interaction.expectedTurnId) {
42
+ return { ok: false, reason: `stale steer for turn ${interaction.expectedTurnId}; active turn is ${task.turnId}` };
43
+ }
44
+ const now = iso(at);
45
+ return {
46
+ ok: true,
47
+ task: {
48
+ ...task,
49
+ status: "running",
50
+ turnId: interaction.turnId,
51
+ updatedAt: now,
52
+ startedAt: now,
53
+ endedAt: undefined,
54
+ lastOutcome: undefined,
55
+ },
56
+ };
57
+ }
58
+ export function recordTaskSteering(task, expectedTurnId, content, at = new Date()) {
59
+ if (!task)
60
+ return { ok: false, reason: "there is no running task to steer" };
61
+ if (task.status !== "running")
62
+ return { ok: false, reason: `task ${task.id} is ${task.status}, not running` };
63
+ if (task.turnId !== expectedTurnId) {
64
+ return { ok: false, reason: `stale steer for turn ${expectedTurnId}; active turn is ${task.turnId}` };
65
+ }
66
+ const now = iso(at);
67
+ const steering = [
68
+ ...(task.steering ?? []),
69
+ { id: randomUUID(), turnId: expectedTurnId, content: boundedText(content, MAX_TASK_STEERING_CHARS), createdAt: now },
70
+ ].slice(-MAX_TASK_STEERING_ENTRIES);
71
+ return { ok: true, task: { ...task, steering, updatedAt: now } };
72
+ }
73
+ export function finishTaskExecution(task, outcome, todos = [], interrupted = false, at = new Date()) {
74
+ if (!task)
75
+ return undefined;
76
+ const now = iso(at);
77
+ const incomplete = todos.some((todo) => todo.status !== "done");
78
+ const lastOutcome = interrupted ? "interrupted" : (outcome?.status ?? "interrupted");
79
+ const status = interrupted
80
+ ? "paused"
81
+ : outcome?.status === "completed"
82
+ ? (incomplete ? "paused" : "completed")
83
+ : outcome?.status === "error" || outcome?.status === "empty" || outcome?.status === "halted"
84
+ ? "blocked"
85
+ : "paused";
86
+ return { ...task, status, lastOutcome, updatedAt: now, endedAt: now };
87
+ }
88
+ /** A process died while this task was running. Recovery is explicit and never claims success. */
89
+ export function recoverTaskExecution(task, at = new Date()) {
90
+ if (!task || task.status !== "running")
91
+ return task;
92
+ const now = iso(at);
93
+ return { ...task, status: "paused", lastOutcome: "interrupted", updatedAt: now, endedAt: now };
94
+ }
95
+ export function forkTaskExecution(task, at = new Date()) {
96
+ if (!task)
97
+ return undefined;
98
+ const now = iso(at);
99
+ return {
100
+ ...task,
101
+ id: randomUUID(),
102
+ status: "paused",
103
+ turnId: randomUUID(),
104
+ createdAt: now,
105
+ updatedAt: now,
106
+ startedAt: now,
107
+ endedAt: now,
108
+ lastOutcome: "interrupted",
109
+ steering: task.steering?.slice(-MAX_TASK_STEERING_ENTRIES).map((entry) => ({ ...entry })),
110
+ };
111
+ }
112
+ export function taskExecutionContext(task, interaction) {
113
+ const steeringNote = interaction.kind === "steer"
114
+ ? "This interaction steers the existing task. Refine execution without replacing its objective."
115
+ : "This interaction created a new task.";
116
+ return [
117
+ "# Task execution (authoritative; separate from conversation history)",
118
+ `Task ID: ${task.id}`,
119
+ `Turn ID: ${task.turnId}`,
120
+ `Objective: ${task.objective}`,
121
+ `Interaction: ${interaction.kind}`,
122
+ steeringNote,
123
+ "Conversation messages provide evidence and refinements, but the task objective above remains authoritative until an explicit new task starts.",
124
+ ].join("\n");
125
+ }
126
+ export function formatTaskExecution(task) {
127
+ if (!task)
128
+ return "(no task state)";
129
+ return [
130
+ `task ${task.id.slice(0, 8)} · ${task.status}`,
131
+ `turn ${task.turnId.slice(0, 8)} · outcome ${task.lastOutcome ?? "running"}`,
132
+ `objective: ${task.objective}`,
133
+ `steering: ${task.steering?.length ?? 0}`,
134
+ ].join("\n");
135
+ }
136
+ export function isTaskExecution(value) {
137
+ if (!value || typeof value !== "object" || Array.isArray(value))
138
+ return false;
139
+ const task = value;
140
+ if (task.schemaVersion !== TASK_SCHEMA_VERSION ||
141
+ !validId(task.id) ||
142
+ typeof task.objective !== "string" || task.objective.length === 0 || task.objective.length > MAX_TASK_OBJECTIVE_CHARS ||
143
+ (task.status !== "running" && task.status !== "paused" && task.status !== "completed" && task.status !== "blocked") ||
144
+ !validId(task.turnId) ||
145
+ !validTimestamp(task.createdAt) || !validTimestamp(task.updatedAt) || !validTimestamp(task.startedAt) ||
146
+ (task.endedAt !== undefined && !validTimestamp(task.endedAt)) ||
147
+ (task.lastOutcome !== undefined && task.lastOutcome !== "completed" && task.lastOutcome !== "error" && task.lastOutcome !== "empty" && task.lastOutcome !== "halted" && task.lastOutcome !== "interrupted"))
148
+ return false;
149
+ if (task.steering === undefined)
150
+ return true;
151
+ if (!Array.isArray(task.steering) || task.steering.length > MAX_TASK_STEERING_ENTRIES)
152
+ return false;
153
+ return task.steering.every((entry) => {
154
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
155
+ return false;
156
+ const steering = entry;
157
+ return validId(steering.id) && validId(steering.turnId) &&
158
+ typeof steering.content === "string" && steering.content.length > 0 && steering.content.length <= MAX_TASK_STEERING_CHARS &&
159
+ validTimestamp(steering.createdAt);
160
+ });
161
+ }
package/dist/tui/App.js CHANGED
@@ -20,6 +20,7 @@ import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
20
20
  import { onTurnPhase, turnPhase } from "../agent/phase.js";
21
21
  import { listJobs, onJobsChange } from "../exec/jobs.js";
22
22
  import { ModelPicker } from "./model-picker.js";
23
+ import { newSteerInteraction, newTurnInteraction } from "../session/task.js";
23
24
  let _id = 0;
24
25
  const nid = () => ++_id;
25
26
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -289,7 +290,7 @@ function StatusRow({ working, todos, queued }) {
289
290
  // Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
290
291
  // generic "working" when the network is slow — the user knows the request is out, not dead.
291
292
  const verb = (phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec)) + bgTag;
292
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ queues${queued ? ` (${queued})` : ""}` })] }));
293
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ steers task${queued ? ` (${queued})` : ""}` })] }));
293
294
  }
294
295
  // Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
295
296
  // don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
@@ -366,6 +367,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
366
367
  const [todos, setTodos] = useState(() => currentTodos());
367
368
  const ctrlRef = useRef(null);
368
369
  const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
370
+ const activeTurnRef = useRef(null); // interaction identity survives React render timing
369
371
  const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
370
372
  const drainingRef = useRef(false); // idempotency guard so the drain effect can't double-send one item
371
373
  const statusRef = useRef(status);
@@ -478,7 +480,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
478
480
  }
479
481
  ctrlRef.current?.abort();
480
482
  }, []);
481
- const handleSubmit = useCallback(async (line, images) => {
483
+ const handleSubmit = useCallback(async (line, images, forcedInteraction) => {
482
484
  const t = line.trim();
483
485
  // A free-text question (ask_user) is awaiting an answer: this submission IS the answer, not a new turn.
484
486
  if (askText) {
@@ -490,15 +492,21 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
490
492
  if ((!t && !images?.length) || prompt)
491
493
  return; // nothing to send, or a choice is pending
492
494
  if (working) {
493
- // type-ahead: hold the message in the pool; all pooled messages are sent together when the turn ends
494
- queueRef.current.push({ line, images });
495
+ // Bind every type-ahead message to the exact live turn. A stale message must never silently become
496
+ // a new task after another turn starts (Codex's expected_turn_id steering invariant).
497
+ const expectedTurnId = activeTurnRef.current;
498
+ if (!expectedTurnId)
499
+ return;
500
+ queueRef.current.push({ line, images, expectedTurnId });
495
501
  setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
496
502
  return;
497
503
  }
504
+ const interaction = forcedInteraction ?? newTurnInteraction();
505
+ activeTurnRef.current = interaction.turnId;
498
506
  // Fold the previous turn's checklist NOW, at the natural boundary (a new task begins). The old
499
507
  // 30s-idle timer yanked the input box UP by the panel's height while the user was reading/typing
500
508
  // (anti-bob); folding on submit means the shrink coincides with the user's own action.
501
- if (currentTodos().length) {
509
+ if (interaction.kind === "turn" && currentTodos().length) {
502
510
  const list = currentTodos();
503
511
  const done = list.filter((td) => td.status === "done").length;
504
512
  setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
@@ -609,7 +617,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
609
617
  // so pressing Enter leaves the message stuck in the input box for seconds ("回车一直不动"). One tick.
610
618
  await new Promise((resolve) => setTimeout(resolve, 0));
611
619
  try {
612
- await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, pickModel: pickModelFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
620
+ await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, pickModel: pickModelFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images, interaction);
613
621
  }
614
622
  catch (e) {
615
623
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
@@ -630,9 +638,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
630
638
  setCurrent([]);
631
639
  setWorking(false);
632
640
  ctrlRef.current = null;
641
+ if (activeTurnRef.current === interaction.turnId)
642
+ activeTurnRef.current = null;
633
643
  }, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
634
- // Drain the type-ahead pool: when the turn finishes (working false) and nothing awaits a choice, COALESCE
635
- // every pooled message into ONE turn and send it additions/clarifications go to the agent together, in order.
644
+ // A message can arrive after runAgent's final pending-input drain but before the view flips to idle. Keep
645
+ // its original expectedTurnId and run it as a STEER continuation of that task never as an ordinary new
646
+ // task inferred from queue timing.
636
647
  useEffect(() => {
637
648
  if (working || prompt || askText || drainingRef.current || !queueRef.current.length)
638
649
  return;
@@ -642,7 +653,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
642
653
  setPool([]);
643
654
  const line = batch.map((b) => b.line).join("\n\n");
644
655
  const images = batch.flatMap((b) => b.images ?? []);
645
- void Promise.resolve(handleSubmit(line, images.length ? images : undefined)).finally(() => {
656
+ const expectedTurnId = batch[0].expectedTurnId;
657
+ void Promise.resolve(handleSubmit(line, images.length ? images : undefined, newSteerInteraction(expectedTurnId))).finally(() => {
646
658
  drainingRef.current = false;
647
659
  });
648
660
  }, [working, prompt, askText, handleSubmit]);
@@ -319,19 +319,40 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
319
319
  const [mode, setMode] = useState("insert"); // vim only
320
320
  const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
321
321
  const [register, setRegister] = useState(""); // vim yank/delete register
322
+ // Ink may emit several logical inputs from one OS chunk (notably bracketed paste followed by Enter)
323
+ // inside one React batch. Refs are the authoritative event-time draft; state remains the render copy.
324
+ // Without this split, the second callback observes the previous render and can submit an empty draft.
325
+ const valueRef = useRef(value);
326
+ const cursorRef = useRef(cursor);
327
+ const imagesRef = useRef(images);
328
+ const pastesRef = useRef(pastes);
329
+ valueRef.current = value;
330
+ cursorRef.current = cursor;
331
+ imagesRef.current = images;
332
+ pastesRef.current = pastes;
322
333
  const historyRef = useRef(null);
323
334
  if (!historyRef.current)
324
335
  historyRef.current = new ComposerHistory();
325
336
  const inputHistory = historyRef.current;
326
337
  const set = (v, c) => {
327
338
  inputHistory.abandonNavigation();
339
+ valueRef.current = v;
340
+ cursorRef.current = c;
328
341
  setValue(v);
329
342
  setCursor(c);
330
343
  setSel(0);
331
344
  setDismissed(false);
332
345
  };
333
- const snapshot = () => ({ value, attachments: images, pastes });
346
+ const snapshot = () => ({
347
+ value: valueRef.current,
348
+ attachments: imagesRef.current,
349
+ pastes: pastesRef.current,
350
+ });
334
351
  const restore = (draft) => {
352
+ valueRef.current = draft.value;
353
+ cursorRef.current = draft.value.length;
354
+ imagesRef.current = draft.attachments;
355
+ pastesRef.current = draft.pastes;
335
356
  setValue(draft.value);
336
357
  setCursor(draft.value.length);
337
358
  setImages(draft.attachments);
@@ -343,12 +364,21 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
343
364
  // (codex / Claude-Code style). Backspace over the token removes both.
344
365
  const addImage = (img) => {
345
366
  inputHistory.abandonNavigation();
346
- const tok = `[Image #${images.length + 1}]`;
347
- const before = value.slice(0, cursor);
367
+ const currentValue = valueRef.current;
368
+ const currentCursor = cursorRef.current;
369
+ const currentImages = imagesRef.current;
370
+ const tok = `[Image #${currentImages.length + 1}]`;
371
+ const before = currentValue.slice(0, currentCursor);
348
372
  const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
349
- setValue(before + ins + value.slice(cursor));
350
- setCursor((before + ins).length);
351
- setImages((xs) => [...xs, img]);
373
+ const nextValue = before + ins + currentValue.slice(currentCursor);
374
+ const nextCursor = (before + ins).length;
375
+ const nextImages = [...currentImages, img];
376
+ valueRef.current = nextValue;
377
+ cursorRef.current = nextCursor;
378
+ imagesRef.current = nextImages;
379
+ setValue(nextValue);
380
+ setCursor(nextCursor);
381
+ setImages(nextImages);
352
382
  setSel(0);
353
383
  setDismissed(false);
354
384
  };
@@ -357,23 +387,36 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
357
387
  // can no longer fire the newline-submit path mid-paste. Expanded back to the full text on submit.
358
388
  const addPaste = (text) => {
359
389
  inputHistory.abandonNavigation();
390
+ const currentValue = valueRef.current;
391
+ const currentCursor = cursorRef.current;
392
+ const currentPastes = pastesRef.current;
360
393
  const lines = text.split("\n").length;
361
- const tok = `[Paste #${pastes.length + 1} +${lines} lines]`;
362
- const before = value.slice(0, cursor);
394
+ const tok = `[Paste #${currentPastes.length + 1} +${lines} lines]`;
395
+ const before = currentValue.slice(0, currentCursor);
363
396
  const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
364
- setValue(before + ins + value.slice(cursor));
365
- setCursor((before + ins).length);
366
- setPastes((xs) => [...xs, text]);
397
+ const nextValue = before + ins + currentValue.slice(currentCursor);
398
+ const nextCursor = (before + ins).length;
399
+ const nextPastes = [...currentPastes, text];
400
+ valueRef.current = nextValue;
401
+ cursorRef.current = nextCursor;
402
+ pastesRef.current = nextPastes;
403
+ setValue(nextValue);
404
+ setCursor(nextCursor);
405
+ setPastes(nextPastes);
367
406
  setSel(0);
368
407
  setDismissed(false);
369
408
  };
370
- const expandPastes = (text) => text.replace(/\[Paste #(\d+) \+\d+ lines\]/g, (m, d) => pastes[Number(d) - 1] ?? m);
371
- const submit = (text) => {
372
- if (!text.trim() && images.length === 0)
409
+ const expandPastes = (text) => text.replace(/\[Paste #(\d+) \+\d+ lines\]/g, (m, d) => pastesRef.current[Number(d) - 1] ?? m);
410
+ const submit = () => {
411
+ const text = valueRef.current;
412
+ const currentImages = imagesRef.current;
413
+ if (!text.trim() && currentImages.length === 0)
373
414
  return; // nothing to send
374
415
  inputHistory.record(snapshot());
375
- onSubmit?.(expandPastes(text), images.length ? images : undefined);
416
+ onSubmit?.(expandPastes(text), currentImages.length ? currentImages : undefined);
376
417
  set("", 0);
418
+ imagesRef.current = [];
419
+ pastesRef.current = [];
377
420
  setImages([]);
378
421
  setPastes([]);
379
422
  setMode("insert"); // a fresh prompt starts in insert
@@ -422,7 +465,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
422
465
  // vim NORMAL mode: printable keys are commands, not text (Enter/arrows/backspace still navigate/submit)
423
466
  if (vim && mode === "normal") {
424
467
  if (key.return)
425
- return submit(value);
468
+ return submit();
426
469
  if (key.leftArrow)
427
470
  return setCursor((c) => Math.max(0, c - 1));
428
471
  if (key.rightArrow)
@@ -472,7 +515,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
472
515
  set(value.slice(0, cursor) + "\n" + value.slice(cursor), cursor + 1);
473
516
  return;
474
517
  }
475
- submit(value);
518
+ submit();
476
519
  return;
477
520
  }
478
521
  if (key.leftArrow)
@@ -536,7 +579,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
536
579
  // A lone newline delivered through `input` (some terminals send Enter this way instead of
537
580
  // setting key.return) = the user pressed Enter → submit.
538
581
  if (/^[\r\n]+$/.test(input)) {
539
- submit(value);
582
+ submit();
540
583
  return;
541
584
  }
542
585
  // A PASTE containing newlines inserts as REAL MULTI-LINE TEXT (normalize CRLF/CR → LF) — the
@@ -0,0 +1,265 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ import { Readable } from "node:stream";
3
+ export const BRACKETED_PASTE_START = "\u001b[200~";
4
+ export const BRACKETED_PASTE_END = "\u001b[201~";
5
+ export const ENABLE_BRACKETED_PASTE = "\u001b[?2004h";
6
+ export const DISABLE_BRACKETED_PASTE = "\u001b[?2004l";
7
+ export const MAX_BRACKETED_PASTE_CHARS = 2 * 1024 * 1024;
8
+ export const INCOMPLETE_PASTE_TIMEOUT_MS = 750;
9
+ const pasteTooLargeMessage = (limit) => {
10
+ const size = limit >= 1024 * 1024 ? `${Math.ceil(limit / (1024 * 1024))} MiB` : `${limit} characters`;
11
+ return `[Paste rejected: input exceeds ${size}]`;
12
+ };
13
+ function suffixPrefixLength(value, marker) {
14
+ const max = Math.min(value.length, marker.length - 1);
15
+ for (let length = max; length > 0; length--) {
16
+ if (value.endsWith(marker.slice(0, length)))
17
+ return length;
18
+ }
19
+ return 0;
20
+ }
21
+ /**
22
+ * Stateful decoder for xterm bracketed-paste framing. Terminals are free to split both markers and
23
+ * UTF-8 content across arbitrary stdin chunks, so no individual chunk can be treated as a paste.
24
+ * Completed paste contents are emitted as exactly one logical input event; framing bytes never reach
25
+ * Ink's key parser.
26
+ */
27
+ export class BracketedPasteDecoder {
28
+ maxPasteChars;
29
+ mode = "normal";
30
+ buffer = "";
31
+ constructor(maxPasteChars = MAX_BRACKETED_PASTE_CHARS) {
32
+ this.maxPasteChars = maxPasteChars;
33
+ }
34
+ get hasIncompletePaste() {
35
+ return this.mode !== "normal";
36
+ }
37
+ get hasPendingMarker() {
38
+ return this.mode === "normal" && this.buffer.length > 0;
39
+ }
40
+ feed(input) {
41
+ if (!input)
42
+ return [];
43
+ this.buffer += input;
44
+ const output = [];
45
+ while (this.buffer.length > 0) {
46
+ if (this.mode === "normal") {
47
+ const start = this.buffer.indexOf(BRACKETED_PASTE_START);
48
+ if (start >= 0) {
49
+ if (start > 0)
50
+ output.push(this.buffer.slice(0, start));
51
+ this.buffer = this.buffer.slice(start + BRACKETED_PASTE_START.length);
52
+ this.mode = "paste";
53
+ continue;
54
+ }
55
+ // Retain only a suffix that could become a split start marker. Everything before it is
56
+ // ordinary keyboard input and can be forwarded immediately.
57
+ const retained = suffixPrefixLength(this.buffer, BRACKETED_PASTE_START);
58
+ const ready = this.buffer.slice(0, this.buffer.length - retained);
59
+ if (ready)
60
+ output.push(ready);
61
+ this.buffer = retained ? this.buffer.slice(-retained) : "";
62
+ break;
63
+ }
64
+ const end = this.buffer.indexOf(BRACKETED_PASTE_END);
65
+ if (end >= 0) {
66
+ if (this.mode === "paste" && end <= this.maxPasteChars)
67
+ output.push(this.buffer.slice(0, end));
68
+ else
69
+ output.push(pasteTooLargeMessage(this.maxPasteChars));
70
+ this.buffer = this.buffer.slice(end + BRACKETED_PASTE_END.length);
71
+ this.mode = "normal";
72
+ continue;
73
+ }
74
+ if (this.mode === "paste" && this.buffer.length > this.maxPasteChars) {
75
+ // Fail closed instead of retaining an unbounded terminal stream or silently submitting a
76
+ // truncated source file. Keep only a possible split end marker while discarding the payload.
77
+ const retained = suffixPrefixLength(this.buffer, BRACKETED_PASTE_END);
78
+ this.buffer = retained ? this.buffer.slice(-retained) : "";
79
+ this.mode = "overflow";
80
+ }
81
+ else if (this.mode === "overflow") {
82
+ const retained = suffixPrefixLength(this.buffer, BRACKETED_PASTE_END);
83
+ this.buffer = retained ? this.buffer.slice(-retained) : "";
84
+ }
85
+ break;
86
+ }
87
+ return output;
88
+ }
89
+ /** Recover when a terminal sent paste-start but never sent paste-end. */
90
+ flushIncomplete() {
91
+ if (this.mode === "normal") {
92
+ const pending = this.buffer;
93
+ this.buffer = "";
94
+ return pending ? [pending] : [];
95
+ }
96
+ const output = this.mode === "paste" ? this.buffer : pasteTooLargeMessage(this.maxPasteChars);
97
+ this.mode = "normal";
98
+ this.buffer = "";
99
+ return output ? [output] : [];
100
+ }
101
+ }
102
+ /**
103
+ * Readable proxy passed to Ink. It preserves the real TTY's raw-mode lifecycle while turning each
104
+ * completed bracketed paste into one readable event. Multiple logical events from one OS chunk are
105
+ * emitted on separate event-loop turns so `paste + Enter` cannot be coalesced back into one keypress.
106
+ */
107
+ export class BracketedPasteInput extends Readable {
108
+ source;
109
+ isTTY;
110
+ utf8 = new StringDecoder("utf8");
111
+ decoder;
112
+ queue = [];
113
+ drainImmediate;
114
+ markerImmediate;
115
+ pasteTimer;
116
+ ending = false;
117
+ disposed = false;
118
+ constructor(source, options = {}) {
119
+ super();
120
+ this.source = source;
121
+ this.isTTY = Boolean(source.isTTY);
122
+ this.decoder = new BracketedPasteDecoder(options.maxPasteChars);
123
+ this.incompleteTimeoutMs = options.incompleteTimeoutMs ?? INCOMPLETE_PASTE_TIMEOUT_MS;
124
+ source.on("data", this.onData);
125
+ source.on("end", this.onEnd);
126
+ source.on("error", this.onError);
127
+ }
128
+ incompleteTimeoutMs;
129
+ _read() {
130
+ // Input is pushed by the wrapped terminal stream.
131
+ }
132
+ setRawMode(enabled) {
133
+ this.source.setRawMode?.(enabled);
134
+ return this;
135
+ }
136
+ ref() {
137
+ this.source.ref?.();
138
+ return this;
139
+ }
140
+ unref() {
141
+ this.source.unref?.();
142
+ return this;
143
+ }
144
+ onData = (chunk) => {
145
+ if (this.disposed)
146
+ return;
147
+ this.clearMarkerImmediate();
148
+ const text = Buffer.isBuffer(chunk) ? this.utf8.write(chunk) : String(chunk);
149
+ this.enqueue(this.decoder.feed(text));
150
+ if (this.decoder.hasIncompletePaste) {
151
+ this.resetPasteTimer();
152
+ }
153
+ else {
154
+ this.clearPasteTimer();
155
+ // Like Ink's own input parser, retain a lone/split ESC sequence for only one event-loop turn.
156
+ if (this.decoder.hasPendingMarker) {
157
+ this.markerImmediate = setImmediate(() => {
158
+ this.markerImmediate = undefined;
159
+ this.enqueue(this.decoder.flushIncomplete());
160
+ });
161
+ }
162
+ }
163
+ };
164
+ onEnd = () => {
165
+ if (this.disposed)
166
+ return;
167
+ const tail = this.utf8.end();
168
+ if (tail)
169
+ this.enqueue(this.decoder.feed(tail));
170
+ this.enqueue(this.decoder.flushIncomplete());
171
+ this.ending = true;
172
+ this.finishIfDrained();
173
+ };
174
+ onError = (error) => {
175
+ this.destroy(error);
176
+ };
177
+ resetPasteTimer() {
178
+ this.clearPasteTimer();
179
+ this.pasteTimer = setTimeout(() => {
180
+ this.pasteTimer = undefined;
181
+ this.enqueue(this.decoder.flushIncomplete());
182
+ }, this.incompleteTimeoutMs);
183
+ this.pasteTimer.unref?.();
184
+ }
185
+ enqueue(values) {
186
+ for (const value of values)
187
+ if (value)
188
+ this.queue.push(value);
189
+ if (this.queue.length === 0 || this.drainImmediate)
190
+ return;
191
+ const first = this.queue.shift();
192
+ if (first !== undefined)
193
+ this.push(first);
194
+ if (this.queue.length > 0)
195
+ this.scheduleDrain();
196
+ else
197
+ this.finishIfDrained();
198
+ }
199
+ scheduleDrain() {
200
+ if (this.drainImmediate)
201
+ return;
202
+ this.drainImmediate = setImmediate(() => {
203
+ this.drainImmediate = undefined;
204
+ const next = this.queue.shift();
205
+ if (next !== undefined)
206
+ this.push(next);
207
+ if (this.queue.length > 0)
208
+ this.scheduleDrain();
209
+ else
210
+ this.finishIfDrained();
211
+ });
212
+ }
213
+ finishIfDrained() {
214
+ if (this.ending && this.queue.length === 0 && !this.drainImmediate)
215
+ this.push(null);
216
+ }
217
+ clearPasteTimer() {
218
+ if (this.pasteTimer)
219
+ clearTimeout(this.pasteTimer);
220
+ this.pasteTimer = undefined;
221
+ }
222
+ clearMarkerImmediate() {
223
+ if (this.markerImmediate)
224
+ clearImmediate(this.markerImmediate);
225
+ this.markerImmediate = undefined;
226
+ }
227
+ dispose() {
228
+ if (this.disposed)
229
+ return;
230
+ this.disposed = true;
231
+ this.clearPasteTimer();
232
+ this.clearMarkerImmediate();
233
+ if (this.drainImmediate)
234
+ clearImmediate(this.drainImmediate);
235
+ this.drainImmediate = undefined;
236
+ this.source.off("data", this.onData);
237
+ this.source.off("end", this.onEnd);
238
+ this.source.off("error", this.onError);
239
+ this.push(null);
240
+ }
241
+ }
242
+ /** Enable terminal-side paste framing and return an idempotent cleanup. */
243
+ export function enableBracketedPaste(output) {
244
+ if (!output.isTTY)
245
+ return () => { };
246
+ let enabled = false;
247
+ try {
248
+ output.write(ENABLE_BRACKETED_PASTE);
249
+ enabled = true;
250
+ }
251
+ catch {
252
+ return () => { };
253
+ }
254
+ return () => {
255
+ if (!enabled)
256
+ return;
257
+ enabled = false;
258
+ try {
259
+ output.write(DISABLE_BRACKETED_PASTE);
260
+ }
261
+ catch {
262
+ // Terminal cleanup is best effort; never mask the original TUI exit/error.
263
+ }
264
+ };
265
+ }