Skip to content

ADR-0006 — TUI navigation model

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/.

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>, /* … */ }
  • Enter pushes, Esc pops. The stack is never empty; popping the root is a no-op, so Esc can never be an accidental quit. Quitting is q, 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.

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.

  • Esc is unconditionally “back” and q is unconditionally “quit”. Neither key is overloaded, at any depth.
  • Adding a resource type is adding a Level variant, not a tab. The stack absorbs depth; the breadcrumb absorbs the labelling.
  • FileDiff gained plugin and mode. Both are additive and #[serde(default)], so existing JSON consumers are unaffected.
  • The root list’s indices shift by one. highlighted_revision underflows to None on row 0, which is the correct answer rather than a guard.
  • A regression test in rivet-core pins the no-contents property, so a future change has to argue with it deliberately rather than erode it.
  • The CLI’s own ui.rs helpers remain off-brand. They are a separate user-visible surface and change in their own commit.