@levu/snap 0.3.6 → 0.3.8

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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.8] - 2026-02-26
9
+
10
+ ### Fixed
11
+ - Prevented premature submit during non-bracketed multi-line paste bursts where newline Enter events were treated as user submit before all pasted lines arrived.
12
+ - Added explicit paste-burst detection from raw input stream and suppressed Enter-submit only during the short paste window.
13
+ - Kept bracketed paste behavior intact and avoided false suppression after paste-end marker.
14
+ - Added regression coverage for non-bracketed multi-line paste with delayed second-line arrival.
15
+
16
+ ## [0.3.7] - 2026-02-26
17
+
18
+ ### Fixed
19
+ - Fixed Enter-submit race in multiline prompt where pasted multi-line input could lose the final line and submit only the first line.
20
+ - Enter submit now waits briefly for the corresponding readline `line` event (with fallback timeout), preserving pending paste buffer content.
21
+ - Prompt rendering now prefers live readline buffer content so pending pasted lines are visible before submit.
22
+ - Added regression coverage for the “first line only after multiline paste” scenario.
23
+
8
24
  ## [0.3.6] - 2026-02-26
9
25
 
10
26
  ### Fixed
@@ -33,7 +33,14 @@ export const createMultilineTextPrompt = () => {
33
33
  let expectingGhosttySequenceEcho = false;
34
34
  let bracketPasteActive = false;
35
35
  let bracketPasteProbe = '';
36
+ let pendingEnterSubmit = false;
37
+ let pendingEnterSubmitTimer;
38
+ let recentPasteBurstUntil = 0;
36
39
  const cleanup = () => {
40
+ if (pendingEnterSubmitTimer) {
41
+ clearTimeout(pendingEnterSubmitTimer);
42
+ pendingEnterSubmitTimer = undefined;
43
+ }
37
44
  if (keypressListener) {
38
45
  input.off('keypress', keypressListener);
39
46
  }
@@ -110,6 +117,19 @@ export const createMultilineTextPrompt = () => {
110
117
  const buildSubmitValue = () => {
111
118
  return normalizeGhosttyInlineTokens(lines.concat(getLiveLine()).join('\n'));
112
119
  };
120
+ const absorbLine = (line) => {
121
+ if (line.trim() === '') {
122
+ if (currentLine !== '') {
123
+ lines.push(currentLine);
124
+ currentLine = '';
125
+ }
126
+ return;
127
+ }
128
+ if (currentLine !== '') {
129
+ lines.push(currentLine);
130
+ }
131
+ currentLine = line;
132
+ };
113
133
  const insertNewline = () => {
114
134
  lines.push(getLiveLine());
115
135
  currentLine = '';
@@ -117,11 +137,12 @@ export const createMultilineTextPrompt = () => {
117
137
  output.write(`\n${pc.dim('> ')}`);
118
138
  };
119
139
  const showPrompt = () => {
120
- output.write(`\n${pc.dim('> ')}${currentLine}`);
140
+ output.write(`\n${pc.dim('> ')}${getLiveLine()}`);
121
141
  };
122
142
  const BRACKET_PASTE_START = '\u001b[200~';
123
143
  const BRACKET_PASTE_END = '\u001b[201~';
124
144
  const BRACKET_PASTE_PROBE_MAX = 96;
145
+ const PASTE_BURST_WINDOW_MS = 70;
125
146
  const updateBracketPasteState = (chunk) => {
126
147
  if (!chunk)
127
148
  return;
@@ -141,6 +162,20 @@ export const createMultilineTextPrompt = () => {
141
162
  }
142
163
  }
143
164
  };
165
+ const notePossiblePasteBurst = (chunk) => {
166
+ if (!chunk)
167
+ return;
168
+ const normalized = chunk
169
+ .replaceAll(BRACKET_PASTE_START, '')
170
+ .replaceAll(BRACKET_PASTE_END, '');
171
+ if (!normalized)
172
+ return;
173
+ // In raw mode, normal typing usually arrives as single-byte chunks.
174
+ // Multi-byte chunks and newlines are strong signals that input is a paste burst.
175
+ if (normalized.length > 1 || /[\r\n]/.test(normalized)) {
176
+ recentPasteBurstUntil = Math.max(recentPasteBurstUntil, Date.now() + PASTE_BURST_WINDOW_MS);
177
+ }
178
+ };
144
179
  showPrompt();
145
180
  // Handle paste from clipboard
146
181
  const handlePaste = async () => {
@@ -181,6 +216,23 @@ export const createMultilineTextPrompt = () => {
181
216
  rl.on('line', (line) => {
182
217
  if (cancelled)
183
218
  return;
219
+ const now = Date.now();
220
+ if (pendingEnterSubmit) {
221
+ const likelyPasteFlow = now < recentPasteBurstUntil;
222
+ pendingEnterSubmit = false;
223
+ if (pendingEnterSubmitTimer) {
224
+ clearTimeout(pendingEnterSubmitTimer);
225
+ pendingEnterSubmitTimer = undefined;
226
+ }
227
+ if (likelyPasteFlow) {
228
+ absorbLine(line);
229
+ showPrompt();
230
+ return;
231
+ }
232
+ absorbLine(line);
233
+ submit(buildSubmitValue());
234
+ return;
235
+ }
184
236
  if (ignoreNextLineEvent) {
185
237
  ignoreNextLineEvent = false;
186
238
  return;
@@ -202,27 +254,13 @@ export const createMultilineTextPrompt = () => {
202
254
  showPrompt();
203
255
  return;
204
256
  }
205
- const now = Date.now();
206
257
  // Check for double Enter to submit
207
258
  if (line === '' && now - lastEnterTime < DOUBLE_ENTER_TIMEOUT) {
208
259
  submit(buildSubmitValue());
209
260
  return;
210
261
  }
211
262
  lastEnterTime = now;
212
- if (line.trim() === '') {
213
- // Empty line - add to lines
214
- if (currentLine !== '') {
215
- lines.push(currentLine);
216
- currentLine = '';
217
- }
218
- }
219
- else {
220
- // Non-empty line
221
- if (currentLine !== '') {
222
- lines.push(currentLine);
223
- }
224
- currentLine = line;
225
- }
263
+ absorbLine(line);
226
264
  showPrompt();
227
265
  });
228
266
  // Handle SIGINT (Ctrl+C)
@@ -246,6 +284,7 @@ export const createMultilineTextPrompt = () => {
246
284
  return;
247
285
  const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '');
248
286
  updateBracketPasteState(text);
287
+ notePossiblePasteBurst(text);
249
288
  };
250
289
  input.on('data', dataListener);
251
290
  keypressListener = async (str, key) => {
@@ -303,13 +342,28 @@ export const createMultilineTextPrompt = () => {
303
342
  insertNewline();
304
343
  }
305
344
  else if (key.name === 'enter' || key.name === 'return') {
306
- ignoreNextLineEvent = true;
345
+ const now = Date.now();
346
+ const likelyPasteReturn = now < recentPasteBurstUntil;
347
+ if (likelyPasteReturn) {
348
+ return;
349
+ }
307
350
  if (key.shift || key.alt) {
351
+ ignoreNextLineEvent = true;
308
352
  // Shift+Enter / Alt+Enter inserts a new line.
309
353
  insertNewline();
310
354
  return;
311
355
  }
312
- submit(buildSubmitValue());
356
+ pendingEnterSubmit = true;
357
+ if (pendingEnterSubmitTimer) {
358
+ clearTimeout(pendingEnterSubmitTimer);
359
+ }
360
+ pendingEnterSubmitTimer = setTimeout(() => {
361
+ if (!pendingEnterSubmit || cancelled)
362
+ return;
363
+ pendingEnterSubmit = false;
364
+ pendingEnterSubmitTimer = undefined;
365
+ submit(buildSubmitValue());
366
+ }, 20);
313
367
  }
314
368
  };
315
369
  input.on('keypress', keypressListener);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levu/snap",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "snap": "./dist/cli-entry.js"