Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173// 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 [<n> 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 } : {}), };}