ADR-0006 — TUI navigation model
- Status: Accepted (2026-07-25)
- Related: TUI.md, v0.7 spec §5, THREAT_MODEL.md
Context
Section titled “Context”The v0.7 TUI shipped four peer screens — Checklist, History, Diff, Restore plan
— switched with Tab / Shift-Tab and ← / →. Two problems surfaced as
soon as the UI carried real data.
The screens were not peers. Selecting a snapshot on History and then reading its files on Diff is a drill-down: Diff shows something that belongs to the row you highlighted. Expressing that as a sibling tab meant the relationship was invisible, and returning to History dropped the cursor at the top of the list. On a repository with forty snapshots, reviewing three of them meant re-finding your place three times.
A flat screen set does not extend. Every capability the CLI had and the TUI lacked — drift, plugins, the vault — would have become tab five, six, seven. Tab strips stop being navigable at roughly five entries, and each addition rearranges the strip under the user’s muscle memory.
Separately, the palette was the CLI’s cyan family (256-colour 81/117), which
matches nothing in assets/branding/.
Decision
Section titled “Decision”Levels form a navigation stack, not a tab strip.
enum Level { Snapshots, Tree, Plan }
struct Frame { level: Level, cursor: usize }struct App { stack: Vec<Frame>, /* … */ }Enterpushes,Escpops. The stack is never empty; popping the root is a no-op, soEsccan never be an accidental quit. Quitting isq, always.- Each frame owns its cursor. This is the load-bearing detail: it is what
makes going back lossless, and it is why the cursor cannot live in flat
per-screen fields on
App. - The title bar is a breadcrumb — it reports position rather than offering destinations, so it never rearranges under the cursor.
- The footer lists only the keys valid at the current level.
One-shot flows are modals, not levels. Snapshot creation (the old Checklist screen) overlays the list it acts on. A modal owns input while open, so the level underneath cannot move behind it. The rule: a level is somewhere you browse; a modal is something you complete and dismiss.
The working tree is row 0 of the root list. Drift is one fact about the
home directory against the newest snapshot — not a property each snapshot
carries — so it is a single row rather than a badge repeated down the list.
revision: None already means “the working tree” throughout the CLI, so the
row needs no special case: descending into it takes exactly the path a snapshot
takes.
Colour comes from a Theme, resolved once at startup from the documented
brand hexes, with 256-colour stand-ins for terminals without truecolor. Status
colours (added/removed/warning) are deliberately excluded from the brand
palette: they carry meaning a reader already knows.
The file pane never renders file contents. It shows path, status, mode and owning plugin, then the diff. See Consequences.
Alternatives considered
Section titled “Alternatives considered”Keep tabs, fix the cursor. Storing a per-screen cursor would have fixed the lost-place symptom without addressing the cause — the hierarchy would still be invisible, and tab five would still be coming.
lazygit-style multi-pane dashboard. Rejected on data shape. lazygit’s panels show different views of one repository state; Rivet’s objects nest (snapshot → files → diff). A dashboard would have to fake the nesting with focus rules, which is more machinery for a worse fit. k9s and yazi solve the hierarchy problem, and this is the same problem.
Contents in the preview pane for unchanged files. Requested during design,
and rejected. rivet-core deliberately keeps file contents out of FileDiff;
the pane redraws on cursor movement, so rendering contents would put the
contents of credential-bearing dotfiles (.netrc, .aws/credentials,
.git-credentials) into scrollback, screen shares and script logs with no
keypress. A metadata line answers the real question — “what is this file and
what will restore do to it?” — without the exposure. A future explicit-keypress
viewer would need its own threat-model delta.
Vault write operations in the TUI. Deferred. list / rm / seal /
add-recipient operate on names and recipients and are safe to surface;
vault add takes a secret value, which would give Rivet an in-process
plaintext input path (masked field, zeroized buffer, no leak into the status
line or any Debug impl) that it does not have today. That is a threat-model
change, not a UI change.
Consequences
Section titled “Consequences”Escis unconditionally “back” andqis unconditionally “quit”. Neither key is overloaded, at any depth.- Adding a resource type is adding a
Levelvariant, not a tab. The stack absorbs depth; the breadcrumb absorbs the labelling. FileDiffgainedpluginandmode. Both are additive and#[serde(default)], so existing JSON consumers are unaffected.- The root list’s indices shift by one.
highlighted_revisionunderflows toNoneon row 0, which is the correct answer rather than a guard. - A regression test in
rivet-corepins the no-contents property, so a future change has to argue with it deliberately rather than erode it. - The CLI’s own
ui.rshelpers remain off-brand. They are a separate user-visible surface and change in their own commit.