@benevbright/read-multiline
v0.3.3-beta.1
Published
Simple multi-line input reader for Node.js terminals
Maintainers
Readme
read-multiline
Simple multi-line input reader for Node.js terminals. Solves the limitation of Node.js's built-in readline module which only supports single-line input.
Features
- Enter to submit, Shift+Enter / Ctrl+J to insert newlines (swappable)
- Arrow key cursor navigation across lines
- Alt+Arrow for word jumping / history, Ctrl+Arrow / Cmd+Arrow for line/buffer jumping
- Delete, Ctrl+U, Ctrl+K for forward delete and line editing
- Ctrl+W to delete previous word
- Ctrl+Z / Ctrl+Y for undo/redo
- Ctrl+L to clear screen and redraw
- Full-width (CJK) character support with correct cursor positioning
- Bracketed paste mode for multi-line paste
- Input history navigation (Up/Down at boundaries, Alt+Up/Down, Ctrl+P/N, PageUp/PageDown)
- File-based persistent history with atomic (tmp+rename) save and per-entry
shouldPersistfilter - Initial value pre-population
- Validation with debounced live feedback
- Max lines / max character length enforcement
- Terminal resize (SIGWINCH) handling
- Ctrl+C / Ctrl+D handling
- Non-TTY (pipe) input support
- Theme/style system with state-dependent styling
- Built-in presets for
@inquirer/promptsand@clack/prompts - Optional
inlinePromptto render the prompt and the first input line on the same terminal line createPrompt()factory for reusable shared configuration- Zero dependencies
Best experience with terminals supporting the kitty keyboard protocol (kitty, iTerm2, WezTerm, Ghostty, foot, etc.). Ctrl+J always inserts a newline regardless of settings, serving as a universal fallback in all terminals.
Install
pnpm add @toiroakr/read-multilineUsage
import { readMultiline } from "@toiroakr/read-multiline";
const [value, error] = await readMultiline("Enter your message:", {
history: { filePath: "./history.json" },
maxLines: 10,
maxLength: 1000,
validate: (v) => (v.trim() === "" ? "Input cannot be empty" : undefined),
});
if (error) {
if (error.kind === "cancel") console.log("Cancelled");
else if (error.kind === "eof") console.log("EOF");
} else {
console.log("You entered:", value);
}With presets
import { createPrompt, presets } from "@toiroakr/read-multiline";
// inquirer-style prompt
const askInquirer = createPrompt(presets.inquirer);
const [name] = await askInquirer("What is your name?");
const [bio] = await askInquirer("Tell me about yourself:");
// clack-style prompt
const askClack = createPrompt(presets.clack);
const [input] = await askClack("Enter some text:");API
readMultiline(prompt, options?): Promise<ReadMultilineResult>
Returns a ReadMultilineResult tuple:
[string, null]on success (submitted input)[string, { kind: "cancel", message: "Input cancelled" }]on Ctrl+C (includes partial input)[string, { kind: "eof", message: "EOF received on empty input" }]on Ctrl+D with empty input
| Parameter | Type | Description |
| --------- | -------- | --------------------------------------------- |
| prompt | string | Prompt message on the header line above input |
| Option | Type | Default | Description |
| ------------------------ | ------------------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------- |
| prefix | Stateful<string> | "> " | Prefix before the prompt message. Can be state-dependent |
| linePrefix | Stateful<string> | same as prefix | Prefix for each input line. Can be state-dependent |
| theme | PromptTheme | undefined | Theme for styling prompt elements |
| input | TTYInput | process.stdin | Input stream |
| output | WritableStream | process.stdout | Output stream |
| initialValue | string | undefined | Pre-populate the input |
| history | string[] \| HistoryOptions | [] | History entries or file-based persistent history |
| historyArrowNavigation | "single" \| "double" \| "disabled" | "single" | How Up/Down interacts with history at boundaries |
| maxLines | number | undefined | Maximum number of lines |
| maxLength | number | undefined | Maximum total character count |
| validate | (value: string) => string \| undefined \| null | undefined | Validation function (return error message to reject) |
| validateDebounceMs | number | 300 | Debounce interval for live validation |
| preferNewlineOnEnter | boolean | false | true: Enter=newline, false: Enter=submit |
| disabledKeys | ModifiedEnterKey[] | [] | Key combos to disable |
| clearAfterSubmit | boolean | true | Deprecated. Clear input from terminal after submit. Use theme.submitRender instead |
| footer | string | undefined | Fixed footer text below the editor |
| helpFooter | boolean \| HelpFooterDisplayOptions | true | Auto-generated key bindings help footer |
| inlinePrompt | boolean | false | Render the prompt header and the first input line on the same line. See Inline prompt |
Layout
The prompt renders as two visual areas: a header line and input lines.
[prefix][prompt] ← header line (no input text here)
[linePrefix][line 1] ← all input lines use linePrefix
[linePrefix][line 2]When prompt is empty and prefix is empty, no header line is shown.
Inline prompt
With inlinePrompt: true, the prompt header and the first input line share a single terminal line. Subsequent lines (from Shift+Enter / Ctrl+J) still use linePrefix:
[prefix][prompt][line 1] ← header and first input on the same line
[linePrefix][line 2] ← subsequent lines keep the normal linePrefixInline mode concatenates prefix + prompt + input with no implicit separator — include any desired trailing space in the prompt text. Combine with a Stateful prefix and theme.submitRender: "preserve" to get an inline prompt that transitions its prefix on submit. Subsequent lines (inserted via Shift+Enter / Ctrl+J) are prefixed with linePrefix:
await readMultiline("Bio: ", {
inlinePrompt: true,
prefix: { pending: "> ", submitted: "✔ " },
linePrefix: " ",
theme: { submitRender: "preserve" },
});
// Before typing:
// > Bio:
//
// While editing (after Shift+Enter between lines):
// > Bio: Hello, I'm Tom.
// I like TypeScript.
//
// After submit:
// ✔ Bio: Hello, I'm Tom.
// I like TypeScript.inlinePrompt requires the prompt header to render on a single terminal line. If prefix or prompt (including any Stateful prefix variant) contains a newline, readMultiline throws at call time. See examples/inline-prompt.ts for a full runnable demo.
Stateful<T>
Options like prefix, linePrefix, and theme styles accept a Stateful<T> value — either a plain value or an object with per-state values:
// Plain value (same in all states)
prefix: "> "
// State-dependent values
prefix: {
pending: "? ", // while editing
submitted: "✔ ", // after submission
cancelled: "✘ ", // after Ctrl+C (optional, defaults to pending)
error: "! ", // on validation error (optional, defaults to pending)
}PromptTheme
| Property | Type | Description |
| -------------- | --------------------------- | ------------------------------------------------------ |
| prefix | Stateful<StyleTextFormat> | Style for the prefix text |
| linePrefix | Stateful<StyleTextFormat> | Style for the line prefix text |
| prompt | StyleTextFormat | Style for the prompt message |
| input | StyleTextFormat | Style for user input text while editing |
| answer | StyleTextFormat | Style for the answer text after submission |
| cancelAnswer | StyleTextFormat | Style for the answer text after cancellation |
| submitRender | "clear" \| "preserve" | How to render after submission (default: "clear") |
| cancelRender | "clear" \| "preserve" | How to render after Ctrl+C or EOF (default: "clear") |
| error | StyleTextFormat | Style for validation error messages |
| success | StyleTextFormat | Style for validation success messages |
| footer | StyleTextFormat | Style for footer text |
StyleTextFormat is the format parameter of Node.js's util.styleText() — e.g. "bold", "red", "cyan", ["strikethrough", "dim"].
createPrompt(shared)
Create a reusable prompt function with shared configuration. Per-call options are shallow-merged over the shared config.
import { createPrompt, presets } from "@toiroakr/read-multiline";
const ask = createPrompt(presets.inquirer);
const [name] = await ask("Name:");
const [email] = await ask("Email:");Presets
presets.inquirer
Mimics @inquirer/prompts visual style:
? Enter name: (pending)
John
✔ Enter name: (submitted)
John- Blue
?prefix → green✔on submit - Bold prompt, cyan answer
- Inline help footer: Enter submit • Shift+Enter newline
submitRender: "preserve",cancelRender: "preserve"
presets.clack
Mimics @clack/prompts visual style:
│ (pending)
◆ Enter name:
│ John
│ (submitted)
◇ Enter name:
│ John- Cyan
◆→ green◇on submit, red■on cancel, yellow▲on error - Gray guide bar, dim answer, strikethrough+dim cancel answer
submitRender: "preserve",cancelRender: "preserve"
Note: Ctrl+J (0x0A) always inserts a newline regardless of
preferNewlineOnEnter. WhenpreferNewlineOnEnter: trueis set but the kitty keyboard protocol is not supported, the option automatically falls back tofalseto ensure submit (Enter) and newline (Ctrl+J) are always available.
Key Bindings
The following table shows all key bindings and their availability across terminal types.
Legend: "All" = works in all terminals, "Kitty" = requires kitty keyboard protocol
Submit / Newline
preferNewlineOnEnter (default false) swaps the role of Enter and modified Enter keys. Ctrl+J always inserts a newline regardless of this setting.
| Key | Action (preferNewlineOnEnter: false) | Action (true) | Terminal |
| ----------- | -------------------------------------- | --------------- | ------------- |
| Enter | Submit | Newline | All |
| Shift+Enter | Newline | Submit | Kitty |
| Ctrl+Enter | Newline | Submit | Kitty |
| Cmd+Enter | Newline | Submit | Kitty (macOS) |
| Alt+Enter | Newline | Submit | All * |
| Ctrl+J | Newline | Newline | All |
* Alt+Enter requires "Use Option as Meta key" on some macOS terminals.
Editing
| Key | Action | Terminal | | ------------------------------------------- | ---------------------------------------- | -------- | | Backspace | Delete character backward (merges lines) | All | | Delete | Delete character forward (merges lines) | All | | Ctrl+U | Delete to line start | All | | Ctrl+K | Delete to line end | All | | Ctrl+W | Delete previous word | All | | Ctrl+Z / Cmd+Z | Undo | All ** | | Ctrl+Y / Ctrl+Shift+Z / Cmd+Shift+Z / Cmd+Y | Redo | All ** | | Ctrl+L | Clear screen and redraw | All |
** Ctrl+Z/Y work in all terminals. Cmd+Z/Y and Ctrl+Shift+Z require kitty protocol.
Cursor Movement
| Key | Action | Terminal | | ------------------------------------ | ------------------------------------------ | ------------- | | Left / Right | Move cursor (crosses line boundaries) | All | | Up / Down | Move between lines (history at boundaries) | All | | Alt+Left / Alt+Right | Word jump | All | | Alt+Up / Alt+Down | History prev / next | All | | Ctrl+P / Ctrl+N | History prev / next | All | | PageUp / PageDown | History prev / next | All | | Ctrl+Left / Ctrl+Right | Line start / end | All | | Ctrl+Up / Ctrl+Down | Buffer start / end | All | | Option+Left / Option+Right (ESC+b/f) | Word jump | All (macOS) | | Cmd+Left / Cmd+Right | Line start / end | Kitty (macOS) | | Ctrl+A / Ctrl+E | Line start / end | All | | Cmd+Up / Cmd+Down | Buffer start / end | Kitty (macOS) | | Home / End | Line start / end | All |
Control
| Key | Action | Terminal |
| ------ | ------------------------------------------------------------------------------- | -------- |
| Ctrl+C | Cancel (returns [input, { kind: "cancel", message }]) | All |
| Ctrl+D | Delete at cursor, or EOF if empty (returns [input, { kind: "eof", message }]) | All |
Disabling Keys
Use disabledKeys to ignore specific key combinations:
// Disable Ctrl+J (e.g., if it conflicts with your app)
await readMultiline("", { disabledKeys: ["ctrl+j"] });
// Only allow Shift+Enter and Ctrl+J as newline
await readMultiline("", { disabledKeys: ["ctrl+enter", "cmd+enter", "alt+enter"] });Valid values: "shift+enter", "ctrl+enter", "cmd+enter", "alt+enter", "ctrl+j"
History
Pass an array for in-memory history, or a HistoryOptions object for file-based persistence:
// In-memory history
await readMultiline("", { history: ["previous input"] });
// File-based persistent history
await readMultiline("", {
history: { filePath: "~/.myapp/history.json", maxEntries: 50 },
});| Option | Type | Default | Description |
| --------------- | ---------------------------- | ----------- | -------------------------------------------------------------------------------------------------- |
| filePath | string | (required) | JSON file path for persistence |
| maxEntries | number | 100 | Maximum entries to keep |
| shouldPersist | (value: string) => boolean | undefined | Predicate that returns false to skip persisting a submitted value (it still resolves as success) |
The file is loaded at startup and updated after each submit via a read-modify-write cycle: the current file is re-read, the new entry appended, and the result written through a sibling temp file plus fs.rename. This way readers never observe a partial JSON document, and entries appended by concurrent sessions after this session started are preserved. Errors are silently ignored; the parent directory is created automatically.
Use shouldPersist to accept values that validate successfully but shouldn't be recalled via history — for example, REPL meta commands or empty submissions:
await readMultiline("> ", {
history: {
filePath: "~/.myapp/history.json",
shouldPersist: (value) => value.trim() !== "" && !value.startsWith("\\"),
},
});historyArrowNavigation
Controls how Up/Down arrow keys interact with history at boundaries:
"single"(default): at boundary, one press navigates history"double": at boundary, two consecutive presses navigate history"disabled": Up/Down never triggers history — use dedicated keys (Alt+Up/Down, Ctrl+P/N, PageUp/PageDown) instead
Footer
Use footer for custom text, helpFooter for auto-generated key bindings help:
// Auto-generated help footer (detects terminal capabilities)
await readMultiline("", { helpFooter: true });
// Customized help footer
await readMultiline("", {
helpFooter: {
items: ["submit", "newline", "undo"], // Choose actions and order (default: ["submit", "newline", "undo", "cancel", "eof"])
maxKeysPerAction: 3, // Show up to 3 key alternatives per action (default: 2)
maxLines: 1, // Limit to 1 line (default: unlimited)
style: "dim", // Overall style (default: "dim", or none when separator is set)
keyStyle: "bold", // Style for key labels
actionStyle: "dim", // Style for action descriptions
separator: " • ", // Inline layout with separator (default: grid layout)
},
});
// Custom footer + help footer together
await readMultiline("", {
footer: "Type your message below",
helpFooter: true,
});helpFooter automatically detects kitty keyboard protocol support and only shows keys available in the current terminal. The preferNewlineOnEnter and disabledKeys options are inherited, and terminal width is auto-calculated.
Validation
When a validate function is provided:
- On submit, the input is validated. If validation fails, a red error message appears below the input and submission is blocked.
- After the first validation failure, validation runs on every change (debounced) with live feedback: red for errors, green "OK" when valid.
- When a theme with error visual state is configured (e.g.
presets.clack), the prefix and line prefix switch to their error-state appearance during validation errors.
Limits
When maxLines or maxLength is set, input beyond the limit is silently blocked and a red error message appears below the input.
License
MIT
