From e34fb1f7dc6487905e791117c4b025953d16aa81 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 14 Jun 2026 17:28:45 -0400 Subject: [PATCH] repl: up/down arrow walks per-tab history, draft preserved at the bottom --- wasm/repl/repl.js | 86 +++++++++++++++++++++++++++++++++++++++++++++++ www/repl/repl.js | 86 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 57e3e8e..5dfcb18 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -296,6 +296,57 @@ function renderAll() { }); } +// ─── History navigation (readline-style up/down) ──────────────────── +// Each tab owns its own history; the active tab's draft is preserved so +// that walking back into history doesn't eat what the user was typing. +const history = { idx: null, draft: "" }; + +function historyEntries() { + // Transcript's `input` fields, deduplicated against the immediate + // predecessor — pressing up shouldn't make you hit the same line + // twice in a row when you just submitted it. + const tab = activeTab(); + if (!tab) return []; + const out = []; + for (const e of tab.transcript) { + if (out.length && out[out.length - 1] === e.input) continue; + out.push(e.input); + } + return out; +} + +function historyPrev() { + const entries = historyEntries(); + if (entries.length === 0) return; + if (history.idx === null) { + history.draft = inputEl.value; + history.idx = entries.length - 1; + } else if (history.idx > 0) { + history.idx--; + } + inputEl.value = entries[history.idx]; + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); +} + +function historyNext() { + const entries = historyEntries(); + if (history.idx === null) return; + if (history.idx >= entries.length - 1) { + history.idx = null; + inputEl.value = history.draft; + history.draft = ""; + } else { + history.idx++; + inputEl.value = entries[history.idx]; + } + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); +} + +function resetHistory() { + history.idx = null; + history.draft = ""; +} + // ─── Input handling ───────────────────────────────────────────────── async function sendInput() { const src = inputEl.value.trim(); @@ -307,6 +358,7 @@ async function sendInput() { const entry = { input: src, results: [], kind: "ok" }; tab.transcript.push(entry); inputEl.value = ""; + resetHistory(); sendBtn.disabled = true; cancelBtn.disabled = false; renderAll(); @@ -360,6 +412,40 @@ inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendInput(); + return; + } + // Up/Down navigate per-tab history when the caret is in the only + // (or first/last) row — otherwise they belong to the textarea's + // natural multi-line navigation. + if (e.key === "ArrowUp") { + const before = inputEl.value.substring(0, inputEl.selectionStart); + if (!before.includes("\n")) { + e.preventDefault(); + historyPrev(); + } + return; + } + if (e.key === "ArrowDown") { + const after = inputEl.value.substring(inputEl.selectionStart); + if (!after.includes("\n")) { + e.preventDefault(); + historyNext(); + } + return; + } +}); +inputEl.addEventListener("input", () => { + // Any keystroke that isn't an arrow drops history navigation — + // edits are now the user's own draft, not the historical entry. + if (history.idx !== null && document.activeElement === inputEl) { + // We can't reliably distinguish arrow-induced updates here, so + // we only invalidate when the buffer has actually diverged from + // the historical entry the cursor was on. + const entries = historyEntries(); + if (entries[history.idx] !== inputEl.value) { + history.draft = inputEl.value; + history.idx = null; + } } }); sendBtn.addEventListener("click", sendInput); diff --git a/www/repl/repl.js b/www/repl/repl.js index 57e3e8e..5dfcb18 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -296,6 +296,57 @@ function renderAll() { }); } +// ─── History navigation (readline-style up/down) ──────────────────── +// Each tab owns its own history; the active tab's draft is preserved so +// that walking back into history doesn't eat what the user was typing. +const history = { idx: null, draft: "" }; + +function historyEntries() { + // Transcript's `input` fields, deduplicated against the immediate + // predecessor — pressing up shouldn't make you hit the same line + // twice in a row when you just submitted it. + const tab = activeTab(); + if (!tab) return []; + const out = []; + for (const e of tab.transcript) { + if (out.length && out[out.length - 1] === e.input) continue; + out.push(e.input); + } + return out; +} + +function historyPrev() { + const entries = historyEntries(); + if (entries.length === 0) return; + if (history.idx === null) { + history.draft = inputEl.value; + history.idx = entries.length - 1; + } else if (history.idx > 0) { + history.idx--; + } + inputEl.value = entries[history.idx]; + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); +} + +function historyNext() { + const entries = historyEntries(); + if (history.idx === null) return; + if (history.idx >= entries.length - 1) { + history.idx = null; + inputEl.value = history.draft; + history.draft = ""; + } else { + history.idx++; + inputEl.value = entries[history.idx]; + } + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); +} + +function resetHistory() { + history.idx = null; + history.draft = ""; +} + // ─── Input handling ───────────────────────────────────────────────── async function sendInput() { const src = inputEl.value.trim(); @@ -307,6 +358,7 @@ async function sendInput() { const entry = { input: src, results: [], kind: "ok" }; tab.transcript.push(entry); inputEl.value = ""; + resetHistory(); sendBtn.disabled = true; cancelBtn.disabled = false; renderAll(); @@ -360,6 +412,40 @@ inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendInput(); + return; + } + // Up/Down navigate per-tab history when the caret is in the only + // (or first/last) row — otherwise they belong to the textarea's + // natural multi-line navigation. + if (e.key === "ArrowUp") { + const before = inputEl.value.substring(0, inputEl.selectionStart); + if (!before.includes("\n")) { + e.preventDefault(); + historyPrev(); + } + return; + } + if (e.key === "ArrowDown") { + const after = inputEl.value.substring(inputEl.selectionStart); + if (!after.includes("\n")) { + e.preventDefault(); + historyNext(); + } + return; + } +}); +inputEl.addEventListener("input", () => { + // Any keystroke that isn't an arrow drops history navigation — + // edits are now the user's own draft, not the historical entry. + if (history.idx !== null && document.activeElement === inputEl) { + // We can't reliably distinguish arrow-induced updates here, so + // we only invalidate when the buffer has actually diverged from + // the historical entry the cursor was on. + const entries = historyEntries(); + if (entries[history.idx] !== inputEl.value) { + history.draft = inputEl.value; + history.idx = null; + } } }); sendBtn.addEventListener("click", sendInput);