# v0.5 — File palette + multi-file > Part of the [Typoena macro plan](macroplan.md). Requirements and targets: > [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md). Prefs reference: > [typoena-toml.md](typoena-toml.md). **Status:** buffer **foundation** landed in core 2026-07-11 (slice 1 of 4), host-tested; the palette + transient panel (Spike 11) and delete → git-staging (Spike 14) remain the on-device gates. The single-file `Effect` return became a drained **effect queue** (`Save{path,contents}` / `Load{path}` / `Publish` / `Pull`), so one action can ask the host for several steps in order — opening a non-resident file queues a `Save` of the outgoing dirty buffer *then* a `Load` of the target. The multi-buffer state deliberately avoids a rope-per-buffer rewrite: the active buffer keeps its fields inline on `Editor`, inactive buffers park in a small LRU `Vec` (≤ 3 resident = active + 2), and a switch marshals fields in/out so the ~3k-line editing engine is untouched. A dirty parked buffer is saved before it is evicted (nothing leaves RAM unsaved); `:e ` opens by prefix (`/sd/repo` → Tracked, `/sd/local` → Local); `:sync` is refused in-core in a Local buffer. Firmware drains the queue **to empty** each batch (a `Load` can cascade an eviction `Save`), and `persistence::{load_path,save_path}` generalise the atomic save off the hard-coded `notes.md`. **Slice 2 of 4 landed in core 2026-07-11**, host-tested: the `Cmd-P` file **palette** — a modal transient panel over the writing column with a bare fuzzy-search input (no `>` prefix: `>` is reserved for the command palette, slice 4 — VS Code semantics), the ranked list, and the selected row in reverse video. A pure host-testable fuzzy matcher (`fuzzy_score`: subsequence match, boundary + consecutive-run bonuses, no penalties) ranks results; an in-core MRU floats recently-opened files to the top on an empty query and is **shared with `:e`** (both flow through `open_path`). The host feeds the file list once at boot (`set_file_list`, enumerating `/sd/repo` + `/sd/local`, dotfiles skipped); `Ctrl-n`/`Ctrl-p` (fzf-style; `Ctrl-d`/`Ctrl-u` too) move the selection — the 60 % board has no arrow keys — Enter opens via the same park/evict path as `:e`, Esc (or `Cmd-P` again) closes. Same slice: **`Ctrl-n`/`Ctrl-p` also work as down/up line motions in Normal mode** (vim `CTRL-N`≡`j`, `CTRL-P`≡`k`, count-aware), which is why the palette opener moved to `Cmd-P` alone. Scope shows as the inline `repo/…` vs `local/…` label rather than the planned `[git]`/`[local]` badge — it also disambiguates subpaths, not just scope. 111 editor tests + 28 keymap tests pass; the no-git firmware binary builds clean. The transient-panel refresh (**Spike 11**) is **CONFIRMED ON DEVICE 2026-07-12 — no ghosting** (user flashed it and eyeballed the full-area partial the palette forces); Cmd-P opens it on-device too. Remaining v0.5 slice: 4 prefs + palette command mode. **Slice 3 (`:enew` + delete) COMPLETE + CONFIRMED ON DEVICE 2026-07-12** (committed `c9c0716`). `:enew ` creates a new file: empty, active, marked **dirty** so eviction/`:w` persists it, and added to the in-core file list so the palette finds it without a disk re-enumeration — no card IO until it is saved. `:delete` unlinks the **current** file (a new `Effect::Delete` the host services), then switches to the most-recently-parked buffer or an empty scratch; the discarded buffer is never saved even when dirty. **Scope for a new file is read from the path, not a modal prompt** — `local/x` / `repo/x` (the palette label form) select the scope, a bare name uses the current buffer's scope. Same change made the **`/sd` prefix optional everywhere** in `resolve_path`: `/sd/repo/x`, `/repo/x`, and `repo/x` all name one file and nothing resolves outside `/sd` (the writer can't reach anything else). **Spike 14 (delete → git-staging) DID need a firmware fix.** The first on-device test found `add_all(["*"])` alone does **not** stage a deletion on this libgit2 build (the tree came back unchanged, so the second push was a silent "up to date" no-op — the "delete didn't work" report). Fix: `stage_and_commit` now runs `add_all` **then `update_all(["*"])`** (`git add -u`), which removes index entries whose working-tree file is gone — together they are `git add -A`. Also, `:delete` gave no clear feedback, so the snackbar now names the scoped file and, for a Tracked file, that it is local until `:sync` (`deleted repo/notes.md - :sync to publish`). Deferred to later: greying the Publish affordance for a Local buffer, and the multi-file publish count. 123 editor tests + 28 keymap tests pass; the no-git firmware binary builds clean. The `update_all` fix (behind `--features git`, unbuildable locally) was **verified on device 2026-07-12** — `:enew test.txt` → `:sync` → `:delete` → `:sync` removed test.txt from origin. **Slice 4 (`.typoena.toml` prefs + palette `>` command mode) COMPLETE in core 2026-07-12, HOST-TESTED not yet on-device.** A `Prefs` type (host-testable line-based TOML parse/serialize — flat `key = value` bools + one string with `#` comments, no crate pulled onto xtensa) lives on `Editor`; the host reads `/sd/repo/.typoena.toml` at boot and applies it before the first render, and a missing/partial file falls back to per-key defaults. Keys: `save_on_idle`, `format_on_save`, `line_numbers` (all bool, default on) and `auto_sync` (string, default `"10m"`, **schema + default only** — nothing reads it yet). `line_numbers` is live: `gutter_cols()` returns 0 when off, so the text reclaims the gutter's columns (the `gutter - 1` field width made saturating to avoid the underflow). The palette `>` command mode (VS Code semantics — a leading `>` in the query switches file search to the command list) exposes the three booleans as live toggles; Enter flips the pref, applies it at once, queues a new `Effect::SavePrefs` (the editor serializes; the host does the atomic write to `.typoena.toml`, which rides the next `:sync` to other devices), and confirms the new state on the snackbar. **The list stays open after a toggle** so several prefs flip in one visit (Esc/`Cmd-P` closes); **`:settings` opens the palette straight into `>` mode** as a one-command shortcut (both requested by the user 2026-07-12, chosen over a separate settings modal — same surface, no duplicate machinery). Committed `c535864`. **Three "decide before build" calls:** (1) the idle auto-save is **unformatted** — `:fmt` runs only on explicit `:w`/`:sync`, so tables/blank-lines are never reflowed mid-session; (2) the per-device `auto_sync` override (card-local `typoena.conf`) is **deferred** — auto_sync is inert in v0.5, so there is nothing yet to override; (3) `> auto sync: ` as a palette command is **deferred to v0.7** — a control that changes a value nothing reads would be a dead switch. `save_on_idle` is honoured host-side: a silent idle auto-save (no snackbar, no forced e-ink flash — a safety net, not an action) fires once per typing burst after a 1.5 s pause. 141 editor tests + 28 keymap tests pass; the no-git firmware binary builds clean. Firmware bumped **0.4.0 → 0.5.0** (the v0.5 feature set is met). **Boot-read of the prefs file CONFIRMED ON DEVICE 2026-07-12** — a `.typoena.toml` in `typoena-test` with non-default values (`save_on_idle=false`, `line_numbers=false`, `auto_sync="5m"`) logged back `prefs: Prefs { save_on_idle: false, format_on_save: true, line_numbers: false, auto_sync: "5m" }` at boot, a byte-exact parse (comments skipped, bools + quoted string read). **Full gate CLOSED 2026-07-12:** the palette `>` live-toggle round-trip is confirmed — origin's `.typoena.toml` went `line_numbers` false → true via a *device*-authored publish (`3c79f38`), proving toggle → `SavePrefs` → atomic write → `git add -A` → push — and the `save_on_idle` autosave works on device too. **v0.5 slice 4 fully DONE + on-device confirmed.** **Amendment 2026-07-12 — non-boolean prefs (`theme`, `auto_sync`).** Two of the "decide before build" calls above are **superseded**: `auto_sync` is now a live palette command, and a new `theme` (`light`/`dark`) key ships. The generalising idea is that a boolean toggle is just the two-option case of *rotate through a preset list on Enter* — so the palette gains one uniform gesture: **Enter advances the selected pref to its next value and wraps** (a bool flips; a string pref cycles its options). `theme` rotates `light`↔`dark` and is applied by a single whole-frame invert at the end of the render ([`Frame::invert`]), so text, selection, caret, panel and palette all flip together; `auto_sync` rotates `2m`/`5m`/`10m`/`15m`/`30m`. Decision (3) — "a value control that changes nothing readable would be a dead switch" — is knowingly overridden: cycling `auto_sync` persists and displays the interval, but **still drives no behaviour until v0.7**; we accept a set-ahead control so the surface is ready and the value syncs now. Decision (2) (per-device `typoena.conf` override) stays deferred. Kept simple: no enum machinery — both string prefs share a `next_option(current, &OPTIONS)` helper, and hand-editing the TOML can still set any value (the palette only cycles presets; an off-list value snaps to the head on the next Enter). Editor tests cover the rotate/wrap/snap, the live theme invert, and the round-trip. **Trailing-newline handling — a saved note ends with a *visible* blank line (commit `d14d9e7`, 2026-07-12; host-tested, on-device gate open).** Two adjustments to how the buffer meets the file. First, format-on-save no longer strips *every* trailing blank line — it collapses a run to **at most one** and keeps it, so a writer who presses Enter to open the next line doesn't have that line (and the caret) yanked away on save (the caret used to jump up to the last non-empty line). Second, persistence treats the file's POSIX terminator as content the editor *shows*: `load_path` reads the file **verbatim** and `save_path` writes the buffer, appending a final newline **only if one is missing** (guarded, not unconditional). Because the editor lays out `rows = #\n + 1`, that terminating newline renders as a **visible trailing empty line** the caret can land on — open a note and the blank line the newline stands for is there. The two are an identity round-trip for any device-written file (all end in `\n`); the file stays git-clean (exactly one terminator — vim and GitHub show no phantom blank line); and a trailing blank line the writer leaves is mirrored, never doubled. This replaced an interim model that stripped the terminator on load and hid it (the file was correct, the newline just wasn't shown). On-device check: reflash → open a note (trailing empty line visible) → `:w` (caret stays on it) → reopen (still there). `Prefs::to_toml` ends in a newline for the same reason; the guarded save leaves the prefs file with exactly one. **Amendment 2026-07-13 — recursive enumeration + a 2-char search threshold.** Loading a real repo (`jcalixte/notes`) exposed that `enumerate_files` listed only the **top-level** files of `/sd/repo` and `/sd/local` — a nested notes tree showed a single file in the palette (subpaths always *opened* fine via `:e repo/sub/x.md`; only the listing was flat). The enumeration is now a recursive walk: dot entries are skipped at every level (so `.git` is never descended into), each directory is read fully before recursing (one FatFS dir handle open at a time — the `remove_dir_recursive` pattern, kind to the FD-bounded mount), depth is capped at 8, and the boot-time walk logs its file count and duration (`file walk: N files in Xms`) so the FAT dir-IO cost on a big repo is measurable, not assumed. With the list now card-sized, the palette gained a **search threshold**: below 2 typed chars the result list is the **recents (MRU) only** — quick-switch (`Cmd-P`, `Enter`) stays one keystroke away — and the full fuzzy-ranked list appears from 2 chars on (`PALETTE_MIN_QUERY`). A fresh boot with no opens yet shows `(type to search)`. `>` commands and `$` snippets are short curated lists; the threshold does not apply to them. **TODO (on-device, next time the device is on the bench)** — two measurements from the same boot log, both already instrumented: - [ ] **Re-measure the walk time** after the d_type fix (`2660a3e` — dirent `file_type()` instead of a per-entry `metadata()` stat, which cost ~32 ms/file and made run 1 take 35 s for 1098 files). Read the `file walk: N files in Xms` line. Only if it's still slow does the async-walk idea come back on the table. - [ ] **Read the file-list DRAM cost** from the new `file list: internal heap -> ( KB consumed)` line (the build is bracketed with `MALLOC_CAP_INTERNAL` readings in `main.rs`). The 1098 path Strings are each below the 16 KB SPIRAM malloc threshold, so they all land in internal DRAM — estimated 60–70 KB, competing with Wi-Fi/TLS. Decision rule: **~60–70 KB confirms** interning the paths into one shared buffer (a single >16 KB alloc goes to PSRAM; only a ~9 KB offset index stays in DRAM); **well under that (≤~30 KB)** kills the idea — the next DRAM suspect is then parked-buffer text. - [x] `Cmd-P` opens fuzzy file palette over **both** `/sd/repo/` and `/sd/local/` — **landed and CONFIRMED ON DEVICE 2026-07-12** (Spike 11: no ghosting on the transient panel); scope shows as the inline `repo/…` / `local/…` label instead of a `[git]`/`[local]` badge. - [~] Open, switch, close buffers (keep ≤ 3 in memory) — **open + switch + the ≤ 3 LRU-resident model with dirty-aware save-before-evict done in core** (host-tested); `:e ` **and the palette** drive it today. Explicit **close** still to come. - [x] `:e` and palette share the same recent-files list — both open via `open_path`, which pushes to the in-core MRU that orders the palette. - [x] `:enew` creates a new file — **done in core (host-tested) 2026-07-12.** Scope is read from the path (`local/x` / `repo/x` select it, the palette label form; a bare name uses the current scope) rather than a modal prompt — the resolved scope is echoed in the snackbar. The `/sd` prefix is optional throughout (`/sd/repo/x` = `/repo/x` = `repo/x`). - [x] Delete a file — **core done (host-tested) 2026-07-12;** `:delete` unlinks the current file via `Effect::Delete`. For a Tracked file the removal reaches the next `:sync` Publish's staged set. **Spike 14 (on-device) found the staging incomplete:** `add_all(["*"])` alone did not stage the deletion, so `stage_and_commit` now also runs `update_all(["*"])` (`git add -u`) — the two together are `git add -A`. A Local file is just unlinked. The snackbar now confirms the delete and flags that a Tracked file needs `:sync`. **Verified on device 2026-07-12** — the `:enew`→`:sync`→`:delete`→`:sync` cycle removed test.txt from origin. - [~] `Ctrl-G` is disabled / hidden when the current buffer is local-scope — **`:sync` / Publish is blocked in-core for a Local buffer** (posts "Publish unavailable (Local)"); the side-panel affordance that hides/greys the gesture is the remaining half. - [ ] The side panel briefly shows file count on `Ctrl-G` when the publish bundles more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`), so workspace-scoped behaviour stays visible to the user - [x] **Preferences file** `/sd/repo/.typoena.toml` — a git-tracked, hand-editable TOML file for editor behaviour, deliberately **distinct from the `/sd/typoena.conf` card secrets** (Wi-Fi / PAT / remote / author, gitignored, never committed — see [v0.1](v0.1-mvp-product.md)). Read at boot; a missing file or key falls back to the defaults below. **Core done 2026-07-12** (a `Prefs` type on `Editor`, host-testable parse/serialize, applied via `Editor::set_prefs` before the first render); full reference: [`typoena-toml.md`](typoena-toml.md). Keys: - [x] `save_on_idle` (bool, default `true`) — auto-save the current buffer on the idle typing-pause, so `:w` becomes optional rather than required. **Honoured host-side** as a *silent* save (no snackbar, no forced e-ink flash — a safety net, not an action), unformatted, once per typing burst after a 1.5 s pause. - [x] `format_on_save` (bool, default `true`) — run `:fmt` (table alignment, blank-line collapse, trailing-whitespace strip) on the buffer before it is persisted, so `:sync` is **fmt → save → commit → push** and `:w` saves formatted. Implemented in-core 2026-07-11 (`Editor`), now **driven by this key**. **Open question RESOLVED (2026-07-12):** fmt runs only on an explicit `:w`/`:sync`; the `save_on_idle` auto-save is deliberately **unformatted**, so tables/blank lines are never reflowed mid-session (the caret would jump under you on every thinking pause). - [x] `line_numbers` (bool, default `true`) — show the absolute line-number gutter (built always-on in v0.2). Off reclaims the gutter's columns for text (`gutter_cols()` → 0); the palette `> line numbers: on/off` command toggles it live. **Done 2026-07-12.** - [x] `theme` (string, default `"light"`; options `light`/`dark`) — panel colour polarity. `dark` is a single whole-frame invert at the end of the render (`Frame::invert`), so everything flips together and stays legible. Palette `> theme` rotates `light`↔`dark` live. **Added 2026-07-12 (amendment); host-tested, on-device check pending.** Caveat: `dark` partial-refreshes ghost more on e-paper than `light` — verify on panel. - [ ] `auto_sync` (duration string, default `"10m"`; `"0"` / omitted disables; **min clamp ~`"2m"`** so a palette typo can't drain the battery) — a *max-staleness cap*, not a wall-clock timer: **opportunistic, rate-limited** Publish. Push when already awake + dirty (coalesced into the idle-pause, ≤ once per `auto_sync`) and once on the way into sleep if dirty; **never wake from deep sleep purely to sync**. Wi-Fi energy is a `1/T` curve whose knee sits at 5–10 min, and `save_on_idle` already owns local data safety — so 10 min halves the sync energy of a 5-min default for no real risk. Full derivation: [`tradeoff-curves/wifi-auto-sync.md`](tradeoff-curves/wifi-auto-sync.md). The **schema + default (`"10m"`) live here in v0.5** and round-trip through `Prefs`; **nothing reads the value yet** — the periodic side rides the better-git work (v0.7) and must interact with light / deep sleep (v0.8). Marked `[~]`: parsed and preserved, no behaviour. **Amended 2026-07-12:** now a palette preset command — Enter rotates it through `2m`/`5m`/`10m`/`15m`/`30m` (the `~2m` min is baked into the preset list). Set-ahead only: still read by nothing until v0.7. - [x] Open question RESOLVED (2026-07-12): the per-device sync cadence override (a card-local `typoena.conf` layer over the committed prefs) is **deferred** — `auto_sync` is inert in v0.5, so there is nothing yet to override; revisit when v0.7 makes the periodic push real. - [x] **Palette command mode** — typing `>` at the `Cmd-P` palette switches it from file search to a command list (VS Code-style). **Done in core 2026-07-12.** The v0.5 commands toggle the three boolean `.typoena.toml` prefs — `> save on idle`, `> format on save`, `> line numbers` — each label carrying its live state; Enter flips the pref, applies it at once, queues `Effect::SavePrefs` (persist to the file), and confirms on the snackbar. **The list stays open after a toggle** (flip several, Esc/`Cmd-P` closes), and **`:settings` opens it directly** — both added 2026-07-12 as the "change config from the device" surface (chosen over a separate settings modal). This command list is the discoverable surface later actions (`:fmt`, font) also register into. **Amended 2026-07-12:** the palette now also carries the non-boolean prefs `> theme` (`light`/`dark`, live whole-frame invert) and `> auto sync` (`2m`..`30m`), both cycled by the same Enter-rotates-to-next gesture. `auto_sync` is exposed **set-ahead** (no behaviour until v0.7), knowingly overriding the earlier "dead switch" call.