aiden-runtime 4.7.0 → 4.8.1

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.
Files changed (36) hide show
  1. package/README.md +12 -1
  2. package/dist/cli/v4/aidenCLI.js +40 -5
  3. package/dist/cli/v4/callbacks.js +52 -31
  4. package/dist/cli/v4/chatSession.js +55 -8
  5. package/dist/cli/v4/commands/help.js +22 -11
  6. package/dist/cli/v4/commands/runs.js +42 -24
  7. package/dist/cli/v4/commands/skills.js +15 -17
  8. package/dist/cli/v4/commands/update.js +14 -2
  9. package/dist/cli/v4/commands/usage.js +17 -5
  10. package/dist/cli/v4/daemonAgentBuilder.js +1 -0
  11. package/dist/cli/v4/design/tokens.js +265 -0
  12. package/dist/cli/v4/display/framedPanel.js +116 -0
  13. package/dist/cli/v4/display/toolTrail.js +2 -2
  14. package/dist/cli/v4/display.js +489 -164
  15. package/dist/cli/v4/onboarding/disclaimer.js +42 -10
  16. package/dist/cli/v4/onboarding/loading.js +24 -1
  17. package/dist/cli/v4/onboarding/successScreen.js +17 -8
  18. package/dist/cli/v4/pasteIntercept.js +214 -70
  19. package/dist/cli/v4/replyRenderer.js +213 -58
  20. package/dist/cli/v4/setupWizard.js +19 -2
  21. package/dist/cli/v4/skinEngine.js +13 -0
  22. package/dist/cli/v4/table.js +65 -8
  23. package/dist/core/v4/aidenAgent.js +23 -0
  24. package/dist/core/v4/auxiliaryClient.js +46 -13
  25. package/dist/core/v4/daemon/dispatcher/realAgentRunner.js +13 -8
  26. package/dist/core/v4/promptBuilder.js +51 -0
  27. package/dist/core/v4/subagent/childBuilder.js +1 -0
  28. package/dist/core/v4/subagent/spawnSubAgent.js +7 -1
  29. package/dist/core/v4/ui/banner.js +16 -16
  30. package/dist/core/v4/update/executeInstall.js +10 -6
  31. package/dist/core/v4/update/installMethodDetect.js +7 -0
  32. package/dist/core/version.js +67 -2
  33. package/dist/moat/approvalEngine.js +14 -0
  34. package/dist/tools/v4/index.js +54 -0
  35. package/dist/tools/v4/subagent/spawnSubAgentTool.js +23 -0
  36. package/package.json +1 -3
@@ -60,9 +60,24 @@ const readline = __importStar(require("node:readline"));
60
60
  const banner_1 = require("../../../core/v4/ui/banner");
61
61
  const theme_1 = require("../../../core/v4/ui/theme");
62
62
  const version_1 = require("../../../core/version");
63
- const DISCLAIMER_PARA = 'Aiden is a semi-autonomous AI agent that can touch your files, ' +
64
- 'browser, and shell. Open source read the code if you want. ' +
65
- 'Started as a hobby project, built solo, still rough in spots.';
63
+ // v4.8.0 Slice 10c replaced the single-paragraph prose with two
64
+ // scannable bullet lists (capability + acknowledgments). Legal terms
65
+ // surfaced as a checklist instead of buried inside prose.
66
+ const DISCLAIMER_HEAD = 'Aiden is an autonomous AI engine that runs on your machine. Aiden can:';
67
+ const CAPABILITY_BULLETS = [
68
+ 'Read, write, and modify files on your computer',
69
+ 'Execute shell commands and run code',
70
+ 'Browse the web and interact with online services',
71
+ 'Connect to AI providers using YOUR API keys (BYOK)',
72
+ 'Generate and execute new skills based on your prompts',
73
+ ];
74
+ const ACK_HEAD = 'By continuing, you acknowledge:';
75
+ const ACK_BULLETS = [
76
+ 'Aiden operates on your behalf with full local-system access',
77
+ 'You are responsible for outcomes of commands you approve',
78
+ 'Open source under AGPL-3.0 — read the code at github.com/taracodlabs/aiden',
79
+ 'This is beta software, built solo, still rough in spots',
80
+ ];
66
81
  /**
67
82
  * Word-wrap `text` to `width` columns. Preserves single spaces; does
68
83
  * not handle ANSI codes (callers pass plain text here).
@@ -97,18 +112,35 @@ function clearScreen(out) {
97
112
  out.write('\x1b[2J\x1b[H');
98
113
  }
99
114
  /**
100
- * Render the disclaimer body — banner + separator + wrapped paragraph.
101
- * Pure renderer; caller owns the write.
115
+ * Render the disclaimer body — v4.8.0 Slice 10c: banner + framed-panel
116
+ * capability list + acknowledgments. Orange `▎` bar on every line of
117
+ * the panel matches the rest of v4.8.0 chrome. `▸` bullets keep
118
+ * capability/ack items scannable rather than buried in prose.
102
119
  */
