@levu/snap 0.3.4 → 0.3.6

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,20 @@ 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.6] - 2026-02-26
9
+
10
+ ### Fixed
11
+ - 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.
12
+ - Improved multiline prompt handling in Ghostty/macOS when pasted newlines are delivered via terminal stream instead of explicit paste shortcuts.
13
+ - Added regression coverage for bracketed multi-line paste to ensure pasted content is preserved until the user explicitly submits.
14
+
15
+ ## [0.3.5] - 2026-02-26
16
+
17
+ ### Fixed
18
+ - Added an additional Ghostty/macOS fallback for `Shift+Enter` when emitted as plain `1`, `3`, `~` keypress chars (no modifier metadata).
19
+ - Added submit-time sanitation of residual Ghostty shift-enter tokens (`13~`, related escape variants) so pasted/echoed artifacts become real newlines in multiline prompts.
20
+ - Added regression coverage for plain-char `13~` keypress and residual token sanitation.
21
+
8
22
  ## [0.3.4] - 2026-02-26
9
23
 
10
24
  ### Fixed
@@ -28,12 +28,18 @@ 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 = '';
33
36
  const cleanup = () => {
34
37
  if (keypressListener) {
35
38
  input.off('keypress', keypressListener);
36
39
  }
40
+ if (dataListener) {
41
+ input.off?.('data', dataListener);
42
+ }
37
43
  if (rawModeEnabled && input.setRawMode) {
38
44
  input.setRawMode(false);
39
45
  rawModeEnabled = false;
@@ -98,6 +104,12 @@ export const createMultilineTextPrompt = () => {
98
104
  }
99
105
  return null;
100
106
  };
107
+ const normalizeGhosttyInlineTokens = (raw) => {
108
+ return raw.replace(/(?:\u001b\[13;2u|\[13;2u|\u001b\[27;2;13~|\[27;2;13~|13~|~13)/g, '\n');
109
+ };
110
+ const buildSubmitValue = () => {
111
+ return normalizeGhosttyInlineTokens(lines.concat(getLiveLine()).join('\n'));
112
+ };
101
113
  const insertNewline = () => {
102
114
  lines.push(getLiveLine());
103
115
  currentLine = '';
@@ -107,6 +119,28 @@ export const createMultilineTextPrompt = () => {
107
119
  const showPrompt = () => {
108
120
  output.write(`\n${pc.dim('> ')}${currentLine}`);
109
121
  };
122
+ const BRACKET_PASTE_START = '\u001b[200~';
123
+ const BRACKET_PASTE_END = '\u001b[201~';
124
+ const BRACKET_PASTE_PROBE_MAX = 96;
125
+ const updateBracketPasteState = (chunk) => {
126
+ if (!chunk)
127
+ return;
128
+ bracketPasteProbe = `${bracketPasteProbe}${chunk}`.slice(-BRACKET_PASTE_PROBE_MAX);
129
+ if (!bracketPasteActive) {
130
+ const startIndex = bracketPasteProbe.indexOf(BRACKET_PASTE_START);
131
+ if (startIndex >= 0) {
132
+ bracketPasteActive = true;
133
+ bracketPasteProbe = bracketPasteProbe.slice(startIndex + BRACKET_PASTE_START.length);
134
+ }
135
+ }
136
+ if (bracketPasteActive) {
137
+ const endIndex = bracketPasteProbe.indexOf(BRACKET_PASTE_END);
138
+ if (endIndex >= 0) {
139
+ bracketPasteActive = false;
140
+ bracketPasteProbe = bracketPasteProbe.slice(endIndex + BRACKET_PASTE_END.length);
141
+ }
142
+ }
143
+ };
110
144
  showPrompt();
111
145
  // Handle paste from clipboard
112
146
  const handlePaste = async () => {
@@ -171,7 +205,7 @@ export const createMultilineTextPrompt = () => {
171
205
  const now = Date.now();
172
206
  // Check for double Enter to submit
173
207
  if (line === '' && now - lastEnterTime < DOUBLE_ENTER_TIMEOUT) {
174
- submit(lines.concat(getLiveLine()).join('\n'));
208
+ submit(buildSubmitValue());
175
209
  return;
176
210
  }
177
211
  lastEnterTime = now;
@@ -207,9 +241,43 @@ export const createMultilineTextPrompt = () => {
207
241
  input.setRawMode(true);
208
242
  rawModeEnabled = true;
209
243
  input.resume();
244
+ dataListener = (chunk) => {
245
+ if (cancelled)
246
+ return;
247
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '');
248
+ updateBracketPasteState(text);
249
+ };
250
+ input.on('data', dataListener);
210
251
  keypressListener = async (str, key) => {
211
252
  if (cancelled)
212
253
  return;
254
+ if (key?.ctrl && key.name === 'c') {
255
+ doCancel();
256
+ return;
257
+ }
258
+ if (key?.name === 'escape') {
259
+ doCancel();
260
+ return;
261
+ }
262
+ // In bracketed paste mode, treat all incoming keypresses as paste content.
263
+ // This avoids accidental submit on Enter while multi-line paste is flowing.
264
+ if (bracketPasteActive) {
265
+ return;
266
+ }
267
+ // Some terminals emit Shift+Enter as literal chars "13~" with no key metadata.
268
+ // Readline may already have appended those chars into rl.line by the time we run.
269
+ if (!key?.ctrl && !key?.meta && key?.name !== 'enter' && key?.name !== 'return') {
270
+ const strippedLive = stripGhosttyShiftEnterSuffix(String(rl.line ?? ''));
271
+ if (strippedLive !== null) {
272
+ lines.push(strippedLive);
273
+ currentLine = '';
274
+ rl.line = '';
275
+ output.write('\r' + ' '.repeat(process.stdout.columns || 80) + '\r');
276
+ output.write(`${pc.dim('> ')}${strippedLive}`);
277
+ output.write(`\n${pc.dim('> ')}`);
278
+ return;
279
+ }
280
+ }
213
281
  // Detect Ctrl+V or Cmd+V for paste
214
282
  if ((key.ctrl && key.name === 'v') || (key.meta && key.name === 'v')) {
215
283
  const pasted = await handlePaste();
@@ -241,10 +309,7 @@ export const createMultilineTextPrompt = () => {
241
309
  insertNewline();
242
310
  return;
243
311
  }
244
- submit(lines.concat(getLiveLine()).join('\n'));
245
- }
246
- else if (key.name === 'escape') {
247
- doCancel();
312
+ submit(buildSubmitValue());
248
313
  }
249
314
  };
250
315
  input.on('keypress', keypressListener);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levu/snap",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "snap": "./dist/cli-entry.js"