@levu/snap 0.3.5 → 0.3.7

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.7] - 2026-02-26
9
+
10
+ ### Fixed
11
+ - Fixed Enter-submit race in multiline prompt where pasted multi-line input could lose the final line and submit only the first line.
12
+ - Enter submit now waits briefly for the corresponding readline `line` event (with fallback timeout), preserving pending paste buffer content.
13
+ - Prompt rendering now prefers live readline buffer content so pending pasted lines are visible before submit.
14
+ - Added regression coverage for the “first line only after multiline paste” scenario.
15
+
16
+ ## [0.3.6] - 2026-02-26
17
+
18
+ ### Fixed
19
+ - Prevented accidental submit during terminal-native multi-line paste by detecting bracketed paste mode (`ESC[200~ ... ESC[201~`) and suppressing Enter submit while paste is in progress.
20
+ - Improved multiline prompt handling in Ghostty/macOS when pasted newlines are delivered via terminal stream instead of explicit paste shortcuts.
21
+ - Added regression coverage for bracketed multi-line paste to ensure pasted content is preserved until the user explicitly submits.
22
+
8
23
  ## [0.3.5] - 2026-02-26
9
24
 
10
25
  ### Fixed
@@ -28,12 +28,24 @@ export const createMultilineTextPrompt = () => {
28
28
  let cancelled = false;
29
29
  let rawModeEnabled = false;
30
30
  let keypressListener;
31
+ let dataListener;
31
32
  let ignoreNextLineEvent = false;
32
33
  let expectingGhosttySequenceEcho = false;
34
+ let bracketPasteActive = false;
35
+ let bracketPasteProbe = '';
36
+ let pendingEnterSubmit = false;
37
+ let pendingEnterSubmitTimer;
33
38
  const cleanup = () => {
39
+ if (pendingEnterSubmitTimer) {
40
+ clearTimeout(pendingEnterSubmitTimer);
41
+ pendingEnterSubmitTimer = undefined;
42
+ }
34
43
  if (keypressListener) {
35
44
  input.off('keypress', keypressListener);
36
45
  }
46
+ if (dataListener) {
47
+ input.off?.('data', dataListener);
48
+ }
37
49
  if (rawModeEnabled && input.setRawMode) {
38
50
  input.setRawMode(false);
39
51
  rawModeEnabled = false;
@@ -104,6 +116,19 @@ export const createMultilineTextPrompt = () => {
104
116
  const buildSubmitValue = () => {
105
117
  return normalizeGhosttyInlineTokens(lines.concat(getLiveLine()).join('\n'));
106
118
  };
119
+ const absorbLine = (line) => {
120
+ if (line.trim() === '') {
121
+ if (currentLine !== '') {
122
+ lines.push(currentLine);
123
+ currentLine = '';
124
+ }
125
+ return;
126
+ }
127
+ if (currentLine !== '') {
128
+ lines.push(currentLine);
129
+ }
130
+ currentLine = line;
131
+ };
107
132
  const insertNewline = () => {
108
133
  lines.push(getLiveLine());
109
134
  currentLine = '';
@@ -111,7 +136,29 @@ export const createMultilineTextPrompt = () => {
111
136
  output.write(`\n${pc.dim('> ')}`);
112
137
  };
113
138
  const showPrompt = () => {
114
- output.write(`\n${pc.dim('> ')}${currentLine}`);
139
+ output.write(`\n${pc.dim('> ')}${getLiveLine()}`);
140
+ };
141
+ const BRACKET_PASTE_START = '\u001b[200~';
142
+ const BRACKET_PASTE_END = '\u001b[201~';
143
+ const BRACKET_PASTE_PROBE_MAX = 96;
144
+ const updateBracketPasteState = (chunk) => {
145
+ if (!chunk)
146
+ return;
147
+ bracketPasteProbe = `${bracketPasteProbe}${chunk}`.slice(-BRACKET_PASTE_PROBE_MAX);
148
+ if (!bracketPasteActive) {
149
+ const startIndex = bracketPasteProbe.indexOf(BRACKET_PASTE_START);
150
+ if (startIndex >= 0) {
151
+ bracketPasteActive = true;
152
+ bracketPasteProbe = bracketPasteProbe.slice(startIndex + BRACKET_PASTE_START.length);
153
+ }
154
+ }
155
+ if (bracketPasteActive) {
156
+ const endIndex = bracketPasteProbe.indexOf(BRACKET_PASTE_END);
157
+ if (endIndex >= 0) {
158
+ bracketPasteActive = false;
159
+ bracketPasteProbe = bracketPasteProbe.slice(endIndex + BRACKET_PASTE_END.length);
160
+ }
161
+ }
115
162
  };
116
163
  showPrompt();
117
164
  // Handle paste from clipboard
@@ -153,6 +200,16 @@ export const createMultilineTextPrompt = () => {
153
200
  rl.on('line', (line) => {
154
201
  if (cancelled)
155
202
  return;
203
+ if (pendingEnterSubmit) {
204
+ pendingEnterSubmit = false;
205
+ if (pendingEnterSubmitTimer) {
206
+ clearTimeout(pendingEnterSubmitTimer);
207
+ pendingEnterSubmitTimer = undefined;
208
+ }
209
+ absorbLine(line);
210
+ submit(buildSubmitValue());
211
+ return;
212
+ }
156
213
  if (ignoreNextLineEvent) {
157
214
  ignoreNextLineEvent = false;
158
215
  return;
@@ -181,20 +238,7 @@ export const createMultilineTextPrompt = () => {
181
238
  return;
182
239
  }
183
240
  lastEnterTime = now;
184
- if (line.trim() === '') {
185
- // Empty line - add to lines
186
- if (currentLine !== '') {
187
- lines.push(currentLine);
188
- currentLine = '';
189
- }
190
- }
191
- else {
192
- // Non-empty line
193
- if (currentLine !== '') {
194
- lines.push(currentLine);
195
- }
196
- currentLine = line;
197
- }
241
+ absorbLine(line);
198
242
  showPrompt();
199
243
  });
200
244
  // Handle SIGINT (Ctrl+C)
@@ -213,9 +257,29 @@ export const createMultilineTextPrompt = () => {
213
257
  input.setRawMode(true);
214
258
  rawModeEnabled = true;
215
259
  input.resume();
260
+ dataListener = (chunk) => {
261
+ if (cancelled)
262
+ return;
263
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '');
264
+ updateBracketPasteState(text);
265
+ };
266
+ input.on('data', dataListener);
216
267
  keypressListener = async (str, key) => {
217
268
  if (cancelled)
218
269
  return;
270
+ if (key?.ctrl && key.name === 'c') {
271
+ doCancel();
272
+ return;
273
+ }
274
+ if (key?.name === 'escape') {
275
+ doCancel();
276
+ return;
277
+ }
278
+ // In bracketed paste mode, treat all incoming keypresses as paste content.
279
+ // This avoids accidental submit on Enter while multi-line paste is flowing.
280
+ if (bracketPasteActive) {
281
+ return;
282
+ }
219
283
  // Some terminals emit Shift+Enter as literal chars "13~" with no key metadata.
220
284
  // Readline may already have appended those chars into rl.line by the time we run.
221
285
  if (!key?.ctrl && !key?.meta && key?.name !== 'enter' && key?.name !== 'return') {
@@ -255,16 +319,23 @@ export const createMultilineTextPrompt = () => {
255
319
  insertNewline();
256
320
  }
257
321
  else if (key.name === 'enter' || key.name === 'return') {
258
- ignoreNextLineEvent = true;
259
322
  if (key.shift || key.alt) {
323
+ ignoreNextLineEvent = true;
260
324
  // Shift+Enter / Alt+Enter inserts a new line.
261
325
  insertNewline();
262
326
  return;
263
327
  }
264
- submit(buildSubmitValue());
265
- }
266
- else if (key.name === 'escape') {
267
- doCancel();
328
+ pendingEnterSubmit = true;
329
+ if (pendingEnterSubmitTimer) {
330
+ clearTimeout(pendingEnterSubmitTimer);
331
+ }
332
+ pendingEnterSubmitTimer = setTimeout(() => {
333
+ if (!pendingEnterSubmit || cancelled)
334
+ return;
335
+ pendingEnterSubmit = false;
336
+ pendingEnterSubmitTimer = undefined;
337
+ submit(buildSubmitValue());
338
+ }, 20);
268
339
  }
269
340
  };
270
341
  input.on('keypress', keypressListener);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levu/snap",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "snap": "./dist/cli-entry.js"