@levu/snap 0.3.2 → 0.3.4

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.4] - 2026-02-26
9
+
10
+ ### Fixed
11
+ - Fixed `picocolors` ESM/CJS interop in multiline and password adapters to prevent runtime errors like `pc.cyan is not a function`.
12
+ - Keeps Ghostty `Shift+Enter` (`13~`) fallback and custom password input fixes from `0.3.3`.
13
+
14
+ ## [0.3.3] - 2026-02-26
15
+
16
+ ### Fixed
17
+ - Added a robust `Shift+Enter` fallback for Ghostty/macOS when terminals emit literal suffix tokens like `13~` via line events without keypress modifiers.
18
+ - Kept `Enter` submit behavior intact while allowing newline insertion from both keypress and fallback sequence paths.
19
+ - 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).
20
+ - Added regression tests for Ghostty `13~` fallback handling and password input behavior.
21
+
8
22
  ## [0.3.2] - 2026-02-26
9
23
 
10
24
  ### Fixed
@@ -1,5 +1,6 @@
1
1
  import { createInterface, emitKeypressEvents } from 'node:readline';
2
- import * as pc from 'picocolors';
2
+ import pcModule from 'picocolors';
3
+ const pc = pcModule?.default ?? pcModule;
3
4
  export const createMultilineTextPrompt = () => {
4
5
  return async (opts) => {
5
6
  const { message, initialValue = '', placeholder = '', validate, allowPaste = false, input = process.stdin, output = process.stdout, signal, } = opts;
@@ -81,6 +82,22 @@ export const createMultilineTextPrompt = () => {
81
82
  return true;
82
83
  return false;
83
84
  };
85
+ const stripGhosttyShiftEnterSuffix = (line) => {
86
+ const suffixes = [
87
+ '\u001b[13;2u',
88
+ '[13;2u',
89
+ '\u001b[27;2;13~',
90
+ '[27;2;13~',
91
+ '13~',
92
+ '~13'
93
+ ];
94
+ for (const suffix of suffixes) {
95
+ if (line.endsWith(suffix)) {
96
+ return line.slice(0, -suffix.length);
97
+ }
98
+ }
99
+ return null;
100
+ };
84
101
  const insertNewline = () => {
85
102
  lines.push(getLiveLine());
86
103
  currentLine = '';
@@ -141,6 +158,16 @@ export const createMultilineTextPrompt = () => {
141
158
  }
142
159
  expectingGhosttySequenceEcho = false;
143
160
  }
161
+ // Some terminals (notably Ghostty on macOS) may emit Shift+Enter as literal
162
+ // suffix text like "13~" without keypress modifier metadata.
163
+ const ghosttySuffixTrimmedLine = stripGhosttyShiftEnterSuffix(line);
164
+ if (ghosttySuffixTrimmedLine !== null) {
165
+ lines.push(ghosttySuffixTrimmedLine);
166
+ currentLine = '';
167
+ rl.line = '';
168
+ showPrompt();
169
+ return;
170
+ }
144
171
  const now = Date.now();
145
172
  // Check for double Enter to submit
146
173
  if (line === '' && now - lastEnterTime < DOUBLE_ENTER_TIMEOUT) {
@@ -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,13 @@
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 pcModule from 'picocolors';
4
+ import { PromptCancelledError, unwrapClackResult } from './cancel.js';
5
+ const pc = pcModule?.default ?? pcModule;
4
6
  export const runPasswordPrompt = async (input) => {
5
- if (!isInteractiveTerminal()) {
7
+ const inStream = (input.input ?? process.stdin);
8
+ const outStream = (input.output ?? process.stdout);
9
+ const isInteractive = Boolean(inStream?.isTTY && outStream?.isTTY);
10
+ if (!isInteractive) {
6
11
  // For non-interactive terminals, read from stdin in a secure way
7
12
  // or return empty/throw error
8
13
  if (input.required) {
@@ -10,15 +15,104 @@ export const runPasswordPrompt = async (input) => {
10
15
  }
11
16
  return '';
12
17
  }
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 ?? '');
18
+ const mask = input.mask && input.mask.length > 0 ? input.mask : '*';
19
+ const validateValue = (value) => {
20
+ if (input.required && value.trim().length === 0) {
21
+ return 'Password is required';
22
+ }
23
+ const result = input.validate?.(value);
24
+ if (!result)
25
+ return undefined;
26
+ return result instanceof Error ? result.message : String(result);
27
+ };
28
+ // Fallback to clack implementation if raw mode is unavailable.
29
+ if (typeof inStream.setRawMode !== 'function') {
30
+ const value = await clackPassword({
31
+ message: input.message,
32
+ mask,
33
+ validate: (raw) => validateValue(raw ?? ''),
34
+ input: inStream,
35
+ output: outStream,
36
+ signal: input.signal
37
+ });
38
+ return unwrapClackResult(value);
39
+ }
40
+ const value = await new Promise((resolve, reject) => {
41
+ let password = '';
42
+ let rawModeEnabled = false;
43
+ let keypressListener;
44
+ const clearLine = () => {
45
+ outStream.write('\r\x1b[2K');
46
+ };
47
+ const render = () => {
48
+ clearLine();
49
+ outStream.write(`${pc.dim('> ')}${mask.repeat(password.length)}`);
50
+ };
51
+ const cleanup = () => {
52
+ if (keypressListener && typeof inStream.off === 'function') {
53
+ inStream.off('keypress', keypressListener);
54
+ }
55
+ if (rawModeEnabled) {
56
+ inStream.setRawMode?.(false);
57
+ rawModeEnabled = false;
58
+ }
59
+ };
60
+ const cancel = () => {
61
+ cleanup();
62
+ reject(new PromptCancelledError('Cancelled by user.'));
63
+ };
64
+ const submit = () => {
65
+ const validationError = validateValue(password);
66
+ if (validationError) {
67
+ outStream.write(`\n${pc.yellow('!')} ${validationError}\n`);
68
+ render();
69
+ return;
70
+ }
71
+ cleanup();
72
+ outStream.write('\n');
73
+ resolve(password);
74
+ };
75
+ outStream.write(`\n${pc.cyan('○')} ${pc.bold(input.message)}\n`);
76
+ outStream.write(`${pc.dim('> ')}`);
77
+ emitKeypressEvents(inStream);
78
+ inStream.setRawMode?.(true);
79
+ rawModeEnabled = true;
80
+ inStream.resume?.();
81
+ keypressListener = (str, key) => {
82
+ if (key?.ctrl && key.name === 'c') {
83
+ cancel();
84
+ return;
85
+ }
86
+ if (key?.name === 'escape') {
87
+ cancel();
88
+ return;
89
+ }
90
+ if (key?.name === 'enter' || key?.name === 'return') {
91
+ submit();
92
+ return;
93
+ }
94
+ if (key?.name === 'backspace') {
95
+ if (password.length > 0) {
96
+ password = password.slice(0, -1);
97
+ render();
98
+ }
99
+ return;
100
+ }
101
+ if (key?.ctrl || key?.meta) {
102
+ return;
103
+ }
104
+ if (!str || /[\u0000-\u001f\u007f]/.test(str)) {
105
+ return;
106
+ }
107
+ password += str;
108
+ render();
109
+ };
110
+ inStream.on('keypress', keypressListener);
111
+ if (input.signal) {
112
+ input.signal.addEventListener('abort', () => {
113
+ cancel();
114
+ }, { once: true });
21
115
  }
22
116
  });
23
- return unwrapClackResult(value);
117
+ return value;
24
118
  };
@@ -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 (including Ghostty sequence fallback variants)
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.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "snap": "./dist/cli-entry.js"