Compare commits

11 Commits

Author SHA1 Message Date
d4a033e500 Merge pull request 'v0.2.0: vim bindings, Shift-H/L focus nav, hot-model default' (#3) from feature/vim-bindings into main 2026-06-09 15:22:41 +00:00
João Pedro Battistella Nadas
163f8dda57 release: v0.2.0 2026-06-09 17:22:26 +02:00
João Pedro Battistella Nadas
2d067226b9 revert: drop clipboard yank/paste and modal dropdowns
The vim clipboard integration was unreliable across browsers
(library "+" register broken on Firefox; custom navigator.clipboard
actions worked on Chrome but hit chip prompts and Firefox config
edge cases). The modal dropdown layer added more state than it
saved keystrokes. Pulling both back out.

What remains of the vim work:
  - CodeMirror + @replit/codemirror-vim in the input buffer
    (i/Esc, hjkl, word/line motions, yy/p via vim's internal
    register, undo, /, :)
  - Shift-H / Shift-L focus navigation across controls, including
    the H/L override inside the editor so it works from any focus
2026-06-09 17:07:01 +02:00
João Pedro Battistella Nadas
73aa54d7bd 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<motion> 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.
2026-06-09 16:53:11 +02:00
João Pedro Battistella Nadas
b092047ca6 feat: y/Y system clipboard, modal vim dropdowns
- 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.
2026-06-09 16:47:33 +02:00
João Pedro Battistella Nadas
a058665b5b feat: vim p/P paste from system clipboard
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.
2026-06-09 16:41:52 +02:00
João Pedro Battistella Nadas
876014a7b7 fix: Shift-H/L also exit the editor in vim normal mode
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.
2026-06-09 16:39:05 +02:00
João Pedro Battistella Nadas
9564900909 feat: editor font-size match, hot model default, Shift-H/L nav
- .cm-editor pinned to font-size 1rem so the input matches the
  output pane (CodeMirror 6 defaults smaller)
- refreshLoaded(isInitial=true) on first load picks the hot model
  as the dropdown default — avoids the 5-15s disk reload on the
  first translation. Subsequent refreshes don't override the user's
  manual selection.
- document-level Shift-H / Shift-L cycle focus across controls
  (source → target → model → editor → button), suppressed when
  focus is inside CodeMirror so vim's viewport H/L still work.
2026-06-09 16:22:20 +02:00
João Pedro Battistella Nadas
782c7182f2 feat: vim bindings in the input via CodeMirror 6
- Replace the plain <textarea> with a CodeMirror 6 EditorView that
  has @replit/codemirror-vim attached. Loaded from esm.sh, so no
  build step — finrod stays no-bundler.
- vim() extension is conditionally omitted on coarse-pointer devices
  (phones, tablets) where modal editing without a hardware Esc key
  is unusable and IME composition fights the keymap.
- Ctrl-/Cmd-Enter shortcut and the typing-debounce that refreshes
  the "Hot:" indicator both migrate to CodeMirror's keymap and
  updateListener APIs respectively.
- Editor styled to match the existing .output pane.

CHANGELOG: note under [Unreleased].
2026-06-09 16:15:36 +02:00
João Pedro Battistella Nadas
f9acdef3fe chore: introduce semver + CHANGELOG.md, cut v0.1.0
- CHANGELOG.md following Keep a Changelog; 0.1.0 entry covers all
  features shipped through the loaded-model indicator
- build.sh now also tags the image with the version read from
  pyproject.toml, e.g. rg.nl-ams.scw.cloud/valinor/finrod:0.1.0
- README documents the release workflow (edit CHANGELOG, bump
  pyproject version, merge, tag vX.Y.Z, build, bump valinor)
2026-06-09 15:55:09 +02:00
João Pedro Battistella Nadas
732982e66c feat: show which model is currently loaded in Ollama
- /api/loaded backend endpoint proxies Ollama /api/ps and returns
  {models: [{name, expires_at}]}
- Status line under the controls shows "Hot: gemma2:9b — unloads in 4m"
  with a glowing orange dot, or "No model loaded" in muted style
- Loaded models in the dropdown get a "● " prefix
- Refreshes on page load, after every translation, and 500ms after the
  user pauses typing in the input box
2026-06-09 15:51:57 +02:00
6 changed files with 298 additions and 22 deletions

44
CHANGELOG.md Normal file
View File

@@ -0,0 +1,44 @@
# Changelog
All notable changes to finrod are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
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.
- 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
- Editor font-size pinned to `1rem` to match the output pane (CodeMirror 6 ships smaller by default).
## [0.1.0] - 2026-06-09
First release. Stateless translator UI that proxies streaming requests to
Ollama on radagast.
### Added
- FastAPI backend with three endpoints:
- `GET /api/models` — filtered (`gemma,aya` by default) list from Ollama's `/api/tags`
- `POST /api/translate` — streams Ollama's NDJSON `/api/generate` response back to the browser
- `GET /api/loaded` — proxies Ollama's `/api/ps` for the currently-loaded model
- `GET /healthz` — for Kubernetes probes
- Single-page UI:
- Source / target / model dropdowns (30+ languages, "Auto-detect" source)
- Token-by-token streaming into the output pane with a blinking caret
- Live status line: spinner while waiting for first token, pulsing dot during streaming, elapsed time and token count
- Glowing border on the output pane during streaming
- Loaded-model indicator (`● ` prefix in dropdown, "Hot: … — unloads in Xm" status line); refreshes on page load, on translation completion, and 500 ms after typing pauses
- ⌘/Ctrl-Enter to translate from the input
- Dark mode, inline SVG favicon (Star of Fëanor)
- Dockerfile: two-stage `uv` build, slim Python 3.13 runtime
- `build.sh`: multi-arch (amd64 + arm64) manual push to Scaleway, tags `:latest`, `:<git-short-sha>`, and `:<pyproject-version>`
- Cluster deploy via the `valinor` repo at `apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`)
### Fixed
- Lock against public PyPI explicitly — the workstation's global `uv` config had pointed `uv.lock` sources at the dexterenergy private artifact registry, which 401'd inside Docker

