3.3 Text Input
import { Aside } from ‘@astrojs/starlight/components’;
Text input in raw mode requires you to implement everything that cooked mode gave you for free: cursor movement, backspace, delete, Home, End.
The Input State
Section titled “The Input State”type InputState = { value: string; // the full text content cursor: number; // cursor position (index into value)};The cursor position is a character index into value. Cursor at 0 means “before the first character”. Cursor at value.length means “after the last character” (end of text).
value: "Hello" index: 01234 cursor=2: "He|llo" (pipe = cursor)Input Operations
Section titled “Input Operations”export type InputState = { value: string; cursor: number;};
export function inputReducer(state: InputState, key: string, keyName: string): InputState { const { value, cursor } = state;
switch (keyName) { case 'left': return { value, cursor: Math.max(0, cursor - 1) };
case 'right': return { value, cursor: Math.min(value.length, cursor + 1) };
case 'home': return { value, cursor: 0 };
case 'end': return { value, cursor: value.length };
case 'backspace': if (cursor === 0) return state; return { value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1, };
case 'delete': if (cursor >= value.length) return state; return { value: value.slice(0, cursor) + value.slice(cursor + 1), cursor, };
default: // Printable character: insert at cursor if (key.length === 1 && key.charCodeAt(0) >= 32) { return { value: value.slice(0, cursor) + key + value.slice(cursor), cursor: cursor + 1, }; } return state; }}Rendering the Input
Section titled “Rendering the Input”The tricky part of rendering a text input is the cursor. We show it by reversing the colors of the character at the cursor position (or showing a block if at the end):
import chalk from 'chalk';import ansiEscapes from 'ansi-escapes';import { type InputState } from './text-input';
export type InputRenderOptions = { col: number; row: number; width: number; state: InputState; label?: string; focused?: boolean; placeholder?: string;};
export function renderInput(opts: InputRenderOptions) { const { col, row, width, state, label, focused = true, placeholder = '' } = opts; const { value, cursor } = state;
const labelText = label ? `${label}: ` : ''; const inputWidth = width - labelText.length;
// Scroll the visible window if cursor is outside const scrollStart = Math.max(0, cursor - inputWidth + 1); const visible = value.slice(scrollStart, scrollStart + inputWidth); const localCursor = cursor - scrollStart;
// Build rendered string with cursor highlight let rendered = ''; for (let i = 0; i < inputWidth; i++) { const char = visible[i] ?? (i === localCursor && value.length === 0 ? placeholder[0] ?? ' ' : ' '); const isCursor = focused && i === localCursor; rendered += isCursor ? chalk.bgWhite.black(char) : char; }
process.stdout.write(ansiEscapes.cursorTo(col - 1, row - 1));
if (label) { process.stdout.write(chalk.blue(labelText)); }
if (focused) { process.stdout.write(chalk.bgBlack(rendered)); } else { process.stdout.write(chalk.dim(rendered)); }}A Multi-Field Form
Section titled “A Multi-Field Form”Here is a form with two fields and Tab to switch between them:
import chalk from 'chalk';import ansiEscapes from 'ansi-escapes';import { inputReducer, type InputState } from './text-input';import { renderInput } from './render-input';import { drawBox } from './draw-box';import { parseKey } from '../module-2/01-keypress-parser';
type FormState = { fields: { label: string; state: InputState }[]; focusedField: number; submitted: boolean;};
const formState: FormState = { fields: [ { label: 'Name', state: { value: '', cursor: 0 } }, { label: 'Email', state: { value: '', cursor: 0 } }, { label: 'Message', state: { value: '', cursor: 0 } }, ], focusedField: 0, submitted: false,};
function render() { process.stdout.write(ansiEscapes.cursorTo(0, 0));
drawBox({ col: 2, row: 2, width: 50, height: 12, title: 'Contact Form', borderStyle: 'rounded', borderColor: chalk.cyan, });
formState.fields.forEach((field, i) => { renderInput({ col: 4, row: 4 + i * 2, width: 46, state: field.state, label: field.label, focused: formState.focusedField === i, }); });
const footerRow = 14; process.stdout.write(ansiEscapes.cursorTo(1, footerRow)); process.stdout.write(chalk.dim('Tab: next field Enter: submit q: quit'));
if (formState.submitted) { process.stdout.write(ansiEscapes.cursorTo(1, footerRow + 1)); process.stdout.write(chalk.green.bold('✓ Form submitted!')); formState.fields.forEach((f, i) => { process.stdout.write(ansiEscapes.cursorTo(1, footerRow + 2 + i)); process.stdout.write(chalk.dim(` ${f.label}: `) + chalk.white(f.state.value)); }); }
process.stdout.write(ansiEscapes.eraseDown);}
function handleKey(raw: string) { const key = parseKey(raw); const focused = formState.focusedField;
if (key.name === 'q') { cleanup(); return; }
if (key.name === 'tab') { formState.focusedField = (focused + 1) % formState.fields.length; render(); return; }
if (key.name === 'enter') { if (focused < formState.fields.length - 1) { formState.focusedField++; } else { formState.submitted = true; } render(); return; }
formState.fields[focused].state = inputReducer( formState.fields[focused].state, raw, key.name, ); render();}
function cleanup() { process.stdin.setRawMode(false); process.stdout.write(ansiEscapes.cursorShow); process.stdout.write(ansiEscapes.exitAlternativeScreen); process.exit(0);}
process.stdout.write(ansiEscapes.enterAlternativeScreen);process.stdout.write(ansiEscapes.cursorHide);process.stdout.write(ansiEscapes.clearScreen);process.on('SIGINT', cleanup);process.on('SIGTERM', cleanup);
process.stdin.setRawMode(true);process.stdin.resume();process.stdin.setEncoding('utf8');process.stdin.on('data', handleKey);render();Word Navigation (Ctrl+Arrow)
Section titled “Word Navigation (Ctrl+Arrow)”Power users expect Ctrl+Left/Right to jump by word:
function wordLeft(value: string, cursor: number): number { let i = cursor - 1; while (i > 0 && value[i] === ' ') i--; // skip spaces while (i > 0 && value[i - 1] !== ' ') i--; // skip word chars return i;}
function wordRight(value: string, cursor: number): number { let i = cursor; while (i < value.length && value[i] === ' ') i++; // skip spaces while (i < value.length && value[i] !== ' ') i++; // skip word chars return i;}
// In your key handler, check for ctrl+left / ctrl+right// These arrive as \x1b[1;5D and \x1b[1;5C in most terminals