@levu/snap 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -19,6 +19,7 @@ For module/tool authors, Snap also exposes optional DX helper groups:
19
19
  - Enforces action triad at registration: `tui + commandline + help`
20
20
  - Uses one runtime engine for TUI and CLI paths
21
21
  - Uses Clack-powered prompt adapters for interactive TUI (`select`, `text`, `confirm`, `multiselect`)
22
+ - Text prompts support clipboard paste and multiline input for pasting multiple lines
22
23
  - Supports workflow transitions: `next`, `back`, `jump`, `exit`
23
24
  - Supports resume checkpoints for interrupted flows
24
25
  - Produces stable help output hierarchy:
package/dist/index.d.ts CHANGED
@@ -16,4 +16,6 @@ export { createSpinner, spinner } from './tui/component-adapters/spinner.js';
16
16
  export type { Spinner, SpinnerOptions } from './tui/component-adapters/spinner.js';
17
17
  export { runPasswordPrompt } from './tui/component-adapters/password.js';
18
18
  export type { PasswordPromptInput } from './tui/component-adapters/password.js';
19
+ export { createMultilineTextPrompt } from './tui/component-adapters/multiline-text.js';
20
+ export type { MultilineTextOptions } from './tui/component-adapters/multiline-text.js';
19
21
  export declare const createRegistry: (modules: ModuleContract[]) => ActionRegistry;
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { createPromptToolkit } from './tui/prompt-toolkit.js';
10
10
  export { runCustomPrompt, createCustomPromptRunner } from './tui/custom/index.js';
11
11
  export { createSpinner, spinner } from './tui/component-adapters/spinner.js';
12
12
  export { runPasswordPrompt } from './tui/component-adapters/password.js';
