From 782c7182f2617d451174956fab36664361fc914e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 16:15:36 +0200 Subject: [PATCH 1/8] feat: vim bindings in the input via CodeMirror 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the plain +
@@ -197,7 +219,12 @@
- From 876014a7b7c02c4c5d95c663a3fceeb7b1b2dc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 16:39:05 +0200 Subject: [PATCH 3/8] fix: Shift-H/L also exit the editor in vim normal mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version only handled Shift-H/L when focus was outside the editor; inside vim normal mode they were viewport top/bottom and required Esc+Tab to escape. Now Vim.mapCommand overrides H/L in normal-mode context to call the same moveFocus(±1) helper, so the shortcut works uniformly regardless of focus position. --- CHANGELOG.md | 2 +- static/index.html | 44 ++++++++++++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 185dfab..938d03c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p`, undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard. -- `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Suppressed inside the editor where vim already owns those motions. +- `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor. - On page load, the model dropdown defaults to whichever model Ollama already has hot — skips the 5-15 s disk-reload pain on the first translation. After load it follows the user's manual selection. ### Changed diff --git a/static/index.html b/static/index.html index c4a9587..2df7e16 100644 --- a/static/index.html +++ b/static/index.html @@ -224,7 +224,7 @@ import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6"; import { EditorState } from "https://esm.sh/@codemirror/state@6"; import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6"; - import { vim } from "https://esm.sh/@replit/codemirror-vim@6"; + import { vim, Vim } from "https://esm.sh/@replit/codemirror-vim@6"; const LANGUAGES = [ "English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish", @@ -478,9 +478,11 @@ goBtn.addEventListener("click", translate); - // Shift-H / Shift-L navigate focus across controls (vim-style Shift-Tab / - // Tab). Skipped when focus is inside the CodeMirror editor — there H/L - // are vim's viewport motions and must reach the editor untouched. + // Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab / + // Tab). Two paths kept in sync: + // 1. Document keydown listener — fires when focus is on a select / button + // 2. Vim normal-mode mapping (below) — fires when focus is in the editor + // Together they make Shift-H/L work everywhere, including escaping the editor. const focusables = [ { el: sourceSel, focus: () => sourceSel.focus() }, { el: targetSel, focus: () => targetSel.focus() }, @@ -488,23 +490,37 @@ { el: editor.contentDOM, focus: () => editor.focus() }, { el: goBtn, focus: () => goBtn.focus() }, ]; + function moveFocus(delta) { + const ae = document.activeElement; + let idx = focusables.findIndex(f => f.el === ae || f.el.contains?.(ae)); + if (idx === -1) { + focusables[delta > 0 ? 0 : focusables.length - 1].focus(); + return; + } + const next = focusables[(idx + delta + focusables.length) % focusables.length]; + next.focus(); + } document.addEventListener("keydown", (e) => { if (!e.shiftKey) return; const k = e.key.toLowerCase(); if (k !== "h" && k !== "l") return; - if (editor.dom.contains(document.activeElement)) return; // vim owns H/L in the editor + // Inside the editor, vim's mapCommand handles H/L (see below). Letting + // the event bubble here would double-jump. + if (editor.dom.contains(document.activeElement)) return; e.preventDefault(); - const ae = document.activeElement; - let idx = focusables.findIndex(f => f.el === ae); - if (idx === -1) { - focusables[k === "l" ? 0 : focusables.length - 1].focus(); - return; - } - const delta = k === "l" ? 1 : -1; - const next = focusables[(idx + delta + focusables.length) % focusables.length]; - next.focus(); + moveFocus(k === "l" ? 1 : -1); }); + // Override vim's H/L (normally "viewport top/bottom") so they exit the + // editor to the previous/next control, matching the same Shift-H/L + // contract the document handler uses elsewhere. + if (!isCoarsePointer) { + Vim.defineAction("finrodFocusPrev", () => moveFocus(-1)); + Vim.defineAction("finrodFocusNext", () => moveFocus(1)); + Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" }); + Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); + } + fillLangs(); loadModels().then(() => refreshLoaded(true)); From a058665b5b4c96086347dd96e1341af3168f3f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 16:41:52 +0200 Subject: [PATCH 4/8] feat: vim p/P paste from system clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vim's default p/P pastes from its internal unnamed register, which is useless when the text to translate lives in another app's clipboard. Override both in normal mode to read navigator.clipboard .readText() and insert at (after) or before the cursor. Caveats: requires browser clipboard permission (prompted on first use), HTTPS or localhost. Both already satisfied — finrod is served over the wildcard TLS cert. --- CHANGELOG.md | 1 + static/index.html | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 938d03c..fa074ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p`, undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard. - `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor. +- `p` / `P` in vim normal mode paste from the system clipboard at / before the cursor (vim's default registers are skipped — the realistic source for a translator is another app, not an in-editor yank). - On page load, the model dropdown defaults to whichever model Ollama already has hot — skips the 5-15 s disk-reload pain on the first translation. After load it follows the user's manual selection. ### Changed diff --git a/static/index.html b/static/index.html index 2df7e16..decd64f 100644 --- a/static/index.html +++ b/static/index.html @@ -513,12 +513,34 @@ // Override vim's H/L (normally "viewport top/bottom") so they exit the // editor to the previous/next control, matching the same Shift-H/L - // contract the document handler uses elsewhere. + // contract the document handler uses elsewhere. Also override p/P in + // normal mode to paste from the system clipboard (vim's default p + // pastes from its internal register — useless when the source text + // comes from another app). if (!isCoarsePointer) { Vim.defineAction("finrodFocusPrev", () => moveFocus(-1)); Vim.defineAction("finrodFocusNext", () => moveFocus(1)); Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" }); Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); + + async function pasteFromClipboard(after) { + try { + const text = await navigator.clipboard.readText(); + if (!text) return; + const sel = editor.state.selection.main; + const pos = after ? sel.to : sel.from; + editor.dispatch({ + changes: { from: pos, insert: text }, + selection: { anchor: pos + text.length }, + }); + } catch (e) { + console.warn("Clipboard read failed:", e); + } + } + Vim.defineAction("finrodPasteAfter", () => pasteFromClipboard(true)); + Vim.defineAction("finrodPasteBefore", () => pasteFromClipboard(false)); + Vim.mapCommand("p", "action", "finrodPasteAfter", {}, { context: "normal" }); + Vim.mapCommand("P", "action", "finrodPasteBefore", {}, { context: "normal" }); } fillLangs(); From b092047ca66ffe6f96428f0171524dceed4acb1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 16:47:33 +0200 Subject: [PATCH 5/8] feat: y/Y system clipboard, modal vim dropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - y/Y/yy and visual y now route through vim's "+" register, which copies to the system clipboard via the synchronous document .execCommand("copy") path — no permission prompt. - p/P also remapped to "+ register for symmetry; replaces my custom navigator.clipboard.readText() action. Browser may still show a permission chip on first paste; granting once makes it silent. - Source / target / model dropdowns get modal vim-style nav: default is native typeahead, Esc → normal mode (accent border), j/k move selectedIndex, i and / return to search, blur resets. Disabled on coarse-pointer devices. --- CHANGELOG.md | 8 ++++- static/index.html | 87 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 71 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa074ad..0477617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,13 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p`, undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard. - `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor. -- `p` / `P` in vim normal mode paste from the system clipboard at / before the cursor (vim's default registers are skipped — the realistic source for a translator is another app, not an in-editor yank). +- `y` / `Y` / `yy` and visual-mode `y` in the editor now copy to the system clipboard via vim's `+` register (synchronous `document.execCommand("copy")` under the hood — no permission prompt). +- `p` / `P` in vim normal mode paste from the system clipboard at / before the cursor via the same `+` register. The first paste may show a one-time browser permission chip; granting clipboard-read on the origin makes future calls silent. +- Vim-style modal navigation on the From / To / Model dropdowns: + - Default is native typeahead search (type letters to filter) + - `Esc` enters normal mode; an accent-coloured border indicates this + - In normal mode, `j` / `k` move selectedIndex down / up + - `i` or `/` returns to search mode; blur also resets so the next focus starts in search - On page load, the model dropdown defaults to whichever model Ollama already has hot — skips the 5-15 s disk-reload pain on the first translation. After load it follows the user's manual selection. ### Changed diff --git a/static/index.html b/static/index.html index decd64f..4b21c74 100644 --- a/static/index.html +++ b/static/index.html @@ -178,6 +178,12 @@ border-top: 1px solid var(--border); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + + /* Vim normal-mode indicator on the language/model dropdowns */ + select.vim-normal { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + } @@ -511,36 +517,71 @@ moveFocus(k === "l" ? 1 : -1); }); - // Override vim's H/L (normally "viewport top/bottom") so they exit the - // editor to the previous/next control, matching the same Shift-H/L - // contract the document handler uses elsewhere. Also override p/P in - // normal mode to paste from the system clipboard (vim's default p - // pastes from its internal register — useless when the source text - // comes from another app). + // Vim customisations: focus navigation + system-clipboard register routing. + // Override H/L (normally "viewport top/bottom") to exit the editor across + // controls, matching the same Shift-H/L contract the document handler + // uses elsewhere. Route y/Y/yy and p/P through vim's "+" register so they + // sync with the system clipboard — the @replit/codemirror-vim "+" handler + // uses document.execCommand("copy") for yank (no permission prompt) and + // navigator.clipboard.readText() for paste (browser may show a one-time + // permission chip; "Allow" makes it permanent). if (!isCoarsePointer) { Vim.defineAction("finrodFocusPrev", () => moveFocus(-1)); Vim.defineAction("finrodFocusNext", () => moveFocus(1)); Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" }); Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); - async function pasteFromClipboard(after) { - try { - const text = await navigator.clipboard.readText(); - if (!text) return; - const sel = editor.state.selection.main; - const pos = after ? sel.to : sel.from; - editor.dispatch({ - changes: { from: pos, insert: text }, - selection: { anchor: pos + text.length }, - }); - } catch (e) { - console.warn("Clipboard read failed:", e); - } + // Yank → system clipboard (synchronous, no prompt). + Vim.map("y", '"+y', "normal"); + Vim.map("Y", '"+Y', "normal"); + Vim.map("yy", '"+yy', "normal"); + Vim.map("y", '"+y', "visual"); + + // Paste ← system clipboard. The chip nag is browser-side; granting + // clipboard-read permission for the origin makes it silent. + Vim.map("p", '"+p', "normal"); + Vim.map("P", '"+P', "normal"); + } + + // Modal vim-style behavior on the language/model dropdowns. + // default: typeahead search (browser native) + // Esc: switch to "normal" mode — j/k move selectedIndex + // i, /: back to search mode + // blur: reset to search mode on next focus + // Disabled on touch (no Esc key, no value). + if (!isCoarsePointer) { + for (const sel of [sourceSel, targetSel, modelSel]) { + sel.dataset.mode = "search"; + const enterNormal = () => { + sel.dataset.mode = "normal"; + sel.classList.add("vim-normal"); + }; + const enterSearch = () => { + sel.dataset.mode = "search"; + sel.classList.remove("vim-normal"); + }; + const step = (delta) => { + const next = sel.selectedIndex + delta; + if (next < 0 || next >= sel.options.length) return; + sel.selectedIndex = next; + sel.dispatchEvent(new Event("change", { bubbles: true })); + }; + sel.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + enterNormal(); + e.preventDefault(); + return; + } + if (sel.dataset.mode === "search") return; // native typeahead + if (e.key === "j") { e.preventDefault(); step(+1); return; } + if (e.key === "k") { e.preventDefault(); step(-1); return; } + if (e.key === "i" || e.key === "/") { enterSearch(); e.preventDefault(); return; } + // Let Shift-H/L bubble to the document focus handler; preventDefault + // anything else so it doesn't pollute the next search. + if (!(e.shiftKey && (e.key === "H" || e.key === "L"))) e.preventDefault(); + }); + sel.addEventListener("blur", enterSearch); } - Vim.defineAction("finrodPasteAfter", () => pasteFromClipboard(true)); - Vim.defineAction("finrodPasteBefore", () => pasteFromClipboard(false)); - Vim.mapCommand("p", "action", "finrodPasteAfter", {}, { context: "normal" }); - Vim.mapCommand("P", "action", "finrodPasteBefore", {}, { context: "normal" }); } fillLangs(); From 73aa54d7bddb08a481d0e0fc90a3bc27a1771e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 16:53:11 +0200 Subject: [PATCH 6/8] fix: Firefox clipboard, flip dropdown default to normal mode - Replace Vim.map("...", "\"+...", ...) with custom Vim.defineAction handlers calling navigator.clipboard directly. The library's "+" register implementation doesn't reliably write on Firefox. Limitation: only yy/Y/visual-y reach system clipboard; bare y still uses vim's internal register. - Flip dropdown modal: default NORMAL (j/k arrows), `/` or `i` enters search. Listener moved to document capture phase so closed dropdowns receive keydown on Firefox. --- CHANGELOG.md | 13 ++--- static/index.html | 145 +++++++++++++++++++++++++++++----------------- 2 files changed, 99 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0477617..99862f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,12 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p`, undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard. - `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor. -- `y` / `Y` / `yy` and visual-mode `y` in the editor now copy to the system clipboard via vim's `+` register (synchronous `document.execCommand("copy")` under the hood — no permission prompt). -- `p` / `P` in vim normal mode paste from the system clipboard at / before the cursor via the same `+` register. The first paste may show a one-time browser permission chip; granting clipboard-read on the origin makes future calls silent. -- Vim-style modal navigation on the From / To / Model dropdowns: - - Default is native typeahead search (type letters to filter) - - `Esc` enters normal mode; an accent-coloured border indicates this - - In normal mode, `j` / `k` move selectedIndex down / up - - `i` or `/` returns to search mode; blur also resets so the next focus starts in search +- `yy` / `Y` and visual-mode `y` in the editor copy to the system clipboard via custom `navigator.clipboard.writeText()` actions (the library's `+` register is broken on Firefox). Bare `y` (e.g. `yw`, `y$`) still uses vim's internal register — that path would need a deeper hook into the library's register system. +- `p` / `P` in vim normal mode paste from the system clipboard at / before the cursor via `navigator.clipboard.readText()`. Browsers may show a one-time permission prompt or chip; granting clipboard-read for the origin makes future calls silent. On Firefox, ensure `dom.events.asyncClipboard.readText` is `true` (default in recent versions) or the read will be blocked. +- Vim-style modal navigation on the From / To / Model dropdowns (default NORMAL): + - `j` / `k` move selectedIndex down / up (works whether the dropdown is open or collapsed; uses document-level capture-phase listener so Firefox plays along) + - `/` or `i` enters search mode — letter keys then do native typeahead; accent-coloured border indicates it + - `Esc` returns to normal mode; blur also resets so the next focus starts in normal - On page load, the model dropdown defaults to whichever model Ollama already has hot — skips the 5-15 s disk-reload pain on the first translation. After load it follows the user's manual selection. ### Changed diff --git a/static/index.html b/static/index.html index 4b21c74..2259bdc 100644 --- a/static/index.html +++ b/static/index.html @@ -179,8 +179,9 @@ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } - /* Vim normal-mode indicator on the language/model dropdowns */ - select.vim-normal { + /* Vim search-mode indicator on the language/model dropdowns + (default is normal mode — j/k navigate; / or i enters search). */ + select.vim-search { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } @@ -517,71 +518,111 @@ moveFocus(k === "l" ? 1 : -1); }); - // Vim customisations: focus navigation + system-clipboard register routing. + // Vim customisations: focus navigation + system-clipboard yank/paste. // Override H/L (normally "viewport top/bottom") to exit the editor across - // controls, matching the same Shift-H/L contract the document handler - // uses elsewhere. Route y/Y/yy and p/P through vim's "+" register so they - // sync with the system clipboard — the @replit/codemirror-vim "+" handler - // uses document.execCommand("copy") for yank (no permission prompt) and - // navigator.clipboard.readText() for paste (browser may show a one-time - // permission chip; "Allow" makes it permanent). + // controls, matching the Shift-H/L contract the document handler uses. + // Custom yank/paste via navigator.clipboard — the library's "+" register + // path is unreliable on Firefox, so we own the clipboard I/O ourselves. + // Limitation: bare `y` (yw, y$, etc.) still goes to vim's internal + // register; only yy / Y / visual `y` reach the system clipboard. if (!isCoarsePointer) { Vim.defineAction("finrodFocusPrev", () => moveFocus(-1)); Vim.defineAction("finrodFocusNext", () => moveFocus(1)); Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" }); Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); - // Yank → system clipboard (synchronous, no prompt). - Vim.map("y", '"+y', "normal"); - Vim.map("Y", '"+Y', "normal"); - Vim.map("yy", '"+yy', "normal"); - Vim.map("y", '"+y', "visual"); + Vim.defineAction("finrodYankLine", () => { + const line = editor.state.doc.lineAt(editor.state.selection.main.head); + const text = editor.state.doc.sliceString(line.from, line.to); + navigator.clipboard.writeText(text) + .catch(e => console.warn("Clipboard write failed:", e)); + }); + Vim.defineAction("finrodYankSelection", () => { + const { from, to } = editor.state.selection.main; + if (from === to) return; + const text = editor.state.sliceDoc(from, to); + navigator.clipboard.writeText(text) + .catch(e => console.warn("Clipboard write failed:", e)); + }); + async function pasteAt(after) { + let text; + try { + text = await navigator.clipboard.readText(); + } catch (e) { + console.warn("Clipboard read failed:", e); + return; + } + if (!text) return; + const sel = editor.state.selection.main; + const pos = after ? sel.to : sel.from; + editor.dispatch({ + changes: { from: pos, insert: text }, + selection: { anchor: pos + text.length }, + }); + } + Vim.defineAction("finrodPasteAfter", () => pasteAt(true)); + Vim.defineAction("finrodPasteBefore", () => pasteAt(false)); - // Paste ← system clipboard. The chip nag is browser-side; granting - // clipboard-read permission for the origin makes it silent. - Vim.map("p", '"+p', "normal"); - Vim.map("P", '"+P', "normal"); + Vim.mapCommand("yy", "action", "finrodYankLine", {}, { context: "normal" }); + Vim.mapCommand("Y", "action", "finrodYankLine", {}, { context: "normal" }); + Vim.mapCommand("y", "action", "finrodYankSelection", {}, { context: "visual" }); + Vim.mapCommand("p", "action", "finrodPasteAfter", {}, { context: "normal" }); + Vim.mapCommand("P", "action", "finrodPasteBefore", {}, { context: "normal" }); } // Modal vim-style behavior on the language/model dropdowns. - // default: typeahead search (browser native) - // Esc: switch to "normal" mode — j/k move selectedIndex - // i, /: back to search mode - // blur: reset to search mode on next focus - // Disabled on touch (no Esc key, no value). + // default: NORMAL mode — j/k move selectedIndex (vim everywhere) + // `/` or `i`: enter search mode — letter keys do native typeahead + // Esc: back to normal mode + // blur: reset to normal mode on next focus + // Uses a document-level CAPTURE listener so events fire whether the + // dropdown is open or collapsed (Firefox doesn't bubble keydown + // reliably from a collapsed ). - if (!isCoarsePointer) { - const vimSelects = [sourceSel, targetSel, modelSel]; - for (const sel of vimSelects) { - sel.dataset.mode = "normal"; - sel.addEventListener("blur", () => { - sel.dataset.mode = "normal"; - sel.classList.remove("vim-search"); - }); - } - const step = (sel, delta) => { - const next = sel.selectedIndex + delta; - if (next < 0 || next >= sel.options.length) return; - sel.selectedIndex = next; - sel.dispatchEvent(new Event("change", { bubbles: true })); - }; - document.addEventListener("keydown", (e) => { - const sel = document.activeElement; - if (!vimSelects.includes(sel)) return; - // Shift-H/L: let the focus-nav handler take it; don't preventDefault. - if (e.shiftKey && (e.key === "H" || e.key === "L")) return; - - if (e.key === "Escape") { - sel.dataset.mode = "normal"; - sel.classList.remove("vim-search"); - e.preventDefault(); - e.stopPropagation(); - return; - } - if (sel.dataset.mode === "search") return; // let native typeahead happen - // normal mode - if (e.key === "j") { e.preventDefault(); e.stopPropagation(); step(sel, +1); return; } - if (e.key === "k") { e.preventDefault(); e.stopPropagation(); step(sel, -1); return; } - if (e.key === "/" || e.key === "i") { - sel.dataset.mode = "search"; - sel.classList.add("vim-search"); - e.preventDefault(); - e.stopPropagation(); - return; - } - // Suppress any other letter so typeahead doesn't fire silently in normal mode. - if (e.key.length === 1) { - e.preventDefault(); - e.stopPropagation(); - } - }, true); // CAPTURE phase — needed for closed-dropdown keydowns on Firefox } fillLangs(); From 163f8dda57622c212a4db8613298c48ad23b612a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 17:22:26 +0200 Subject: [PATCH 8/8] release: v0.2.0 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0027d94..c0b0314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.2.0] - 2026-06-09 + ### Added - Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p` (vim's internal register), undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard. - `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor. diff --git a/pyproject.toml b/pyproject.toml index 637910b..b1caec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "finrod" -version = "0.1.0" +version = "0.2.0" description = "Translator UI backed by Ollama" requires-python = ">=3.11" dependencies = [