103
120
  function renderDisclaimerBody(version) {
104
121
  const w = (0, theme_1.termWidth)();
122
+ const innerW = Math.min(w - 4, 70);
105
123
  const body = [];
106
124
  body.push((0, banner_1.renderBanner)({ version }));
107
- body.push(' ' + (0, theme_1.separator)(Math.min(w - 4, 64)) + '\n');
108
- body.push('\n');
109
- const indent = ' ';
110
- for (const line of wrap(DISCLAIMER_PARA, Math.min(w - 4, 70))) {
111
- body.push(indent + theme_1.c.text(line) + '\n');
125
+ // Slice 10c framed-panel chrome. Orange bar at col 2; content + 2
126
+ // inner spaces; muted `─` divider between sections.
127
+ // v4.8.0 Slice 11c — `▎` swapped for `│` (U+2502); same swap as
128
+ // glyphs.panel.bar in the design tokens.
129
+ const bar = theme_1.c.primary('');
130
+ const divider = theme_1.c.muted('─'.repeat(innerW - 2));
131
+ const line = (s) => ` ${bar} ${s}\n`;
132
+ body.push(line(theme_1.c.text(DISCLAIMER_HEAD)));
133
+ body.push(line(''));
134
+ for (const item of CAPABILITY_BULLETS) {
135
+ body.push(line(theme_1.c.muted('▸ ') + theme_1.c.text(item)));
136
+ }
137
+ body.push(line(''));
138
+ body.push(line(divider));
139
+ body.push(line(''));
140
+ body.push(line(theme_1.c.text(ACK_HEAD)));
141
+ body.push(line(''));
142
+ for (const item of ACK_BULLETS) {
143
+ body.push(line(theme_1.c.muted('▸ ') + theme_1.c.text(item)));
112
144
  }
113
145
  body.push('\n');
114
146
  return body.join('');
@@ -86,13 +86,29 @@ async function runLoadingSequence(steps, opts = {}) {
86
86
  return { ok: results.every((r) => r.ok), steps: results };
87
87
  }
88
88
  out.write('\n ' + theme_1.c.text(heading) + '\n\n');
89
+ // v4.8.0 Slice 10c — progress bar above the step rows. 10 cells
90
+ // (●/○) split proportionally across the steps; each completed step
91
+ // fills floor(10 * (i+1) / N) cells. Uses the same hex-dot glyphs
92
+ // as the status footer's context bar for visual consistency.
93
+ const BAR_CELLS = 10;
94
+ const buildBar = (completed) => {
95
+ const filled = Math.min(BAR_CELLS, Math.floor((BAR_CELLS * completed) / steps.length));
96
+ const pct = Math.round((completed / steps.length) * 100);
97
+ const fillSeg = theme_1.c.primary('●'.repeat(filled));
98
+ const emptySeg = theme_1.c.muted('○'.repeat(BAR_CELLS - filled));
99
+ const label = completed < steps.length
100
+ ? theme_1.c.muted(steps[completed].label + '...')
101
+ : theme_1.c.muted('done');
102
+ return ` ${fillSeg}${emptySeg} ${theme_1.c.text(String(pct).padStart(3) + '%')} ${label}`;
103
+ };
104
+ out.write(buildBar(0) + '\n\n');
89
105
  // Pre-paint placeholder rows so the spinner overwrites in place.
90
106
  for (const step of steps) {
91
107
  const line = ' ' + theme_1.c.muted('·') + ' ' + rpad(step.label, labelCol) +
92
108
  ' ' + theme_1.c.muted(lpad('—', statusCol));
93
109
  out.write(line + '\n');
94
110
  }
95
- // Walk back up to the top of the block.
111
+ // Walk back up to the top of the step block.
96
112
  out.write(`\x1b[${steps.length}A`);
97
113
  for (let i = 0; i < steps.length; i++) {
98
114
  const step = steps[i];
@@ -127,6 +143,13 @@ async function runLoadingSequence(steps, opts = {}) {
127
143
  ' ' + lpad(statusText, statusCol) + ' ' + timing;
128
144
  out.write('\x1b[2K\r' + row + '\n');
129
145
  results.push({ label: step.label, ok, status, ms });
146
+ // v4.8.0 Slice 10c — repaint the progress bar above the step
147
+ // block after each step completes. Cursor is currently on the
148
+ // line below the just-completed step; walk up to the bar line
149
+ // (steps.length - i - 1 rows of remaining steps + 1 blank line
150
+ // separator + the bar itself), rewrite, then walk back down.
151
+ const upCount = (steps.length - i - 1) + 2;
152
+ out.write(`\x1b[${upCount}A\x1b[2K\r${buildBar(i + 1)}\x1b[${upCount}B\r`);
130
153
  }
131
154
  out.write('\n ' + (0, theme_1.separator)(Math.min(w - 4, 64)) + '\n');
132
155
  return { ok: results.every((r) => r.ok), steps: results };
@@ -47,22 +47,31 @@ function renderSuccessScreen(opts = {}) {
47
47
  out.write('setup-complete\n');
48
48
  return;
49
49
  }
50
+ // v4.8.0 Slice 10b — Aiden-native framed panel chrome. Each row
51
+ // carries the orange bar; content (title + examples + closing
52
+ // hint) preserved verbatim so content-level test assertions hold.
53
+ // v4.8.0 Slice 11c — `▎` swapped for `│` (U+2502); same swap as
54
+ // glyphs.panel.bar in the design tokens.
50
55
  const w = (0, theme_1.termWidth)();
51
56
  const sepW = Math.min(w - 4, 64);
52
57
  const narrow = w < 60;
53
- out.write('\n ' + (0, theme_1.separator)(sepW) + '\n');
54
- out.write('\n ' + (0, theme_1.bold)(theme_1.c.primary('All set!')) + '\n');
55
- out.write('\n ' + theme_1.c.text('Aiden is ready. Try these to start:') + '\n');
58
+ const bar = theme_1.c.primary('');
59
+ const divider = theme_1.c.muted(''.repeat(sepW - 2));
60
+ const line = (s) => ` ${bar} ${s}`;
56
61
  out.write('\n');
62
+ out.write(line((0, theme_1.bold)(theme_1.c.primary('All set!'))) + '\n');
63
+ out.write(line(divider) + '\n');
64
+ out.write(line(theme_1.c.text('Aiden is ready. Try these to start:')) + '\n');
65
+ out.write(line('') + '\n');
57
66
  if (narrow) {
58
- // Compact: a single suggestion line + the muted hello fallback.
59
- out.write(' ' + theme_1.c.muted('▸ ') + theme_1.c.accent(examples[0]) + '\n');
67
+ out.write(line(theme_1.c.muted('▸ ') + theme_1.c.accent(examples[0])) + '\n');
60
68
  }
61
69
  else {
62
70
  for (const ex of examples) {
63
- out.write(' ' + theme_1.c.muted('▸ ') + theme_1.c.accent(ex) + '\n');
71
+ out.write(line(theme_1.c.muted('▸ ') + theme_1.c.accent(ex)) + '\n');
64
72
  }
65
73
  }
66
- out.write('\n ' + theme_1.c.muted('Or just say hi.') + '\n');
67
- out.write('\n ' + (0, theme_1.separator)(sepW) + '\n\n');
74
+ out.write(line('') + '\n');
75
+ out.write(line(theme_1.c.muted('Or just say hi.')) + '\n');
76
+ out.write('\n');
68
77
  }
@@ -6,19 +6,52 @@
6
6
  * Aiden — local-first agent.
7
7
  */
8
8
  /**
9
- * cli/v4/pasteIntercept.ts — Tier-3.1a (v4.1-tier3.1a)
9
+ * cli/v4/pasteIntercept.ts — stdin pre-tap for bracketed paste.
10
10
  *
11
- * Stdin pre-tap that handles bracketed paste sequences before
12
- * @inquirer/prompts sees them. Modern inquirer treats any internal
13
- * `\n` as Enter and resolves early, so a multi-line paste auto-
14
- * submits before the user has a chance to review. This module
15
- * intercepts paste boundaries (CSI 2004), captures the content,
16
- * persists it via the existing pasteCompression manifest, and
17
- * substitutes a `[paste #<id>: <N> lines, <KB>]` label on stdin.
11
+ * Modern @inquirer/prompts treats any embedded `\n` as Enter and
12
+ * resolves early, so a multi-line paste would auto-submit one line
13
+ * at a time. This module intercepts paste payloads BEFORE inquirer
14
+ * sees them, persists them to a manifest, and substitutes a
15
+ * `[paste #<id>: <N> lines, <bytes>]` label on stdin. The user sees
16
+ * the label inside inquirer's input buffer, edits it like any other
17
+ * text, then presses Enter to submit; `chatSession.readUserInput`
18
+ * swaps the label back for the original via `getPasteOriginal(id)`
19
+ * before handing to the agent.
18
20
  *
19
- * The user sees the label in inquirer's input buffer, presses Enter
20
- * to submit, and chatSession.readUserInput swaps the label for the
21
- * original via getPasteOriginal(id) before handing to the agent.
21
+ * v4.8.1 Slice 2 hotfix #6 robustness rebuild for terminal-
22
+ * environment diversity:
23
+ *
24
+ * • State machine survives reads split across chunk boundaries.
25
+ * The begin or end marker can arrive partially in one chunk
26
+ * and be completed by the next; the parser keeps state in `buf`
27
+ * until a full marker is observed.
28
+ *
29
+ * • 800ms watchdog flushes a stuck `in_marker_paste` state if
30
+ * the terminal never delivers PASTE_END (mosh/tmux/SSH paths
31
+ * have all been observed to drop end markers under load).
32
+ *
33
+ * • Degraded marker forms get normalised to canonical at the
34
+ * intercept boundary. Visible-escape variants (`^[[200~`) are
35
+ * the common case from terminals that escape control sequences
36
+ * for display.
37
+ *
38
+ * • CRLF/CR → LF normalisation is applied universally on every
39
+ * incoming chunk, not just inside marker payloads. Some
40
+ * clipboard payloads carry CR-only line endings.
41
+ *
42
+ * • 30ms timing accumulation catches line-by-line paste delivery
43
+ * — the failure mode that surfaced after hotfix #5. When a
44
+ * terminal delivers a paste as N small `"<line>\n"` chunks
45
+ * instead of one bulk chunk, each chunk has a single trailing
46
+ * `\n` and would otherwise pass through as an Enter keystroke.
47
+ * The accumulator holds candidate chunks (`length > 1` so the
48
+ * bare Enter keystroke `"\n"` is never held) for a 30ms window;
49
+ * if another candidate arrives, both are accumulated as a
50
+ * multi-line paste and substituted with the placeholder before
51
+ * any `\n` reaches inquirer. If no follow-up arrives within the
52
+ * window, the held chunk is emitted unchanged (normal Enter).
53
+ * 30ms is imperceptible to humans and well below sustained
54
+ * keystroke timing.
22
55
  */
23
56
  var __importDefault = (this && this.__importDefault) || function (mod) {
24
57
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -33,7 +66,16 @@ const node_path_1 = __importDefault(require("node:path"));
33
66
  const paths_1 = require("../../core/v4/paths");
34
67
  const PASTE_BEGIN = '\x1b[200~';
35
68
  const PASTE_END = '\x1b[201~';
36
- /** id → original text (in-memory swap table). */
69
+ /**
70
+ * Degraded marker patterns observed in the wild. Each is rewritten
71
+ * to canonical at the normalisation boundary so the parser only
72
+ * needs to know about one form.
73
+ */
74
+ const DEGRADED_BEGIN = /\^\[\[200~/g;
75
+ const DEGRADED_END = /\^\[\[201~/g;
76
+ const ACCUMULATION_MS = 30;
77
+ const WATCHDOG_MS = 800;
78
+ /** id → original text (in-memory swap table). Disk has /pastes/paste_<id>.txt as source of truth for /show. */
37
79
  const originals = new Map();
38
80
  function pastesDir() {
39
81
  return node_path_1.default.join((0, paths_1.resolveAidenPaths)().root, 'pastes');
@@ -74,18 +116,17 @@ function compressSync(text) {
74
116
  }
75
117
  /**
76
118
  * Look up the original text for a paste id. Returns undefined if the
77
- * id was never seen by this process (e.g. the user typed a label by
78
- * hand). Disk is the source of truth for /show <id>; this map is the
79
- * fast path for the in-flight prompt swap.
119
+ * id was never seen by this process. Disk (/pastes/paste_<id>.txt)
120
+ * is the source of truth for /show <id>; this map is the fast path
121
+ * for the in-flight prompt swap.
80
122
  */
81
123
  function getPasteOriginal(id) {
82
124
  return originals.get(id);
83
125
  }
84
126
  /**
85
127
  * Replace `[paste #N: …]` patterns in `input` with the corresponding
86
- * original text from the in-process map. Patterns whose id we don't
87
- * know are left intact (might be user-typed). Returns the swapped
88
- * string.
128
+ * original text. Patterns whose id we don't know are left intact
129
+ * (might be user-typed by hand).
89
130
  */
90
131
  function expandPasteLabels(input) {
91
132
  return input.replace(/\[paste #(\d+):[^\]]*\]/g, (m, id) => {
@@ -93,6 +134,38 @@ function expandPasteLabels(input) {
93
134
  return orig !== undefined ? orig : m;
94
135
  });
95
136
  }
137
+ /**
138
+ * Universal normalisation applied at the intercept boundary:
139
+ * CRLF + bare CR → LF, then degraded marker variants → canonical.
140
+ */
141
+ function normalize(text) {
142
+ let t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
143
+ t = t.replace(DEGRADED_BEGIN, PASTE_BEGIN);
144
+ t = t.replace(DEGRADED_END, PASTE_END);
145
+ return t;
146
+ }
147
+ /**
148
+ * Decide whether `payload` should emit inline (small single-line) or
149
+ * be funnelled through the disk-backed placeholder system. Same
150
+ * thresholds for marker-wrapped and timing-accumulated paths so the
151
+ * user sees identical chrome regardless of how the paste arrived.
152
+ */
153
+ function payloadToEmission(payload) {
154
+ const trimmed = payload.replace(/\n+$/, '');
155
+ if (!trimmed.includes('\n') && trimmed.length <= 500) {
156
+ return trimmed;
157
+ }
158
+ try {
159
+ const { id, label } = compressSync(trimmed);
160
+ originals.set(id, trimmed);
161
+ return label;
162
+ }
163
+ catch {
164
+ // Disk failure: collapse newlines so the auto-submit we're
165
+ // preventing doesn't fire downstream.
166
+ return trimmed.replace(/\n/g, ' ');
167
+ }
168
+ }
96
169
  let installed = null;
97
170
  /**
98
171
  * Install the stdin pre-tap. Wraps `process.stdin.emit('data', …)`
@@ -103,72 +176,140 @@ let installed = null;
103
176
  * MCP serve mode: never call this — `aiden mcp serve` doesn't run
104
177
  * the REPL.
105
178
  */
106
- function installPasteInterceptor(stdin) {
179
+ function installPasteInterceptor(stdin, opts = {}) {
107
180
  if (installed)
108
181
  return installed.restore;
182
+ const accumulationMs = opts.accumulationMs ?? ACCUMULATION_MS;
183
+ const watchdogMs = opts.watchdogMs ?? WATCHDOG_MS;
109
184
  const origEmit = stdin.emit.bind(stdin);
110
- const state = { inPaste: false, buf: '' };
111
- function processChunk(text) {
112
- let out = '';
185
+ // State machine
186
+ // normal : default; chunks pass through or accumulate
187
+ // in_marker_paste : between PASTE_BEGIN and PASTE_END; buf accumulates payload
188
+ let mode = 'normal';
189
+ let buf = '';
190
+ let markerTimer = null;
191
+ let pendingChunk = null;
192
+ let pendingTimer = null;
193
+ function emitDownstream(text) {
194
+ if (text.length === 0)
195
+ return;
196
+ origEmit('data', Buffer.from(text, 'utf8'));
197
+ }
198
+ function clearMarkerWatchdog() {
199
+ if (markerTimer) {
200
+ clearTimeout(markerTimer);
201
+ markerTimer = null;
202
+ }
203
+ }
204
+ function armMarkerWatchdog() {
205
+ clearMarkerWatchdog();
206
+ markerTimer = setTimeout(() => {
207
+ // PASTE_END never arrived. Flush whatever we have and reset.
208
+ const payload = buf;
209
+ buf = '';
210
+ mode = 'normal';
211
+ markerTimer = null;
212
+ emitDownstream(payloadToEmission(payload));
213
+ }, watchdogMs);
214
+ }
215
+ function clearPending() {
216
+ if (pendingTimer) {
217
+ clearTimeout(pendingTimer);
218
+ pendingTimer = null;
219
+ }
220
+ pendingChunk = null;
221
+ }
222
+ function flushPendingAsIs() {
223
+ if (pendingChunk === null)
224
+ return;
225
+ const chunk = pendingChunk;
226
+ clearPending();
227
+ // Pending was a normal Enter — emit as-is, don't placeholder.
228
+ emitDownstream(chunk);
229
+ }
230
+ function flushPendingAsPaste() {
231
+ if (pendingChunk === null)
232
+ return;
233
+ const chunk = pendingChunk;
234
+ clearPending();
235
+ emitDownstream(payloadToEmission(chunk));
236
+ }
237
+ function processNormalised(text) {
113
238
  let cursor = 0;
114
239
  while (cursor < text.length) {
115
- if (state.inPaste) {
240
+ if (mode === 'in_marker_paste') {
116
241
  const endIdx = text.indexOf(PASTE_END, cursor);
117
242
  if (endIdx === -1) {
118
- state.buf += text.slice(cursor);
243
+ buf += text.slice(cursor);
119
244
  cursor = text.length;
245
+ // Watchdog stays armed — extending the buf without an end
246
+ // marker doesn't restart the clock; we still want to flush
247
+ // if the entire turn never produces PASTE_END.
120
248
  }
121
249
  else {
122
- state.buf += text.slice(cursor, endIdx);
250
+ buf += text.slice(cursor, endIdx);
123
251
  cursor = endIdx + PASTE_END.length;
124
- // Tier-3.1c: terminals (and some clipboard payloads) emit a
125
- // trailing CR/LF immediately after PASTE_END. Without this
126
- // swallow the bytes pass through to readline, where they
127
- // become an Enter event and auto-submit the prompt before
128
- // the user has reviewed the paste. Eat at most one CR + one
129
- // LF (in either order) right after PASTE_END.
130
- if (text[cursor] === '\r')
131
- cursor += 1;
252
+ // Swallow a trailing newline that some terminals emit
253
+ // immediately after PASTE_END.
132
254
  if (text[cursor] === '\n')
133
255
  cursor += 1;
134
- state.inPaste = false;
135
- const original = state.buf.replace(/\r\n/g, '\n');
136
- state.buf = '';
137
- // Strip a single trailing newline (Enter at end of paste).
138
- const trimmed = original.replace(/\n+$/, '');
139
- if (!trimmed.includes('\n') && trimmed.length <= 500) {
140
- // Single-line, small — emit as-is so user can edit.
141
- out += trimmed;
142
- }
143
- else {
144
- // Multi-line or large — disk-back + emit label.
145
- try {
146
- const { id, label } = compressSync(trimmed);
147
- originals.set(id, trimmed);
148
- out += label;
149
- }
150
- catch {
151
- // Disk failure: fall back to a single-space substitute
152
- // so internal newlines don't trigger auto-submit.
153
- out += trimmed.replace(/\n/g, ' ');
154
- }
155
- }
256
+ mode = 'normal';
257
+ clearMarkerWatchdog();
258
+ const payload = buf;
259
+ buf = '';
260
+ emitDownstream(payloadToEmission(payload));
156
261
  }
262
+ continue;
157
263
  }
158
- else {
159
- const beginIdx = text.indexOf(PASTE_BEGIN, cursor);
160
- if (beginIdx === -1) {
161
- out += text.slice(cursor);
162
- cursor = text.length;
264
+ // mode === 'normal'
265
+ const beginIdx = text.indexOf(PASTE_BEGIN, cursor);
266
+ if (beginIdx !== -1) {
267
+ // Pre-marker content: flush any pending and emit inline so
268
+ // it lands in inquirer's buffer ahead of the placeholder
269
+ // (preserves typed prefix when the user pastes after typing).
270
+ flushPendingAsIs();
271
+ if (beginIdx > cursor)
272
+ emitDownstream(text.slice(cursor, beginIdx));
273
+ cursor = beginIdx + PASTE_BEGIN.length;
274
+ mode = 'in_marker_paste';
275
+ armMarkerWatchdog();
276
+ continue;
277
+ }
278
+ // No marker in the remainder.
279
+ const remainder = text.slice(cursor);
280
+ cursor = text.length;
281
+ const nlCount = (remainder.match(/\n/g) ?? []).length;
282
+ const hasInternalNl = nlCount > 1 || (nlCount === 1 && !remainder.endsWith('\n'));
283
+ if (hasInternalNl) {
284
+ // Single bulk chunk with internal newlines — instant
285
+ // placeholder. Flush pending first so any prior single-line
286
+ // candidate isn't lost.
287
+ flushPendingAsIs();
288
+ emitDownstream(payloadToEmission(remainder));
289
+ continue;
290
+ }
291
+ // Candidate paste-line: non-empty content ending in `\n` with
292
+ // length > 1 (excludes bare Enter keystroke `"\n"`).
293
+ const isCandidate = remainder.endsWith('\n') && remainder.length > 1;
294
+ if (isCandidate) {
295
+ if (pendingChunk !== null) {
296
+ // Already pending — append, restart the window.
297
+ pendingChunk += remainder;
298
+ if (pendingTimer)
299
+ clearTimeout(pendingTimer);
300
+ pendingTimer = setTimeout(flushPendingAsPaste, accumulationMs);
163
301
  }
164
302
  else {
165
- out += text.slice(cursor, beginIdx);
166
- cursor = beginIdx + PASTE_BEGIN.length;
167
- state.inPaste = true;
303
+ pendingChunk = remainder;
304
+ pendingTimer = setTimeout(flushPendingAsIs, accumulationMs);
168
305
  }
306
+ continue;
169
307
  }
308
+ // Non-candidate (bare Enter, or non-`\n`-terminated keystroke).
309
+ // Flush pending first since this chunk closes the window.
310
+ flushPendingAsIs();
311
+ emitDownstream(remainder);
170
312
  }
171
- return out;
172
313
  }
173
314
  const wrappedEmit = function (event, ...args) {
174
315
  if (event !== 'data')
@@ -176,19 +317,22 @@ function installPasteInterceptor(stdin) {
176
317
  const chunk = args[0];
177
318
  if (chunk == null)
178
319
  return origEmit(event, ...args);
179
- const text = Buffer.isBuffer(chunk)
320
+ const raw = Buffer.isBuffer(chunk)
180
321
  ? chunk.toString('utf8')
181
322
  : (typeof chunk === 'string' ? chunk : String(chunk));
182
- const processed = processChunk(text);
183
- if (processed.length === 0)
184
- return true; // suppress entirely
185
- const nextArgs = [Buffer.from(processed, 'utf8'), ...args.slice(1)];
186
- return origEmit(event, ...nextArgs);
323
+ const normalised = normalize(raw);
324
+ processNormalised(normalised);
325
+ // We always claim to have handled the emit. Downstream listeners
326
+ // fire from `emitDownstream` immediately on the same tick OR
327
+ // from a deferred timer in the accumulation case.
328
+ return true;
187
329
  };
188
330
  stdin.emit = wrappedEmit;
189
331
  const restore = () => {
190
332
  if (!installed)
191
333
  return;
334
+ clearPending();
335
+ clearMarkerWatchdog();
192
336
  stdin.emit = origEmit;
193
337
  installed = null;
194
338
  };