Multi-window — Window menu, per-window theme, persistence, scope toggle
A tmnl window is the unit of state. Each one owns its own tab strip, its own
split tree, its own theme, and — as of the Phase 6 multi-window arc — its
own slice of the config. You can have a window painted in kanagawa running
mnml on a client repo, a window painted in catppuccin running a stack of
shells against your home lab, and a window with prompt_position = "bottom"
for note-taking, all in the same tmnl process. Quit, relaunch, and every one
of them comes back where you left it.
That’s the deliverable. This page walks the three phases that ship it — the Window menu (6a), per-window theme (6b), per-window cfg override + Apply-to scope toggle (6c) — and then how N windows persist across restarts. It closes with the small list of knobs that are deliberately NOT per-window yet, so you know what to expect when the Settings overlay seems to ignore your edits.
Phase 6a — the Window menu
Section titled “Phase 6a — the Window menu”The first piece is a discoverable path between open windows. The native macOS
menu bar gains a Window submenu with two new items at the top:
| Menu item | Chord | What it does |
| --- | --- | --- |
| Window > Next Window | Cmd+\`` | Focus the next background window. No-op when only one window is open. | | Window > Previous Window|Cmd+Shift+“ | Focus the LAST background window (reverse cycle). |
`Cmd+“ is the standard macOS application-window-cycle chord, so the muscle memory matches every other Mac app. Both menu items stay enabled even when only one window exists — the dispatch is a no-op rather than a greyed-out item, which keeps the menu shape stable.
The pre-existing palette command window.cycle_next keeps its
Cmd+Alt+\`` chord too. The menu chords are the discoverable path; the palette chord is for muscle memory. They both call the same focus_background_window` helper under the hood.
Dynamic window list at the bottom of the menu
Section titled “Dynamic window list at the bottom of the menu”The bottom of the Window menu carries a live, dynamic list of every open
window — the same Safari / Finder convention. The focused window is
✓-marked; background windows are prefixed with two spaces so the
bullet column aligns. Clicking a background window swaps focus to it
(focus_background_window under the hood); clicking the bulleted focused
row dismisses the menu (standard macOS pattern — no-op on the focused
window’s own entry).
Window Minimize ⌘M ─ Next Tab ⌘⇧] Previous Tab ⌘⇧[ … Next Window ⌘` Previous Window ⌘⇧` ─ Focus Pane Left ⌘⌥← Focus Pane Right ⌘⌥→ Focus Pane Up ⌘⌥↑ Focus Pane Down ⌘⌥↓ ─ ← dynamic section below ✓ tmnl — mnml ~/Projects/foo tmnl — shell tmnl — mixrThe list rebuilds each tick from (focused_label, background_labels). A
String fingerprint guards the rebuild — when nothing has changed, the
per-tick sync is a single hash compare. Opening or closing a window
live-updates the list; renaming a tab does too (since tab labels feed the
window label).
IDs on the dynamic items are window_focus_<idx> (idx = position in
App.background_windows) for background entries and window_focus_main
for the focused one; the menu dispatcher parses the suffix to route into
focus_background_window. The dynamic-list bookkeeping lives in
AppMenu::sync_window_list (src/menu.rs).
Per-window settings verbs
Section titled “Per-window settings verbs”The application menu (top-level tmnl) also gains two per-window cfg
verbs for managing overrides without spelunking into the Settings overlay:
| Item | Effect |
| --- | --- |
| Reset Window Settings | Clears the focused window’s cfg_override so it falls back to the global cfg. No-op when no override exists. |
| Apply Window Settings to All Windows | Copies the focused window’s cfg_override into the global cfg, then clears every window’s override (focused + background). Persists. |
These pair with the Settings overlay’s Apply to scope toggle (Phase
6c/2) — once you’ve experimented with a window-scoped override and
decide you like it everywhere, Apply Window Settings to All Windows
promotes it without re-opening Settings.
Move Tab to New Window
Section titled “Move Tab to New Window”Window > Move Tab to New Window pops the focused tab out of the current
window and into a fresh one. No-op when the focused window has only one
tab (the move would leave it empty). The achievable substitute for full
drag-out-of-window-into-new-window; Safari has the same convention with
no accelerator.
Phase 6b — per-window theme
Section titled “Phase 6b — per-window theme”The blocker that made per-window themes hard is that tmnl’s palette is a
process-global OnceLock<RwLock<Palette>> read directly by hundreds of
render-path call sites. Swapping it on focus change would leak the focused
window’s palette into background-window paint that races on the same RwLock
at the same time.
Phase 6b solves it with a thread-local override layer.
How the override layer works
Section titled “How the override layer works”Each window’s redraw runs on a single thread. The paint entry point sets a
thread-local Cell<Option<Palette>> to that window’s chosen palette, then
drops the guard at end-of-function:
// src/app.rs — handle_redraw_requestedlet _g = self.win.theme_palette.map(PaletteScope::push);// … entire paint runs with the per-window palette pushed …// _g drops here, restoring the previous thread-local override.palette() reads the thread-local first and only falls back to the global
RwLock when the slot is empty. Background windows on other threads (or the
same thread between paints) see the global. Single-window users with no
override see the global. The override is opt-in, the cost is one
read-thru on every palette() call, and nesting is LIFO-safe — overlays
painted inside a per-window paint nest cleanly.
Picking a theme per window
Section titled “Picking a theme per window”The Settings overlay (open with Cmd+, or tmnl > Settings…) gains a
Window theme row. Its choices are (global) plus every theme file
discovered under:
~/.config/mnml/themes/~/Local/share/mnml/themes/~/Projects/mnml/themes/
(global) means “inherit the process palette” — the row’s default. Any
other choice is this window’s override. ← / → cycle through the
choices with wrap-around; r resets to (global); Enter saves; Esc
reverts to the snapshot taken when the overlay opened.
Live preview on nudge is not wired today — the row shows the new name
but doesn’t repaint chrome until you press Enter. That’s a deliberate
v1 cut: live preview would either nest a PaletteScope around every
Settings paint frame (cheap but fiddly) or call set_theme on every arrow
press (expensive — one TOML read per nudge). If you want to see the change,
press Enter to commit.
What the override actually changes
Section titled “What the override actually changes”A per-window theme retints every paint path that reads through
palette() — cell colours, the chrome strip, chip backgrounds, the cursor,
the gradient inside the bufferline cluster, all of it. The other window
on your other monitor doesn’t move.
Phase 6c — per-window cfg override
Section titled “Phase 6c — per-window cfg override”Theme is one knob. Phase 6c generalises the same pattern to the whole
Config struct.
The infrastructure (6c/1)
Section titled “The infrastructure (6c/1)”WindowState gains a cfg_override: Option<Config> slot. None (the
default) means “inherit the App’s global cfg.” Some(c) means “paint this
window with c.” A new accessor returns the effective config:
pub fn effective_cfg<'a>(&'a self, global: &'a Config) -> &'a Config { self.cfg_override.as_ref().unwrap_or(global)}Render-path knobs that should vary per window read through
self.win.effective_cfg(&self.cfg) instead of self.cfg directly. The
override is a full-Config overlay, not a sparse per-knob bag — that
keeps the accessor branch trivial and lets the Settings overlay write the
dialog’s edited Config struct verbatim.
The Apply-to scope toggle (6c/2)
Section titled “The Apply-to scope toggle (6c/2)”
The Settings overlay’s first row is Apply to, with two choices:
▸ Apply to: [global] / window Editing globally. Enter writes to ~/.config/tmnl/config.toml and applies process-wide. Switch to "window" to scope changes to this window only.| Scope | What Enter does |
| --- | --- |
| [global] (default) | Writes the cfg to disk. Applies process-wide. Clears any in-flight cfg_override on the focused window so it doesn’t mask the globally-saved value. |
| [window] | Calls set_cfg_override(Some(edited)) on the focused window. On-disk cfg untouched. Other windows unaffected. The focused window’s GPU resizes immediately. |
r (reset row) and R (reset all) return Scope to global. The *
modified marker lights when Scope is window. The help text spells out
exactly what each scope does, so you don’t accidentally write a one-off
window experiment to disk.
When you re-open Settings on a window that already has an override, the
overlay reads effective_cfg so the rows show the override’s values, and
Scope starts on window — so re-opening Settings on a windowed override
doesn’t silently flip to Global on Enter.
Read-site sweep (6c/3)
Section titled “Read-site sweep (6c/3)”Infrastructure plus a toggle is half the story. The other half is
migrating every render-path read site from self.cfg.<knob> to
self.win.effective_cfg(&self.cfg).<knob> so the override actually takes
effect on paint.
Phase 6c/3 swept the following knobs across four stages:
| Stage | Knobs migrated |
| --- | --- |
| 1 | prompt_position, focused_pane_border_color (snapshot-before-gpu-borrow refactor). |
| 2 | selection_invert_fg_bg, cursor_opacity, cursor_blink_animation, cursor_blink_ms, cursor_thickness, bell_flash_color, padding_color, background_opacity (+ _active / _inactive), image_storage_gpu_limit, tab_bar_position, window_padding_balance. |
| 3 | tab_layout (horizontal vs vertical), launcher_position, prompt_position, macOS macos_native_tabs, show_tab_kind_icons, shell_context_chip. |
| 4 | launcher_icons (count for strip-width math + per-icon glyph/colour clone), macOS trackpad_swipe_cycles_tabs, themed_prompt (4 shell-spawn sites). |
Once the sweep landed, a window-scoped Settings save of (say) tab_layout = "vertical" reshapes the focused window’s chrome — sidebar appears,
body grid shifts right — without touching the other window where the
horizontal strip stays the way you had it.
The pattern in every migrated site is snapshot before the GPU
mutable-borrow — you can’t call self.win.effective_cfg(&self.cfg)
inside if let Some(gpu) = &mut self.win.gpu { … } because both come
from self.win. The fix is mechanical: read the knobs into a small
RenderKnobs struct at the top of the paint, then read from the struct
inside the borrow. Single-window users see no behaviour change; the
snapshot resolves to &self.cfg.
What’s NOT per-window yet
Section titled “What’s NOT per-window yet”Three knobs are deliberately process-global and ignore the scope toggle:
font_familyfont_sizebold_is_bright
These are consumed at GPU atlas init time — Gpu::new rasterises every
glyph in the font at the chosen size into a texture, and changing them
mid-window requires rebuilding the atlas, reflowing the grid to the new
cell dimensions, and re-rasterising every glyph. Each Gpu carries its
own atlas instance, but they all read from process-global font setup.
Window-scoped font isn’t a user-asked-for shape anyway — most users want
one font process-wide — so the per-window-atlas rebuild work is
deferred until somebody files a feature ask. If you flip these in the
Settings overlay under [window] scope today, the override is recorded
but doesn’t take effect; the row is honest about which scope it really
honours.
Same shape applies to command and cwd at shell spawn: they remain on
self.cfg, because they’re spawn-time overrides typically set globally
per session (tmnl --command vim etc.), not per window.
Persistence — N windows survive a restart
Section titled “Persistence — N windows survive a restart”The earlier per-window state file (~/.config/tmnl/window_state.toml) was
one struct. Quit with three windows open, relaunch, and only the focused
one came back. Phase 6 persistence v3 fixes that with a forward-compatible
schema bump.
The v3 schema
Section titled “The v3 schema”# Legacy flat fields — mirrors windows[0] so older tmnl binaries# can still read the focused window's geometry.position = [120.0, 80.0]size = [1400.0, 900.0]active = 0theme_name = "kanagawa"cfg_override_toml = """prompt_position = "bottom"tab_layout = "vertical""""
[[windows]]position = [120.0, 80.0]size = [1400.0, 900.0]active = 0theme_name = "kanagawa"cfg_override_toml = """prompt_position = "bottom"tab_layout = "vertical""""# … tabs, etc.
[[windows]]position = [1700.0, 80.0]size = [1400.0, 900.0]active = 2theme_name = "catppuccin"# … tabs, etc.
[[windows]]position = [200.0, 600.0]size = [1200.0, 600.0]active = 0# inherits global theme + cfgEach [[windows]] entry carries position, size, active tab index, theme
override, cfg override (as a TOML-serialized string — see below), and the
per-tab list. The legacy flat fields at the top mirror windows[0] so a
tmnl binary that predates v3 can still restore the focused window’s
geometry from the same file. New tmnl reads [[windows]] and ignores the
flat duplicate.
Why cfg_override_toml is a TOML string instead of a nested Config:
Config { last_window_cfg: Option<Config> } doesn’t terminate. The
string-boxing sidesteps the infinite-type cycle, and the parse cost is
amortised over one read per launch.
Save semantics
Section titled “Save semantics”persist_window_state walks self.background_windows after saving the
focused window and produces a Vec<WindowState>, one entry per open
window. Save happens on every move + resize event, so a force-quit, panic,
or power loss still leaves a recent snapshot.
Order in the vec is focused first, then backgrounds in their stored
order. The next launch restores windows[0] as the focused window.
Resume semantics
Section titled “Resume semantics”App::resumed reads load_all(), consumes the first entry as the focused
window’s snapshot (current behaviour from v2), and queues the rest into a
new pending_restore_windows: Vec<WindowState> field.
App::tick drains one pending entry per tick. Per-tick instead of
all-at-once keeps the first-frame paint snappy and avoids back-to-back
wgpu surface creation stutter (visible on 3+ saved windows). Each
restored background window gets its saved theme + cfg override + position
- size applied, then
restore_tabs_from_statewalks itstabsvec and calls the matchingnew_shell_tab/new_native_tab/ browser helpers. Custom tab names re-apply via the helper’s existing logic.
The originally-focused window keeps focus after the queue drains — the restored windows show up as additional backgrounds you can `Cmd+“ cycle to.
Two persistence stores, one user model
Section titled “Two persistence stores, one user model”window_state.toml is the per-window store described above. There’s
also two process-global fallbacks in ~/.config/tmnl/config.toml:
# Most-recently-saved window theme. Read by `spawn_in_process_window`# (Cmd+N) so a freshly spawned window inherits the family default.last_window_theme = "kanagawa"
# Most-recently-saved window cfg override (TOML-serialized Config).# Same role as last_window_theme for the cfg path.last_window_cfg = "prompt_position = \"bottom\"\n"window_state.toml always wins when both are present. The
last_window_* knobs serve a different role — they’re the “next launch
default for a freshly spawned window in this session,” read by
spawn_in_process_window when Cmd+N produces a new window. The
window_state store can’t serve there because the new window has no
window_state entry yet.
Putting it together
Section titled “Putting it together”A practical session that exercises every piece:
- Launch tmnl. One window opens with the global theme and cfg.
- Hit
Cmd+N. A second window spawns in-process, inheriting the global theme + thelast_window_*knobs if any. - In the second window, open Settings (
Cmd+,), setApply to: [window], changeWindow theme: kanagawa, fliptab_layout: vertical. PressEnter. - The second window retints to kanagawa and grows a left sidebar. The first window is untouched.
- `Cmd+“ cycles you between them. Each paints with its own palette and its own chrome layout.
- Move both windows around. Quit (
Cmd+Q). - Relaunch. Both windows come back, same positions, same themes,
same
tab_layoutchoices, same tabs.
Where to go next
Section titled “Where to go next”- Tabs, splits, and panes — the model inside a single window. Multi-window sits on top of it; each window is a fresh instance of that model.
- Menu bar reference — every Window-menu entry, including the dynamic window list and the per-window cfg verbs in the tmnl menu.
- Tab layout — horizontal vs vertical — one of the per-window knobs that benefits most from window-scoped overrides.
- Native tabs — what happens inside the tabs the Multi-window machinery restores.
- Scrollback dump — capturing every shell pane across every tab before a multi-window session ends.
- FEATURES.md — the shipped-feature inventory.
- The implementation lives in
src/window.rs(WindowState+effective_cfg),src/theme.rs(thePaletteScopeoverride layer),src/settings_ui.rs(the Apply-to + Window theme rows), andsrc/window_state.rs(the v3[[windows]]schema).