@levu/snap 0.2.0 → 0.3.1
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 +8 -0
- package/README.md +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/tui/component-adapters/multiline-text.d.ts +13 -0
- package/dist/tui/component-adapters/multiline-text.js +198 -0
- package/dist/tui/component-adapters/spinner.d.ts +11 -0
- package/dist/tui/component-adapters/spinner.js +42 -1
- package/dist/tui/component-adapters/text.d.ts +4 -0
- package/dist/tui/component-adapters/text.js +18 -0
- package/docs/component-reference.md +85 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ 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.1] - 2026-02-26
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Multiline text prompt now submits on `Enter` in raw input mode, restoring submit behavior in macOS terminals like JetBrains and Ghostty.
|
|
12
|
+
- Added `Shift+Enter` (and `Alt+Enter` fallback) support to insert newline in multiline prompt mode.
|
|
13
|
+
- Restored terminal state reliably by disabling raw mode and detaching keypress listeners on prompt cleanup.
|
|
14
|
+
- Added regression tests covering `Enter` submit and `Shift+Enter` newline behavior for multiline prompts.
|
|
15
|
+
|
|
8
16
|
## [0.2.0] - 2025-02-24
|
|
9
17
|
|
|
10
18
|
### Added
|
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,198 @@
|
|
|
1
|
+
import { createInterface, emitKeypressEvents } 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
|
+
let rawModeEnabled = false;
|
|
29
|
+
let keypressListener;
|
|
30
|
+
let ignoreNextLineEvent = false;
|
|
31
|
+
const cleanup = () => {
|
|
32
|
+
if (keypressListener) {
|
|
33
|
+
input.off('keypress', keypressListener);
|
|
34
|
+
}
|
|
35
|
+
if (rawModeEnabled && input.setRawMode) {
|
|
36
|
+
input.setRawMode(false);
|
|
37
|
+
rawModeEnabled = false;
|
|
38
|
+
}
|
|
39
|
+
rl.close();
|
|
40
|
+
};
|
|
41
|
+
const submit = (val) => {
|
|
42
|
+
cleanup();
|
|
43
|
+
resolve(val);
|
|
44
|
+
};
|
|
45
|
+
const doCancel = () => {
|
|
46
|
+
cancelled = true;
|
|
47
|
+
cleanup();
|
|
48
|
+
const { isCancel: cancelSymbol } = require('@clack/prompts');
|
|
49
|
+
resolve(cancelSymbol);
|
|
50
|
+
};
|
|
51
|
+
// Show instructions
|
|
52
|
+
output.write(`\n${pc.cyan('○')} ${pc.bold(message)}\n`);
|
|
53
|
+
if (allowPaste) {
|
|
54
|
+
output.write(pc.dim(` Paste support: Ctrl+V to paste (macOS/Linux: Cmd+Shift+V)\n`));
|
|
55
|
+
}
|
|
56
|
+
output.write(pc.dim(` Press Enter to submit; Shift+Enter for newline (Alt+Enter fallback)\n`));
|
|
57
|
+
const lines = value.split('\n');
|
|
58
|
+
let currentLine = lines.length > 0 ? lines.pop() : '';
|
|
59
|
+
const getLiveLine = () => {
|
|
60
|
+
const rlLine = typeof rl.line === 'string' ? rl.line : '';
|
|
61
|
+
if (rlLine.length > 0)
|
|
62
|
+
return rlLine;
|
|
63
|
+
return currentLine;
|
|
64
|
+
};
|
|
65
|
+
const showPrompt = () => {
|
|
66
|
+
output.write(`\n${pc.dim('> ')}${currentLine}`);
|
|
67
|
+
};
|
|
68
|
+
showPrompt();
|
|
69
|
+
// Handle paste from clipboard
|
|
70
|
+
const handlePaste = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const { execSync } = await import('node:child_process');
|
|
73
|
+
const platform = process.platform;
|
|
74
|
+
if (platform === 'darwin') {
|
|
75
|
+
return execSync('pbpaste', { encoding: 'utf-8' });
|
|
76
|
+
}
|
|
77
|
+
else if (platform === 'win32') {
|
|
78
|
+
return execSync('powershell -command "Get-Clipboard"', { encoding: 'utf-8', shell: true }).trim();
|
|
79
|
+
}
|
|
80
|
+
else if (platform === 'linux') {
|
|
81
|
+
try {
|
|
82
|
+
return execSync('xclip -selection clipboard -o', {
|
|
83
|
+
encoding: 'utf-8',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
try {
|
|
88
|
+
return execSync('xsel --clipboard --output', {
|
|
89
|
+
encoding: 'utf-8',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Silent fail if clipboard is unavailable
|
|
100
|
+
}
|
|
101
|
+
return '';
|
|
102
|
+
};
|
|
103
|
+
let lastEnterTime = 0;
|
|
104
|
+
const DOUBLE_ENTER_TIMEOUT = 500; // ms
|
|
105
|
+
rl.on('line', (line) => {
|
|
106
|
+
if (cancelled)
|
|
107
|
+
return;
|
|
108
|
+
if (ignoreNextLineEvent) {
|
|
109
|
+
ignoreNextLineEvent = false;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
// Check for double Enter to submit
|
|
114
|
+
if (line === '' && now - lastEnterTime < DOUBLE_ENTER_TIMEOUT) {
|
|
115
|
+
submit(lines.concat(getLiveLine()).join('\n'));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
lastEnterTime = now;
|
|
119
|
+
if (line.trim() === '') {
|
|
120
|
+
// Empty line - add to lines
|
|
121
|
+
if (currentLine !== '') {
|
|
122
|
+
lines.push(currentLine);
|
|
123
|
+
currentLine = '';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Non-empty line
|
|
128
|
+
if (currentLine !== '') {
|
|
129
|
+
lines.push(currentLine);
|
|
130
|
+
}
|
|
131
|
+
currentLine = line;
|
|
132
|
+
}
|
|
133
|
+
showPrompt();
|
|
134
|
+
});
|
|
135
|
+
// Handle SIGINT (Ctrl+C)
|
|
136
|
+
rl.on('SIGINT', () => {
|
|
137
|
+
doCancel();
|
|
138
|
+
});
|
|
139
|
+
// Handle signal
|
|
140
|
+
if (signal) {
|
|
141
|
+
signal.addEventListener('abort', () => {
|
|
142
|
+
doCancel();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Handle paste via keyboard shortcut
|
|
146
|
+
if (allowPaste && input.setRawMode) {
|
|
147
|
+
emitKeypressEvents(input);
|
|
148
|
+
input.setRawMode(true);
|
|
149
|
+
rawModeEnabled = true;
|
|
150
|
+
input.resume();
|
|
151
|
+
keypressListener = async (str, key) => {
|
|
152
|
+
if (cancelled)
|
|
153
|
+
return;
|
|
154
|
+
// Detect Ctrl+V or Cmd+V for paste
|
|
155
|
+
if ((key.ctrl && key.name === 'v') || (key.meta && key.name === 'v')) {
|
|
156
|
+
const pasted = await handlePaste();
|
|
157
|
+
if (pasted) {
|
|
158
|
+
// Clear current line and show pasted content
|
|
159
|
+
output.write('\r' + ' '.repeat(process.stdout.columns || 80) + '\r');
|
|
160
|
+
const pastedLines = pasted.split('\n');
|
|
161
|
+
if (pastedLines.length > 1) {
|
|
162
|
+
// Multiline paste
|
|
163
|
+
lines.push(...pastedLines.slice(0, -1));
|
|
164
|
+
currentLine = pastedLines[pastedLines.length - 1];
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// Single line paste
|
|
168
|
+
currentLine += pasted;
|
|
169
|
+
}
|
|
170
|
+
rl.line = currentLine;
|
|
171
|
+
output.write(`${pc.dim('> ')}${currentLine}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else if (key.name === 'enter' || key.name === 'return') {
|
|
175
|
+
ignoreNextLineEvent = true;
|
|
176
|
+
if (key.shift || key.alt) {
|
|
177
|
+
// Shift+Enter / Alt+Enter inserts a new line.
|
|
178
|
+
lines.push(getLiveLine());
|
|
179
|
+
currentLine = '';
|
|
180
|
+
rl.line = '';
|
|
181
|
+
output.write(`\n${pc.dim('> ')}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
submit(lines.concat(getLiveLine()).join('\n'));
|
|
185
|
+
}
|
|
186
|
+
else if (key.name === 'escape') {
|
|
187
|
+
doCancel();
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
input.on('keypress', keypressListener);
|
|
191
|
+
}
|
|
192
|
+
// Handle non-interactive terminal
|
|
193
|
+
if (!input.isTTY) {
|
|
194
|
+
submit(initialValue);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
};
|
|
@@ -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,35 @@ 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. Keyboard behavior:
|
|
60
|
+
- `Enter` submits input
|
|
61
|
+
- `Shift+Enter` inserts a newline (when your terminal reports Shift modifiers)
|
|
62
|
+
- `Alt+Enter` inserts a newline fallback
|
|
63
|
+
|
|
35
64
|
### confirm
|
|
36
65
|
Yes/no confirmation prompt.
|
|
37
66
|
|
|
@@ -125,12 +154,66 @@ spinner.message('Still working...');
|
|
|
125
154
|
spinner.stop('Complete!');
|
|
126
155
|
```
|
|
127
156
|
|
|
157
|
+
**⚠️ Important: Avoid Rapid Message Updates**
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
**❌ AVOID - This causes UI tearing:**
|
|
162
|
+
```typescript
|
|
163
|
+
// DON'T: Update in a tight loop
|
|
164
|
+
for (let i = 0; i < 1000; i++) {
|
|
165
|
+
spinner.message(`Processing item ${i}`); // Too fast!
|
|
166
|
+
await processItem(i);
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**✅ RECOMMENDED - Batch updates or throttle:**
|
|
171
|
+
```typescript
|
|
172
|
+
// DO: Update at reasonable intervals
|
|
173
|
+
for (let i = 0; i < 1000; i++) {
|
|
174
|
+
// Only update every 100 items
|
|
175
|
+
if (i % 100 === 0) {
|
|
176
|
+
spinner.message(`Processing item ${i}`);
|
|
177
|
+
}
|
|
178
|
+
await processItem(i);
|
|
179
|
+
}
|
|
180
|
+
spinner.stop(`Processed ${1000} items`);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**✅ Alternative - Use percentage for progress:**
|
|
184
|
+
```typescript
|
|
185
|
+
const total = 1000;
|
|
186
|
+
for (let i = 0; i < total; i++) {
|
|
187
|
+
// Update every 5% progress
|
|
188
|
+
if (i % Math.floor(total * 0.05) === 0) {
|
|
189
|
+
const percent = Math.floor((i / total) * 100);
|
|
190
|
+
spinner.message(`Processing... ${percent}%`);
|
|
191
|
+
}
|
|
192
|
+
await processItem(i);
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
128
196
|
**Interface:**
|
|
129
197
|
```typescript
|
|
130
198
|
interface Spinner {
|
|
131
199
|
start(message?: string): void;
|
|
132
200
|
stop(message?: string): void;
|
|
201
|
+
cancel(message?: string): void;
|
|
202
|
+
error(message?: string): void;
|
|
133
203
|
message(message: string): void;
|
|
204
|
+
clear(): void;
|
|
205
|
+
readonly isCancelled: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface SpinnerOptions {
|
|
209
|
+
message?: string;
|
|
210
|
+
indicator?: 'dots' | 'timer';
|
|
211
|
+
onCancel?: () => void;
|
|
212
|
+
cancelMessage?: string;
|
|
213
|
+
errorMessage?: string;
|
|
214
|
+
frames?: string[];
|
|
215
|
+
delay?: number;
|
|
216
|
+
styleFrame?: (frame: string) => string;
|
|
134
217
|
}
|
|
135
218
|
```
|
|
136
219
|
|
|
@@ -328,6 +411,7 @@ context.terminal.error('Error message');
|
|
|
328
411
|
| Component | Import From | Also Available Via |
|
|
329
412
|
|-----------|-------------|-------------------|
|
|
330
413
|
| text, confirm, select, multiselect, group, custom | `createPromptToolkit()` | `context.prompts` |
|
|
414
|
+
| text with paste/multiline support | `'snap-framework'` | `context.prompts.text({ paste: true, multiline: true })` |
|
|
331
415
|
| createSpinner, spinner | `'snap-framework'` | `SnapTui.createSpinner` |
|
|
332
416
|
| runPasswordPrompt | `'snap-framework'` | Direct import only |
|
|
333
417
|
| createProgress, progress | - | `SnapTui.createProgress` |
|