View File

@@ -47,14 +47,29 @@ Then on every change:
./build.sh ./build.sh
``` ```
That builds `linux/amd64,linux/arm64` and pushes `rg.nl-ams.scw.cloud/valinor/finrod:{latest, <git-short-sha>}`. Take note of the short SHA it prints — that's what you pin in valinor. That builds `linux/amd64,linux/arm64` and pushes three tags:
- `:latest` — moving pointer
- `:<git-short-sha>` — immutable, traceable
- `:<pyproject-version>` (e.g. `:0.1.0`) — semver
## Deploy ## Deploy
Cluster manifests live in `valinor/apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`). To roll out a new build: Cluster manifests live in `valinor/apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`). To roll out a new build, bump `tag:` in `valinor/apps/finrod/values.yaml` to the new semver on a branch → PR → merge → ArgoCD syncs.
1. `./build.sh` here — get the SHA ## Release workflow
2. Bump `tag:` in `valinor/apps/finrod/values.yaml` to that SHA on a branch
3. Merge → ArgoCD syncs the new image
Using `:latest` works for the first deploy (the pull policy is `Always`) but pinning to a SHA on each rollout gives you trivial rollback via git revert. This project uses [Semantic Versioning](https://semver.org/) and keeps a human-edited [CHANGELOG.md](./CHANGELOG.md).
To cut a release:
1. On a branch, edit `CHANGELOG.md`: move accumulated `[Unreleased]` notes into a new `## [X.Y.Z] - YYYY-MM-DD` section.
2. Bump `version` in `pyproject.toml` to `X.Y.Z`.
3. PR → merge to `main`.
4. Tag the merge commit:
```sh
git checkout main && git pull
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z
```
5. `./build.sh` — the image now also gets `:X.Y.Z` (read from `pyproject.toml`).
6. In `valinor`, bump `apps/finrod/values.yaml` `tag:` from the previous version to `X.Y.Z` on a branch → PR → ArgoCD picks it up.

17
app.py
View File

@@ -49,6 +49,23 @@ async def list_models() -> list[str]:
return sorted(names) return sorted(names)
@app.get("/api/loaded")
async def loaded_models() -> dict[str, list[dict[str, str | None]]]:
"""Models currently resident in Ollama's memory (from /api/ps)."""
async with httpx.AsyncClient(timeout=5) as client:
try:
r = await client.get(f"{OLLAMA_URL}/api/ps")
r.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(502, f"Ollama unreachable: {e}") from e
return {
"models": [
{"name": m["name"], "expires_at": m.get("expires_at")}
for m in r.json().get("models", [])
]
}
@app.post("/api/translate") @app.post("/api/translate")
async def translate(req: TranslateRequest) -> StreamingResponse: async def translate(req: TranslateRequest) -> StreamingResponse:
text = req.text.strip() text = req.text.strip()

View File

@@ -9,20 +9,24 @@
# docker login rg.nl-ams.scw.cloud -u nologin -p <SCW_SECRET_KEY> # docker login rg.nl-ams.scw.cloud -u nologin -p <SCW_SECRET_KEY>
# #
# Usage: # Usage:
# ./build.sh # tags :latest and :<short-sha> # ./build.sh
# TAG=v0.2.0 ./build.sh # additional explicit tag #
# Always tags:
# :latest — moving pointer
# :<git-short-sha> — immutable, traceable to a commit
# :<pyproject-version> — semver from pyproject.toml (e.g. :0.1.0)
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")"
REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}" REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}"
IMAGE="${REGISTRY}/finrod" IMAGE="${REGISTRY}/finrod"
SHA="$(git rev-parse --short HEAD)" SHA="$(git rev-parse --short HEAD)"
TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}") VERSION="$(awk -F\" '/^version =/ {print $2; exit}' pyproject.toml)"
if [[ -n "${TAG:-}" ]]; then : "${VERSION:?Could not read version from pyproject.toml}"
TAGS+=(-t "${IMAGE}:${TAG}")
fi
cd "$(dirname "$0")" TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}" -t "${IMAGE}:${VERSION}")
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
@@ -31,4 +35,7 @@ docker buildx build \
. .
echo echo
echo "Pushed: ${IMAGE}:{latest,${SHA}${TAG:+,${TAG}}}" echo "Pushed:"
echo " ${IMAGE}:latest"
echo " ${IMAGE}:${SHA}"
echo " ${IMAGE}:${VERSION}"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "finrod" name = "finrod"
version = "0.1.0" version = "0.2.0"
description = "Translator UI backed by Ollama" description = "Translator UI backed by Ollama"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -132,6 +132,53 @@
0%, 100% { opacity: 0.3; transform: scale(0.85); } 0%, 100% { opacity: 0.3; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); } 50% { opacity: 1; transform: scale(1.15); }
} }
.hot-status {
font-size: 0.8rem;
color: var(--muted);
margin: 0.25rem 0 0.75rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 1.2em;
}
.hot-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #e07a3e;
box-shadow: 0 0 6px #e07a3e;
}
.hot-status.idle { opacity: 0.55; }
.hot-status.idle .hot-dot {
background: var(--border);
box-shadow: none;
}
/* CodeMirror editor host — matches .output styling so input & output align */
.cm-editor {
min-height: 180px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 1rem; /* match .output's inherited body size — CM6 defaults are smaller */
}
.cm-editor.cm-focused {
outline: none;
border-color: var(--accent);
}
.cm-scroller { padding: 0.6rem 0.75rem; }
.cm-content { caret-color: var(--accent); }
/* vim's command-line / status hint inherits readable colors */
.cm-vim-panel {
background: var(--panel);
color: var(--fg);
border-top: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style> </style>
</head> </head>
<body> <body>
@@ -154,10 +201,15 @@
</div> </div>
</div> </div>
<div id="hot-status" class="hot-status idle">
<span class="hot-dot"></span>
<span id="hot-status-text">No model loaded</span>
</div>
<div class="panes"> <div class="panes">
<div> <div>
<label for="input">Input</label> <label for="input">Input</label>
<textarea id="input" placeholder="Type or paste text to translate…"></textarea> <div id="input"></div>
</div> </div>
<div> <div>
<label for="output">Translation</label> <label for="output">Translation</label>
@@ -169,7 +221,12 @@
<div id="meta" class="meta"></div> <div id="meta" class="meta"></div>
</div> </div>
<script> <script type="module">
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, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
const LANGUAGES = [ const LANGUAGES = [
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish", "English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
"French", "German", "Italian", "Dutch", "Polish", "Russian", "French", "German", "Italian", "Dutch", "Polish", "Russian",
@@ -182,10 +239,42 @@
const sourceSel = document.getElementById("source"); const sourceSel = document.getElementById("source");
const targetSel = document.getElementById("target"); const targetSel = document.getElementById("target");
const modelSel = document.getElementById("model"); const modelSel = document.getElementById("model");
const input = document.getElementById("input");
const output = document.getElementById("output"); const output = document.getElementById("output");
const goBtn = document.getElementById("go"); const goBtn = document.getElementById("go");
const meta = document.getElementById("meta"); const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status");
const hotStatusText = document.getElementById("hot-status-text");
// --- CodeMirror editor (vim mode on real keyboards only) ----------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
let typingTimer;
const editor = new EditorView({
parent: document.getElementById("input"),
state: EditorState.create({
extensions: [
// vim() MUST be first per @replit/codemirror-vim docs so its keymap
// takes precedence. Skipped on touch devices — modal editing without
// a hardware Esc key on a virtual keyboard is unusable, and IME
// composition fights the vim keymap.
...(isCoarsePointer ? [] : [vim()]),
history(),
keymap.of([
{ key: "Mod-Enter", run: () => { translate(); return true; } },
...defaultKeymap,
...historyKeymap,
]),
EditorView.lineWrapping,
placeholder("Type or paste text to translate…"),
EditorView.updateListener.of(u => {
if (u.docChanged) {
clearTimeout(typingTimer);
typingTimer = setTimeout(refreshLoaded, 500);
}
}),
],
}),
});
const inputText = () => editor.state.doc.toString();
function fillLangs() { function fillLangs() {
const auto = document.createElement("option"); const auto = document.createElement("option");
@@ -246,7 +335,7 @@
} }
async function translate() { async function translate() {
const text = input.value.trim(); const text = inputText().trim();
if (!text) return; if (!text) return;
goBtn.disabled = true; goBtn.disabled = true;
goBtn.textContent = "Translating…"; goBtn.textContent = "Translating…";
@@ -313,6 +402,7 @@
output.textContent = acc; output.textContent = acc;
clearInterval(ticker); clearInterval(ticker);
setMeta({ phase: "done", model, seconds: dt, tokens }); setMeta({ phase: "done", model, seconds: dt, tokens });
refreshLoaded();
} catch (e) { } catch (e) {
clearInterval(ticker); clearInterval(ticker);
output.innerHTML = `<span class="error">${e.message || e}</span>`; output.innerHTML = `<span class="error">${e.message || e}</span>`;
@@ -324,13 +414,116 @@
} }
} }
function formatRemaining(expiresAt) {
if (!expiresAt) return null;
const ms = new Date(expiresAt).getTime() - Date.now();
if (!isFinite(ms) || ms <= 0) return null;
const totalSec = Math.floor(ms / 1000);
const mins = Math.floor(totalSec / 60);
const secs = totalSec % 60;
if (mins >= 60) {
const hrs = Math.floor(mins / 60);
const m = mins % 60;
return `${hrs}h${m ? m + "m" : ""}`;
}
if (mins > 0) return `${mins}m${secs ? secs + "s" : ""}`;
return `${secs}s`;
}
async function refreshLoaded(isInitial = false) {
let models = [];
try {
const r = await fetch("/api/loaded");
if (r.ok) {
const j = await r.json();
models = j.models || [];
}
} catch {
/* leave previous state if Ollama is briefly unreachable */
return;
}
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
// Update dropdown options. Use textContent (option doesn't render HTML).
const optionValues = Array.from(modelSel.options, o => o.value);
for (const opt of modelSel.options) {
if (loadedMap.has(opt.value)) {
opt.textContent = `${opt.value}`;
} else {
opt.textContent = opt.value;
}
}
// On the very first refresh after page load, prefer a hot model as the
// dropdown default — skipping disk reload pain. Only on initial load,
// so the user's manual selection isn't yanked away while they type.
if (isInitial) {
const hot = models.find(m => optionValues.includes(m.name));
if (hot) modelSel.value = hot.name;
}
// Update status line.
if (models.length === 0) {
hotStatus.classList.add("idle");
hotStatusText.textContent = "No model loaded";
return;
}
hotStatus.classList.remove("idle");
const parts = models.map(m => {
const remaining = formatRemaining(m.expires_at);
return remaining ? `${m.name} — unloads in ${remaining}` : m.name;
});
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
}
goBtn.addEventListener("click", translate); goBtn.addEventListener("click", translate);
input.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate(); // 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() },
{ el: modelSel, focus: () => modelSel.focus() },
{ 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;
// 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();
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 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(); fillLangs();
loadModels(); loadModels().then(() => refreshLoaded(true));
</script> </script>
</body> </body>
</html> </html>