@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
|
-
|
|
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 {
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return
|
|
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
|
|
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 (
|
|
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
|