// A pure reducer over terminal input: no DOM, no bash, no I/O. `shell.js` drives a // terminal's `onData` chunks through `step()` and writes the result. // // Redraw contract: `echo` always equals the returned `state.buffer` — the full text to // show after the prompt. The caller repaints with `"\r" + prompt + echo`, then moves the // cursor left by `echo.length - state.cursor` columns (`ESC [ D`). Redrawing the whole // line on every keystroke, rather than diffing it, is simple and correct for a one-line // prompt no wider than the terminal. /** @typedef {{ buffer: string, cursor: number, history: string[], recall: number | null, draft: string }} LineState */ export const initialLine = () => ({ buffer: "", cursor: 0, history: [], recall: null, draft: "" }); /** Non-blank, and different from the most recent entry. */ function pushHistory(history, line) { return line.trim() !== "" && history[history.length - 1] !== line ? [...history, line] : history; } function submitLine(state) { const line = state.buffer; return { state: { buffer: "", cursor: 0, history: pushHistory(state.history, line), recall: null, draft: "" }, submit: line }; } // A pasted chunk carrying `\r` is one or more completed lines followed by an unfinished // one. Everything up to the LAST `\r` submits as a single script — internal `\r`s become // `\n` so a pasted heredoc keeps its line breaks — and the text after it becomes the new // buffer, so typing can continue on the unfinished line. function pasteSubmit(state, data) { const combined = state.buffer.slice(0, state.cursor) + data + state.buffer.slice(state.cursor); const lastCr = combined.lastIndexOf("\r"); const script = combined.slice(0, lastCr).replace(/\r/g, "\n"); const remainder = combined.slice(lastCr + 1); return { state: { buffer: remainder, cursor: remainder.length, history: pushHistory(state.history, script), recall: null, draft: "" }, submit: script, }; } function insertText(state, text) { const buffer = state.buffer.slice(0, state.cursor) + text + state.buffer.slice(state.cursor); return { state: { ...state, buffer, cursor: state.cursor + text.length } }; } function backspace(state) { if (state.cursor === 0) return { state }; const buffer = state.buffer.slice(0, state.cursor - 1) + state.buffer.slice(state.cursor); return { state: { ...state, buffer, cursor: state.cursor - 1 } }; } function deleteForward(state) { if (state.cursor >= state.buffer.length) return { state }; const buffer = state.buffer.slice(0, state.cursor) + state.buffer.slice(state.cursor + 1); return { state: { ...state, buffer } }; } function moveLeft(state) { return { state: { ...state, cursor: Math.max(0, state.cursor - 1) } }; } function moveRight(state) { return { state: { ...state, cursor: Math.min(state.buffer.length, state.cursor + 1) } }; } function moveHome(state) { return { state: { ...state, cursor: 0 } }; } function moveEnd(state) { return { state: { ...state, cursor: state.buffer.length } }; } function killToStart(state) { return { state: { ...state, buffer: state.buffer.slice(state.cursor), cursor: 0 } }; } function killToEnd(state) { return { state: { ...state, buffer: state.buffer.slice(0, state.cursor) } }; } // Deletes the word behind the cursor: trailing whitespace, then the run of non-whitespace // before it. function killPrevWord(state) { const b = state.buffer; let i = state.cursor; while (i > 0 && /\s/.test(b[i - 1])) i--; while (i > 0 && !/\s/.test(b[i - 1])) i--; return { state: { ...state, buffer: b.slice(0, i) + b.slice(state.cursor), cursor: i } }; } function interruptLine(state) { return { state: { buffer: "", cursor: 0, history: state.history, recall: null, draft: "" }, interrupt: true }; } // ^L changes nothing about the line; `step()` below turns it into `clear: true`. function clearScreen(state) { return { state }; } // Up/Down walk `history` by index, stashing the line being edited as `draft` on the way // in and restoring it on the way out, as a shell's line editor does. function historyUp(state) { if (state.history.length === 0) return { state }; if (state.recall === null) { const recall = state.history.length - 1; const buffer = state.history[recall]; return { state: { ...state, buffer, cursor: buffer.length, recall, draft: state.buffer } }; } if (state.recall === 0) return { state }; const recall = state.recall - 1; const buffer = state.history[recall]; return { state: { ...state, buffer, cursor: buffer.length, recall } }; } function historyDown(state) { if (state.recall === null) return { state }; if (state.recall < state.history.length - 1) { const recall = state.recall + 1; const buffer = state.history[recall]; return { state: { ...state, buffer, cursor: buffer.length, recall } }; } const buffer = state.draft; return { state: { ...state, buffer, cursor: buffer.length, recall: null, draft: "" } }; } /** Exact single-key-sequence edits. Paste (multi-char data containing `\r`) and plain * printable insertion are handled outside this table, in `step()`. */ const KEYS = { "\r": submitLine, "\x7f": backspace, "\x1b[3~": deleteForward, "\x1b[D": moveLeft, "\x1b[C": moveRight, "\x1b[H": moveHome, "\x1b[F": moveEnd, "\x01": moveHome, "\x05": moveEnd, "\x1b[A": historyUp, "\x1b[B": historyDown, "\x03": interruptLine, "\x15": killToStart, "\x0b": killToEnd, "\x17": killPrevWord, "\x0c": clearScreen, }; function isPrintable(data) { if (data.length === 0) return false; for (const ch of data) { const code = ch.codePointAt(0); if (code < 0x20 || code === 0x7f) return false; } return true; } /** * @param {LineState} state * @param {string} data one `term.onData` chunk * @returns {{ state: LineState, echo: string, submit?: string, interrupt?: true, clear?: true }} */ export function step(state, data) { const result = data.length > 1 && data.includes("\r") ? pasteSubmit(state, data) : KEYS[data] ? KEYS[data](state) : isPrintable(data) ? insertText(state, data) : { state }; // an escape sequence this editor does not model: ignored return { state: result.state, echo: result.state.buffer, ...(result.submit !== undefined ? { submit: result.submit } : {}), ...(result.interrupt ? { interrupt: true } : {}), ...(data === "\x0c" ? { clear: true } : {}), }; }