Skip to content

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.

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)
examples/module-3/text-input.ts
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;
}
}

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));
}
}

Here is a form with two fields and Tab to switch between them:

examples/module-3/03-text-input.ts
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();

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