13
+ export { createMultilineTextPrompt } from './tui/component-adapters/multiline-text.js';
13
14
  export const createRegistry = (modules) => {
14
15
  const registry = new ActionRegistry();
15
16
  for (const moduleContract of modules) {
@@ -0,0 +1,13 @@
1
+ import { Readable } from 'node:stream';
2
+ import { Writable } from 'node:stream';
3
+ export interface MultilineTextOptions {
4
+ message: string;
5
+ initialValue?: string;
6
+ placeholder?: string;
7
+ validate?: (value: string | undefined) => string | Error | undefined;
8
+ allowPaste?: boolean;
9
+ input?: Readable;
10
+ output?: Writable;
11
+ signal?: AbortSignal;
12
+ }
13
+ export declare const createMultilineTextPrompt: () => (opts: MultilineTextOptions) => Promise<string | symbol>;
@@ -0,0 +1,166 @@
1
+ import { createInterface } from 'node:readline';
2
+ import * as pc from 'picocolors';
3
+ export const createMultilineTextPrompt = () => {
4
+ return async (opts) => {
5
+ const { message, initialValue = '', placeholder = '', validate, allowPaste = false, input = process.stdin, output = process.stdout, signal, } = opts;
6
+ // Use standard text prompt for single line paste
7
+ if (!allowPaste) {
8
+ const { text: textPrompt } = await import('@clack/prompts');
9
+ return textPrompt({
10
+ message,
11
+ initialValue,
12
+ placeholder,
13
+ validate,
14
+ input,
15
+ output,
16
+ signal,
17
+ });
18
+ }
19
+ // For multiline paste support, use a custom readline-based approach
20
+ return new Promise((resolve, reject) => {
21
+ const rl = createInterface({
22
+ input,
23
+ output,
24
+ terminal: true,
25
+ });
26
+ let value = initialValue;
27
+ let cancelled = false;
28
+ const cleanup = () => {
29
+ rl.close();
30
+ };
31
+ const submit = (val) => {
32
+ cleanup();
33
+ resolve(val);
34
+ };
35
+ const doCancel = () => {
36
+ cancelled = true;
37
+ cleanup();
38
+ const { isCancel: cancelSymbol } = require('@clack/prompts');
39
+ resolve(cancelSymbol);
40
+ };
41
+ // Show instructions
42
+ output.write(`\n${pc.cyan('○')} ${pc.bold(message)}\n`);
43
+ if (allowPaste) {
44
+ output.write(pc.dim(` Paste support: Ctrl+V to paste (macOS/Linux: Cmd+Shift+V)\n`));
45
+ }
46
+ output.write(pc.dim(` Press Enter twice or Alt+Enter to submit\n`));
47
+ const lines = value.split('\n');
48
+ let currentLine = lines.length > 0 ? lines.pop() : '';
49
+ const showPrompt = () => {
50
+ output.write(`\n${pc.dim('> ')}${currentLine}`);
51
+ };
52
+ showPrompt();
53
+ // Handle paste from clipboard
54
+ const handlePaste = async () => {
55
+ try {
56
+ const { execSync } = await import('node:child_process');
57
+ const platform = process.platform;
58
+ if (platform === 'darwin') {
59
+ return execSync('pbpaste', { encoding: 'utf-8' });
60
+ }
61
+ else if (platform === 'win32') {
62
+ return execSync('powershell -command "Get-Clipboard"', { encoding: 'utf-8', shell: true }).trim();
63
+ }
64
+ else if (platform === 'linux') {
65
+ try {
66
+ return execSync('xclip -selection clipboard -o', {
67
+ encoding: 'utf-8',
68
+ });
69
+ }
70
+ catch {
71
+ try {
72
+ return execSync('xsel --clipboard --output', {
73
+ encoding: 'utf-8',
74
+ });
75
+ }
76
+ catch {
77
+ return '';
78
+ }
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ // Silent fail if clipboard is unavailable
84
+ }
85
+ return '';
86
+ };
87
+ let lastEnterTime = 0;
88
+ const DOUBLE_ENTER_TIMEOUT = 500; // ms
89
+ rl.on('line', (line) => {
90
+ if (cancelled)
91
+ return;
92
+ const now = Date.now();
93
+ // Check for double Enter to submit
94
+ if (line === '' && now - lastEnterTime < DOUBLE_ENTER_TIMEOUT) {
95
+ submit(lines.join('\n') + currentLine);
96
+ return;
97
+ }
98
+ lastEnterTime = now;
99
+ if (line.trim() === '') {
100
+ // Empty line - add to lines
101
+ if (currentLine !== '') {
102
+ lines.push(currentLine);
103
+ currentLine = '';
104
+ }
105
+ }
106
+ else {
107
+ // Non-empty line
108
+ if (currentLine !== '') {
109
+ lines.push(currentLine);
110
+ }
111
+ currentLine = line;
112
+ }
113
+ showPrompt();
114
+ });
115
+ // Handle SIGINT (Ctrl+C)
116
+ rl.on('SIGINT', () => {
117
+ doCancel();
118
+ });
119
+ // Handle signal
120
+ if (signal) {
121
+ signal.addEventListener('abort', () => {
122
+ doCancel();
123
+ });
124
+ }
125
+ // Handle paste via keyboard shortcut
126
+ if (allowPaste && input.setRawMode) {
127
+ input.setRawMode(true);
128
+ input.resume();
129
+ input.on('keypress', async (str, key) => {
130
+ if (cancelled)
131
+ return;
132
+ // Detect Ctrl+V or Cmd+V for paste
133
+ if ((key.ctrl && key.name === 'v') || (key.meta && key.name === 'v')) {
134
+ const pasted = await handlePaste();
135
+ if (pasted) {
136
+ // Clear current line and show pasted content
137
+ output.write('\r' + ' '.repeat(process.stdout.columns || 80) + '\r');
138
+ const pastedLines = pasted.split('\n');
139
+ if (pastedLines.length > 1) {
140
+ // Multiline paste
141
+ lines.push(...pastedLines.slice(0, -1));
142
+ currentLine = pastedLines[pastedLines.length - 1];
143
+ }
144
+ else {
145
+ // Single line paste
146
+ currentLine += pasted;
147
+ }
148
+ output.write(`${pc.dim('> ')}${currentLine}`);
149
+ }
150
+ }
151
+ else if (key.alt && key.name === 'enter') {
152
+ // Alt+Enter to submit
153
+ submit(lines.join('\n') + currentLine);
154
+ }
155
+ else if (key.name === 'escape') {
156
+ doCancel();
157
+ }
158
+ });
159
+ }
160
+ // Handle non-interactive terminal
161
+ if (!input.isTTY) {
162
+ submit(initialValue);
163
+ }
164
+ });
165
+ };
166
+ };
@@ -1,10 +1,21 @@
1
1
  export interface SpinnerOptions {
2
2
  message?: string;
3
+ indicator?: 'dots' | 'timer';
4
+ onCancel?: () => void;
5
+ cancelMessage?: string;
6
+ errorMessage?: string;
7
+ frames?: string[];
8
+ delay?: number;
9
+ styleFrame?: (frame: string) => string;
3
10
  }
4
11
  export interface Spinner {
5
12
  start(message?: string): void;
6
13
  stop(message?: string): void;
14
+ cancel(message?: string): void;
15
+ error(message?: string): void;
7
16
  message(message: string): void;
17
+ clear(): void;
18
+ readonly isCancelled: boolean;
8
19
  }
9
20
  export declare const createSpinner: (options?: SpinnerOptions) => Spinner;
10
21
  export declare const spinner: (options?: SpinnerOptions) => Spinner;
@@ -4,6 +4,7 @@ export const createSpinner = (options = {}) => {
4
4
  // Non-interactive fallback
5
5
  if (!isInteractiveTerminal()) {
6
6
  let currentMessage = options.message ?? '';
7
+ let cancelled = false;
7
8
  return {
8
9
  start(message) {
9
10
  currentMessage = message ?? currentMessage;
@@ -16,13 +17,28 @@ export const createSpinner = (options = {}) => {
16
17
  process.stdout.write(`${message}\n`);
17
18
  }
18
19
  },
20
+ cancel(message) {
21
+ cancelled = true;
22
+ const msg = message || options.cancelMessage || 'Cancelled';
23
+ process.stdout.write(`${msg}\n`);
24
+ },
25
+ error(message) {
26
+ const msg = message || options.errorMessage || 'Error';
27
+ process.stderr.write(`${msg}\n`);
28
+ },
19
29
  message(newMessage) {
20
30
  currentMessage = newMessage;
31
+ },
32
+ clear() {
33
+ // No-op in non-interactive mode
34
+ },
35
+ get isCancelled() {
36
+ return cancelled;
21
37
  }
22
38
  };
23
39
  }
24
40
  // Interactive spinner using @clack/prompts
25
- const internalSpinner = clackSpinner();
41
+ const internalSpinner = clackSpinner(options);
26
42
  return {
27
43
  start(message) {
28
44
  if (message) {
@@ -31,6 +47,9 @@ export const createSpinner = (options = {}) => {
31
47
  else if (options.message) {
32
48
  internalSpinner.start(options.message);
33
49
  }
50
+ else {
51
+ internalSpinner.start();
52
+ }
34
53
  },
35
54
  stop(message) {
36
55
  if (message) {
@@ -40,8 +59,30 @@ export const createSpinner = (options = {}) => {
40
59
  internalSpinner.stop();
41
60
  }
42
61
  },
62
+ cancel(message) {
63
+ if (message) {
64
+ internalSpinner.cancel(message);
65
+ }
66
+ else {
67
+ internalSpinner.cancel();
68
+ }
69
+ },
70
+ error(message) {
71
+ if (message) {
72
+ internalSpinner.error(message);
73
+ }
74
+ else {
75
+ internalSpinner.error();
76
+ }
77
+ },
43
78
  message(newMessage) {
44
79
  internalSpinner.message(newMessage);
80
+ },
81
+ clear() {
82
+ internalSpinner.clear();
83
+ },
84
+ get isCancelled() {
85
+ return internalSpinner.isCancelled;
45
86
  }
46
87
  };
47
88
  };
@@ -4,5 +4,9 @@ export interface TextPromptInput {
4
4
  required?: boolean;
5
5
  placeholder?: string;
6
6
  validate?: (value: string) => string | Error | undefined;
7
+ /** Enable paste support for text input. When true, allows pasting single or multiple lines of text. */
8
+ paste?: boolean;
9
+ /** When paste is enabled, allow multiple lines of input. Defaults to true when paste is enabled. */
10
+ multiline?: boolean;
7
11
  }
8
12
  export declare const runTextPrompt: (input: TextPromptInput) => Promise<string>;
@@ -1,6 +1,7 @@
1
1
  import { text } from '@clack/prompts';
2
2
  import { isInteractiveTerminal } from './readline-utils.js';
3
3
  import { unwrapClackResult } from './cancel.js';
4
+ import { createMultilineTextPrompt } from './multiline-text.js';
4
5
  export const runTextPrompt = async (input) => {
5
6
  const fallbackValue = input.initialValue ?? '';
6
7
  if (!isInteractiveTerminal()) {
@@ -9,6 +10,23 @@ export const runTextPrompt = async (input) => {
9
10
  }
10
11
  return fallbackValue;
11
12
  }
13
+ // Use multiline prompt when paste is enabled or multiline is explicitly requested
14
+ if (input.paste || input.multiline) {
15
+ const multilinePrompt = createMultilineTextPrompt();
16
+ const value = await multilinePrompt({
17
+ message: input.message,
18
+ initialValue: input.initialValue,
19
+ placeholder: input.placeholder,
20
+ validate: (raw) => {
21
+ if (input.required && (!raw || raw.trim().length === 0)) {
22
+ return `Required text value missing: ${input.message}`;
23
+ }
24
+ return input.validate?.(raw ?? '');
25
+ },
26
+ allowPaste: input.paste ?? false
27
+ });
28
+ return unwrapClackResult(value);
29
+ }
12
30
  const value = await text({
13
31
  message: input.message,
14
32
  initialValue: input.initialValue,
@@ -18,7 +18,7 @@ interface PromptToolkit {
18
18
  ```
19
19
 
20
20
  ### text
21
- Single-line text input with validation.
21
+ Single-line text input with validation and optional paste/multiline support.
22
22
 
23
23
  ```typescript
24
24
  const name = await context.prompts.text({
@@ -32,6 +32,34 @@ const name = await context.prompts.text({
32
32
  });
33
33
  ```
34
34
 
35
+ **With Paste Support:**
36
+ ```typescript
37
+ const description = await context.prompts.text({
38
+ message: 'Enter a description:',
39
+ paste: true, // Enable clipboard paste (Ctrl+V / Cmd+V)
40
+ multiline: true, // Allow multiple lines of input
41
+ placeholder: 'Paste or type your description here...'
42
+ });
43
+ ```
44
+
45
+ **Options:**
46
+ - `message` (required): The prompt message
47
+ - `placeholder`: Placeholder text when input is empty
48
+ - `defaultValue` / `initialValue`: Initial value
49
+ - `required`: Whether input is required (default: false)
50
+ - `validate`: Custom validation function
51
+ - `paste`: Enable clipboard paste support (default: false)
52
+ - `multiline`: Allow multiple lines of input (default: false, implied by paste: true)
53
+
54
+ **Paste Support Details:**
55
+ When `paste` is enabled, users can paste from clipboard using:
56
+ - macOS/Linux: `Cmd+V` or `Ctrl+V`
57
+ - Windows: `Ctrl+V`
58
+
59
+ Multi-line paste is automatically supported. Submit with:
60
+ - `Alt+Enter` or `Cmd+Enter`
61
+ - Double `Enter` (press Enter twice quickly)
62
+
35
63
  ### confirm
36
64
  Yes/no confirmation prompt.
37
65
 
@@ -125,12 +153,66 @@ spinner.message('Still working...');
125
153
  spinner.stop('Complete!');
126
154
  ```
127
155
 
156
+ **⚠️ Important: Avoid Rapid Message Updates**
157
+
158
+ Updating the spinner message too frequently (multiple times per second) can cause UI tearing and rendering issues. The spinner uses ANSI escape codes to update in-place, and rapid updates can overwhelm the terminal.
159
+
160
+ **❌ AVOID - This causes UI tearing:**
161
+ ```typescript
162
+ // DON'T: Update in a tight loop
163
+ for (let i = 0; i < 1000; i++) {
164
+ spinner.message(`Processing item ${i}`); // Too fast!
165
+ await processItem(i);
166
+ }
167
+ ```
168
+
169
+ **✅ RECOMMENDED - Batch updates or throttle:**
170
+ ```typescript
171
+ // DO: Update at reasonable intervals
172
+ for (let i = 0; i < 1000; i++) {
173
+ // Only update every 100 items
174
+ if (i % 100 === 0) {
175
+ spinner.message(`Processing item ${i}`);
176
+ }
177
+ await processItem(i);
178
+ }
179
+ spinner.stop(`Processed ${1000} items`);
180
+ ```
181
+
182
+ **✅ Alternative - Use percentage for progress:**
183
+ ```typescript
184
+ const total = 1000;
185
+ for (let i = 0; i < total; i++) {
186
+ // Update every 5% progress
187
+ if (i % Math.floor(total * 0.05) === 0) {
188
+ const percent = Math.floor((i / total) * 100);
189
+ spinner.message(`Processing... ${percent}%`);
190
+ }
191
+ await processItem(i);
192
+ }
193
+ ```
194
+
128
195
  **Interface:**
129
196
  ```typescript
130
197
  interface Spinner {
131
198
  start(message?: string): void;
132
199
  stop(message?: string): void;
200
+ cancel(message?: string): void;
201
+ error(message?: string): void;
133
202
  message(message: string): void;
203
+ clear(): void;
204
+ readonly isCancelled: boolean;
205
+ }
206
+
207
+ interface SpinnerOptions {
208
+ message?: string;
209
+ indicator?: 'dots' | 'timer';
210
+ onCancel?: () => void;
211
+ cancelMessage?: string;
212
+ errorMessage?: string;
213
+ frames?: string[];
214
+ delay?: number;
215
+ styleFrame?: (frame: string) => string;
134
216
  }
135
217
  ```
136
218
 
@@ -328,6 +410,7 @@ context.terminal.error('Error message');
328
410
  | Component | Import From | Also Available Via |
329
411
  |-----------|-------------|-------------------|
330
412
  | text, confirm, select, multiselect, group, custom | `createPromptToolkit()` | `context.prompts` |
413
+ | text with paste/multiline support | `'snap-framework'` | `context.prompts.text({ paste: true, multiline: true })` |
331
414
  | createSpinner, spinner | `'snap-framework'` | `SnapTui.createSpinner` |
332
415
  | runPasswordPrompt | `'snap-framework'` | Direct import only |
333
416
  | createProgress, progress | - | `SnapTui.createProgress` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levu/snap",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "snap": "./dist/cli-entry.js"