@levu/snap 0.3.7 → 0.3.9

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,21 @@ 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.9] - 2026-02-28
9
+
10
+ ### Fixed
11
+ - Added multiline paste recovery for cases where readline submit flow only exposes the last pasted line.
12
+ - Recovery now uses recent raw paste chunks to reconstruct full multi-line content when the submit payload is unexpectedly single-line.
13
+ - Added regression coverage for the “last line only after multiline paste” scenario.
14
+
15
+ ## [0.3.8] - 2026-02-26
16
+
17
+ ### Fixed
18
+ - Prevented premature submit during non-bracketed multi-line paste bursts where newline Enter events were treated as user submit before all pasted lines arrived.
19
+ - Added explicit paste-burst detection from raw input stream and suppressed Enter-submit only during the short paste window.
20
+ - Kept bracketed paste behavior intact and avoided false suppression after paste-end marker.
21
+ - Added regression coverage for non-bracketed multi-line paste with delayed second-line arrival.
22
+
8
23
  ## [0.3.7] - 2026-02-26
9
24
 
10
25
  ### Fixed
@@ -35,6 +35,9 @@ export const createMultilineTextPrompt = () => {
35
35
  let bracketPasteProbe = '';
36
36
  let pendingEnterSubmit = false;
37
37
  let pendingEnterSubmitTimer;
38
+ let recentPasteBurstUntil = 0;
39
+ let sawPasteLikeRawInput = false;
40
+ let recentPasteRawBuffer = '';
38
41
  const cleanup = () => {
39
42
  if (pendingEnterSubmitTimer) {
40
43
  clearTimeout(pendingEnterSubmitTimer);
@@ -113,8 +116,35 @@ export const createMultilineTextPrompt = () => {
113
116
  const normalizeGhosttyInlineTokens = (raw) => {
114
117
  return raw.replace(/(?:\u001b\[13;2u|\[13;2u|\u001b\[27;2;13~|\[27;2;13~|13~|~13)/g, '\n');
115
118
  };
119
+ const stripAnsiControls = (raw) => {
120
+ return String(raw || '')
121
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
122
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
123
+ };
124
+ const recoverMultilineFromRawPaste = (primary) => {
125
+ if (!sawPasteLikeRawInput)
126
+ return '';
127
+ if (primary.includes('\n'))
128
+ return '';
129
+ if (!recentPasteRawBuffer)
130
+ return '';
131
+ const recoveredLines = stripAnsiControls(recentPasteRawBuffer)
132
+ .replace(/\r/g, '\n')
133
+ .split('\n')
134
+ .map((line) => line.trim())
135
+ .filter(Boolean);
136
+ if (recoveredLines.length < 2)
137
+ return '';
138
+ const normalizedPrimary = String(primary || '').trim();
139
+ const recoveredLastLine = recoveredLines[recoveredLines.length - 1] || '';
140
+ if (normalizedPrimary && recoveredLastLine !== normalizedPrimary)
141
+ return '';
142
+ return recoveredLines.join('\n');
143
+ };
116
144
  const buildSubmitValue = () => {
117
- return normalizeGhosttyInlineTokens(lines.concat(getLiveLine()).join('\n'));
145
+ const primary = normalizeGhosttyInlineTokens(lines.concat(getLiveLine()).join('\n'));
146
+ const recovered = recoverMultilineFromRawPaste(primary);
147
+ return recovered || primary;
118
148
  };
119
149
  const absorbLine = (line) => {
120
150
  if (line.trim() === '') {
@@ -141,6 +171,8 @@ export const createMultilineTextPrompt = () => {
141
171
  const BRACKET_PASTE_START = '\u001b[200~';
142
172
  const BRACKET_PASTE_END = '\u001b[201~';
143
173
  const BRACKET_PASTE_PROBE_MAX = 96;
174
+ const PASTE_BURST_WINDOW_MS = 70;
175
+ const RAW_PASTE_BUFFER_MAX = 8192;
144
176
  const updateBracketPasteState = (chunk) => {
145
177
  if (!chunk)
146
178
  return;
@@ -160,6 +192,22 @@ export const createMultilineTextPrompt = () => {
160
192
  }
161
193
  }
162
194
  };
195
+ const notePossiblePasteBurst = (chunk) => {
196
+ if (!chunk)
197
+ return;
198
+ const normalized = chunk
199
+ .replaceAll(BRACKET_PASTE_START, '')
200
+ .replaceAll(BRACKET_PASTE_END, '');
201
+ if (!normalized)
202
+ return;
203
+ // In raw mode, normal typing usually arrives as single-byte chunks.
204
+ // Multi-byte chunks and newlines are strong signals that input is a paste burst.
205
+ if (normalized.length > 1 || /[\r\n]/.test(normalized)) {
206
+ sawPasteLikeRawInput = true;
207
+ recentPasteRawBuffer = `${recentPasteRawBuffer}${normalized}`.slice(-RAW_PASTE_BUFFER_MAX);
208
+ recentPasteBurstUntil = Math.max(recentPasteBurstUntil, Date.now() + PASTE_BURST_WINDOW_MS);
209
+ }
210
+ };
163
211
  showPrompt();
164
212
  // Handle paste from clipboard
165
213
  const handlePaste = async () => {
@@ -200,12 +248,19 @@ export const createMultilineTextPrompt = () => {
200
248
  rl.on('line', (line) => {
201
249
  if (cancelled)
202
250
  return;
251
+ const now = Date.now();
203
252
  if (pendingEnterSubmit) {
253
+ const likelyPasteFlow = now < recentPasteBurstUntil;
204
254
  pendingEnterSubmit = false;
205
255
  if (pendingEnterSubmitTimer) {
206
256
  clearTimeout(pendingEnterSubmitTimer);
207
257
  pendingEnterSubmitTimer = undefined;
208
258
  }
259
+ if (likelyPasteFlow) {
260
+ absorbLine(line);
261
+ showPrompt();
262
+ return;
263
+ }
209
264
  absorbLine(line);
210
265
  submit(buildSubmitValue());
211
266
  return;
@@ -231,7 +286,6 @@ export const createMultilineTextPrompt = () => {
231
286
  showPrompt();
232
287
  return;
233
288
  }
234
- const now = Date.now();
235
289
  // Check for double Enter to submit
236
290
  if (line === '' && now - lastEnterTime < DOUBLE_ENTER_TIMEOUT) {
237
291
  submit(buildSubmitValue());
@@ -262,6 +316,7 @@ export const createMultilineTextPrompt = () => {
262
316
  return;
263
317
  const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '');
264
318
  updateBracketPasteState(text);
319
+ notePossiblePasteBurst(text);
265
320
  };
266
321
  input.on('data', dataListener);
267
322
  keypressListener = async (str, key) => {
@@ -319,6 +374,11 @@ export const createMultilineTextPrompt = () => {
319
374
  insertNewline();
320
375
  }
321
376
  else if (key.name === 'enter' || key.name === 'return') {
377
+ const now = Date.now();
378
+ const likelyPasteReturn = now < recentPasteBurstUntil;
379
+ if (likelyPasteReturn) {
380
+ return;
381
+ }
322
382
  if (key.shift || key.alt) {
323
383
  ignoreNextLineEvent = true;
324
384
  // Shift+Enter / Alt+Enter inserts a new line.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levu/snap",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "snap": "./dist/cli-entry.js"