@levu/snap 0.3.1 → 0.3.3

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.3] - 2026-02-26
9
+
10
+ ### Fixed
11
+ - Added a robust `Shift+Enter` fallback for Ghostty/macOS when terminals emit literal suffix tokens like `13~` via line events without keypress modifiers.
12
+ - Kept `Enter` submit behavior intact while allowing newline insertion from both keypress and fallback sequence paths.
13
+ - Replaced raw `@clack/prompts` password rendering with a stable raw-mode password input path to avoid visual cursor/glyph jitter (including the 3-character middle-glyph jump case).
14
+ - Added regression tests for Ghostty `13~` fallback handling and password input behavior.
15
+
16
+ ## [0.3.2] - 2026-02-26
17
+
18
+ ### Fixed
19
+ - Added Ghostty macOS fallback handling for `Shift+Enter` escape sequence variants like `13~`, so multiline prompts insert newline instead of echoing sequence text.
20
+ - Added regression coverage for the `13~` modified-enter sequence path.
21
+
8
22
  ## [0.3.1] - 2026-02-26
9
23
 
10
24
  ### Fixed
@@ -28,6 +28,7 @@ export const createMultilineTextPrompt = () => {
28
28
  let rawModeEnabled = false;
29
29
  let keypressListener;
30
30
  let ignoreNextLineEvent = false;
31
+ let expectingGhosttySequenceEcho = false;
31
32
  const cleanup = () => {
32
33
  if (keypressListener) {
33
34
  input.off('keypress', keypressListener);
@@ -62,6 +63,46 @@ export const createMultilineTextPrompt = () => {
62
63
  return rlLine;
63
64
  return currentLine;
64
65
  };
66
+ const isGhosttyShiftEnter = (str, key) => {
67
+ const sequence = String(key?.sequence ?? '');
68
+ if (sequence.includes('[13;2u'))
69
+ return true;
70
+ if (sequence.includes('[27;2;13~'))
71
+ return true;
72
+ if (sequence.endsWith('13~'))
73
+ return true;
74
+ if (sequence === '13~')
75
+ return true;
76
+ if (str === '~13')
77
+ return true;
78
+ if (str === '13~')
79
+ return true;
80
+ if (str === '\u001b[13;2u')
81
+ return true;
82
+ return false;
83
+ };
84
+ const stripGhosttyShiftEnterSuffix = (line) => {
85
+ const suffixes = [
86
+ '\u001b[13;2u',
87
+ '[13;2u',
88
+ '\u001b[27;2;13~',
89
+ '[27;2;13~',
90
+ '13~',
91
+ '~13'
92
+ ];
93
+ for (const suffix of suffixes) {
94
+ if (line.endsWith(suffix)) {
95
+ return line.slice(0, -suffix.length);
96
+ }
97
+ }
98
+ return null;
99
+ };
100
+ const insertNewline = () => {
101
+ lines.push(getLiveLine());
102
+ currentLine = '';
103
+ rl.line = '';
104
+ output.write(`\n${pc.dim('> ')}`);
105
+ };
65
106
  const showPrompt = () => {
66
107
  output.write(`\n${pc.dim('> ')}${currentLine}`);
67
108
  };
@@ -109,6 +150,23 @@ export const createMultilineTextPrompt = () => {
109
150
  ignoreNextLineEvent = false;
110
151
  return;
111
152
  }
153
+ if (expectingGhosttySequenceEcho) {
154
+ if (line === '13~' || line === '~13' || line === '[13;2u' || line === '\u001b[13;2u') {
155
+ expectingGhosttySequenceEcho = false;
156
+ return;
157
+ }
158
+ expectingGhosttySequenceEcho = false;
159
+ }
160
+ // Some terminals (notably Ghostty on macOS) may emit Shift+Enter as literal
161
+ // suffix text like "13~" without keypress modifier metadata.
162
+ const ghosttySuffixTrimmedLine = stripGhosttyShiftEnterSuffix(line);
163
+ if (ghosttySuffixTrimmedLine !== null) {
164
+ lines.push(ghosttySuffixTrimmedLine);
165
+ currentLine = '';
166
+ rl.line = '';
167
+ showPrompt();
168
+ return;
169
+ }
112
170
  const now = Date.now();
113
171
  // Check for double Enter to submit
114
172
  if (line === '' && now - lastEnterTime < DOUBLE_ENTER_TIMEOUT) {
@@ -171,14 +229,15 @@ export const createMultilineTextPrompt = () => {
171
229
  output.write(`${pc.dim('> ')}${currentLine}`);
172
230
  }
173
231
  }
232
+ else if (isGhosttyShiftEnter(str, key)) {
233
+ expectingGhosttySequenceEcho = true;
234
+ insertNewline();
235
+ }
174
236
  else if (key.name === 'enter' || key.name === 'return') {
175
237
  ignoreNextLineEvent = true;
176
238
  if (key.shift || key.alt) {
177
239
  // Shift+Enter / Alt+Enter inserts a new line.
178
- lines.push(getLiveLine());
179
- currentLine = '';
180
- rl.line = '';
181
- output.write(`\n${pc.dim('> ')}`);
240
+ insertNewline();
182
241
  return;
183
242
  }
184
243
  submit(lines.concat(getLiveLine()).join('\n'));
@@ -1,7 +1,11 @@
1
+ import type { Readable, Writable } from 'node:stream';
1
2
  export interface PasswordPromptInput {
2
3
  message: string;
3
4
  required?: boolean;
4
5
  validate?: (value: string) => string | Error | undefined;
5
6
  mask?: string;
7
+ input?: Readable;
8
+ output?: Writable;
9
+ signal?: AbortSignal;
6
10
  }
7
11
  export declare const runPasswordPrompt: (input: PasswordPromptInput) => Promise<string>;
@@ -1,8 +1,12 @@
1
1
  import { password as clackPassword } from '@clack/prompts';
2
- import { isInteractiveTerminal } from './readline-utils.js';
3
- import { unwrapClackResult } from './cancel.js';
2
+ import { emitKeypressEvents } from 'node:readline';
3
+ import * as pc from 'picocolors';
4
+ import { PromptCancelledError, unwrapClackResult } from './cancel.js';
4
5
  export const runPasswordPrompt = async (input) => {
5
- if (!isInteractiveTerminal()) {
6
+ const inStream = (input.input ?? process.stdin);
7
+ const outStream = (input.output ?? process.stdout);
8
+ const isInteractive = Boolean(inStream?.isTTY && outStream?.isTTY);
9
+ if (!isInteractive) {
6
10
  // For non-interactive terminals, read from stdin in a secure way
7
11
  // or return empty/throw error
8
12
  if (input.required) {
@@ -10,15 +14,104 @@ export const runPasswordPrompt = async (input) => {
10
14
  }
11
15
  return '';
12
16
  }
13
- const value = await clackPassword({
14
- message: input.message,
15
- mask: input.mask ?? '•',
16
- validate: (raw) => {
17
- if (input.required && (!raw || raw.trim().length === 0)) {
18
- return `Password is required`;
19
- }
20
- return input.validate?.(raw ?? '');
17
+ const mask = input.mask && input.mask.length > 0 ? input.mask : '*';
18
+ const validateValue = (value) => {
19
+ if (input.required && value.trim().length === 0) {
20
+ return 'Password is required';
21
+ }
22
+ const result = input.validate?.(value);
23
+ if (!result)
24
+ return undefined;
25
+ return result instanceof Error ? result.message : String(result);
26
+ };
27
+ // Fallback to clack implementation if raw mode is unavailable.
28
+ if (typeof inStream.setRawMode !== 'function') {
29
+ const value = await clackPassword({
30
+ message: input.message,
31
+ mask,
32
+ validate: (raw) => validateValue(raw ?? ''),
33
+ input: inStream,
34
+ output: outStream,
35
+ signal: input.signal
36
+ });
37
+ return unwrapClackResult(value);
38
+ }
39
+ const value = await new Promise((resolve, reject) => {
40
+ let password = '';
41
+ let rawModeEnabled = false;
42
+ let keypressListener;
43
+ const clearLine = () => {
44
+ outStream.write('\r\x1b[2K');
45
+ };
46
+ const render = () => {
47
+ clearLine();
48
+ outStream.write(`${pc.dim('> ')}${mask.repeat(password.length)}`);
49
+ };
50
+ const cleanup = () => {
51
+ if (keypressListener && typeof inStream.off === 'function') {
52
+ inStream.off('keypress', keypressListener);
53
+ }
54
+ if (rawModeEnabled) {
55
+ inStream.setRawMode?.(false);
56
+ rawModeEnabled = false;
57
+ }
58
+ };
59
+ const cancel = () => {
60
+ cleanup();
61
+ reject(new PromptCancelledError('Cancelled by user.'));
62
+ };
63
+ const submit = () => {
64
+ const validationError = validateValue(password);
65
+ if (validationError) {
66
+ outStream.write(`\n${pc.yellow('!')} ${validationError}\n`);
67
+ render();
68
+ return;
69
+ }
70
+ cleanup();
71
+ outStream.write('\n');
72
+ resolve(password);
73
+ };
74
+ outStream.write(`\n${pc.cyan('○')} ${pc.bold(input.message)}\n`);
75
+ outStream.write(`${pc.dim('> ')}`);
76
+ emitKeypressEvents(inStream);
77
+ inStream.setRawMode?.(true);
78
+ rawModeEnabled = true;
79
+ inStream.resume?.();
80
+ keypressListener = (str, key) => {
81
+ if (key?.ctrl && key.name === 'c') {
82
+ cancel();
83
+ return;
84
+ }
85
+ if (key?.name === 'escape') {
86
+ cancel();
87
+ return;
88
+ }
89
+ if (key?.name === 'enter' || key?.name === 'return') {
90
+ submit();
91
+ return;
92
+ }
93
+ if (key?.name === 'backspace') {
94
+ if (password.length > 0) {
95
+ password = password.slice(0, -1);
96
+ render();
97
+ }
98
+ return;
99
+ }
100
+ if (key?.ctrl || key?.meta) {
101
+ return;
102
+ }
103
+ if (!str || /[\u0000-\u001f\u007f]/.test(str)) {
104
+ return;
105
+ }
106
+ password += str;
107
+ render();
108
+ };
109
+ inStream.on('keypress', keypressListener);
110
+ if (input.signal) {
111
+ input.signal.addEventListener('abort', () => {
112
+ cancel();
113
+ }, { once: true });
21
114
  }
22
115
  });
23
- return unwrapClackResult(value);
116
+ return value;
24
117
  };
@@ -58,7 +58,7 @@ When `paste` is enabled, users can paste from clipboard using:
58
58
 
59
59
  Multi-line paste is automatically supported. Keyboard behavior:
60
60
  - `Enter` submits input
61
- - `Shift+Enter` inserts a newline (when your terminal reports Shift modifiers)
61
+ - `Shift+Enter` inserts a newline (including Ghostty/macOS fallback sequences like `13~`)
62
62
  - `Alt+Enter` inserts a newline fallback
63
63
 
64
64
  ### confirm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levu/snap",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "snap": "./dist/cli-entry.js"