Add session model for unified interactive TUI

Amolith created

Change summary

internal/ui/screen.go       |  81 +++++
internal/ui/session.go      | 261 ++++++++++++++++++
internal/ui/session_test.go | 553 +++++++++++++++++++++++++++++++++++++++
3 files changed, 895 insertions(+)

Detailed changes

internal/ui/screen.go 🔗

@@ -0,0 +1,81 @@
+// Package ui provides the unified interactive session for keld's TUI.
+//
+// The session runs a single [tea.Program] that coordinates navigation
+// between screens, manages the visual chrome (breadcrumb, title, help
+// bar), and handles back navigation and cancellation.
+package ui
+
+import (
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+)
+
+// Screen is the interface that every step in an interactive flow must
+// satisfy. The session delegates input handling and rendering to the
+// active screen without knowing its internals.
+//
+// Screens are constructed before use and may be re-activated when the
+// user navigates back. Implementations should preserve their state
+// across deactivation/reactivation cycles so previous answers are
+// visible when the user returns.
+type Screen interface {
+	// Init returns the command to run when this screen becomes active.
+	// Called both on first activation and when the user navigates back
+	// to this screen.
+	Init() tea.Cmd
+
+	// Update handles a message and returns the updated screen plus any
+	// command. The session forwards all messages to the active screen,
+	// including Esc key presses.
+	//
+	// Screens that use Esc internally (e.g. closing a filter, navigating
+	// to a parent directory) should handle it and return normally.
+	// Screens that do not need Esc should return a [BackCmd] to signal
+	// that the session should navigate back.
+	Update(msg tea.Msg) (Screen, tea.Cmd)
+
+	// View renders the screen's content. The session wraps this with
+	// chrome (breadcrumb, title, help bar), so the screen should not
+	// render those elements itself.
+	View() string
+
+	// Title returns the screen's display title, shown below the
+	// breadcrumb.
+	Title() string
+
+	// KeyBindings returns the key bindings to display in the help bar
+	// for this screen. The session adds the global bindings (Esc, Ctrl+C)
+	// automatically.
+	//
+	// The returned slice must not be modified by the caller.
+	KeyBindings() []key.Binding
+
+	// Selection returns a short string representing the user's choice
+	// on this screen, for display in the breadcrumb. Returns "" if the
+	// screen has not been completed yet.
+	Selection() string
+}
+
+// BackMsg signals that the active screen wants the session to navigate
+// back to the previous screen. Screens return this via [BackCmd] when
+// they receive an Esc press they do not handle internally.
+type BackMsg struct{}
+
+// BackCmd is a [tea.Cmd] that produces a [BackMsg]. Screens should
+// return this from their Update method when they receive an Esc press
+// and have no internal use for it.
+func BackCmd() tea.Msg {
+	return BackMsg{}
+}
+
+// DoneMsg signals that the active screen has been completed and the
+// session should advance to the next screen. Screens return this via
+// [DoneCmd] when the user confirms their selection.
+type DoneMsg struct{}
+
+// DoneCmd is a [tea.Cmd] that produces a [DoneMsg]. Screens should
+// return this from their Update method when the user makes or confirms
+// a selection.
+func DoneCmd() tea.Msg {
+	return DoneMsg{}
+}

internal/ui/session.go 🔗

@@ -0,0 +1,261 @@
+package ui
+
+import (
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+
+	"git.secluded.site/keld/internal/theme"
+)
+
+// chromeLines is the number of terminal lines reserved for the
+// session's visual frame. Screens receive the terminal height minus
+// this value so they can size themselves without overflowing.
+//
+//	breadcrumb:  1 (0 on first screen, but we reserve 1 for consistency)
+//	title + gap: 2
+//	gap + help:  2
+const chromeLines = 5
+
+// keys defines the global key bindings that the session handles
+// regardless of which screen is active.
+var keys = struct {
+	Back key.Binding
+	Quit key.Binding
+}{
+	Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
+	Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")),
+}
+
+// Session is the top-level tea.Model for keld's interactive mode. It
+// manages a stack of screens, renders chrome, and handles global
+// navigation (back/cancel).
+type Session struct {
+	// screens holds every screen in the flow. The session keeps them
+	// all so back navigation can re-activate earlier screens with
+	// their state intact.
+	screens []Screen
+
+	// cursor points to the currently active screen in the screens
+	// slice.
+	cursor int
+
+	// styles holds the pre-computed theme.
+	styles *theme.Styles
+
+	// help renders the key binding bar at the bottom.
+	help help.Model
+
+	// width and height store the last known terminal dimensions.
+	width, height int
+
+	// lastSize caches the most recent WindowSizeMsg so it can be
+	// replayed to screens activated via advance or back navigation.
+	lastSize *tea.WindowSizeMsg
+
+	// done is set when the session should exit (cancel or complete).
+	done bool
+
+	// completed distinguishes successful flow completion from
+	// cancellation. The caller checks this via Completed() to decide
+	// whether to execute restic.
+	completed bool
+}
+
+// New creates a session with the given screens. Screens that should be
+// skipped (because their value is already resolved) should simply not
+// be included in the slice — the caller is responsible for building
+// only the screens that need user input.
+func New(screens []Screen) Session {
+	// Default to dark until we hear from the terminal.
+	sty := theme.New(true)
+
+	h := help.New()
+	h.Styles = sty.Help
+
+	// Copy the slice so mutations during forwardToScreen don't
+	// affect the caller's original.
+	owned := make([]Screen, len(screens))
+	copy(owned, screens)
+
+	return Session{
+		screens: owned,
+		styles:  &sty,
+		help:    h,
+	}
+}
+
+// Completed reports whether the user finished the entire flow
+// (as opposed to cancelling with Ctrl+C or Esc on the first screen).
+func (s Session) Completed() bool {
+	return s.completed
+}
+
+// Init requests the terminal background colour and window size, then
+// initialises the first screen.
+func (s Session) Init() tea.Cmd {
+	cmds := []tea.Cmd{
+		tea.RequestBackgroundColor,
+		tea.RequestWindowSize,
+	}
+	if len(s.screens) > 0 {
+		cmds = append(cmds, s.screens[s.cursor].Init())
+	}
+	return tea.Batch(cmds...)
+}
+
+// Update handles messages. Global concerns (background detection,
+// resize, Ctrl+C) are handled here. Esc and all other messages are
+// forwarded to the active screen. The session navigates back when
+// it receives a [BackMsg] and advances when it receives a [DoneMsg].
+func (s Session) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.BackgroundColorMsg:
+		sty := theme.New(msg.IsDark())
+		s.styles = &sty
+		s.help.Styles = sty.Help
+		return s, nil
+
+	case tea.WindowSizeMsg:
+		s.width = msg.Width
+		s.height = msg.Height
+		s.lastSize = &msg
+		s.help.SetWidth(msg.Width)
+		// Forward an adjusted size to the active screen so it knows
+		// how much vertical space is available after chrome.
+		return s.forwardToScreen(s.adjustedSizeMsg())
+
+	case tea.KeyPressMsg:
+		if key.Matches(msg, keys.Quit) {
+			s.done = true
+			return s, tea.Quit
+		}
+		// All other keys, including Esc, are forwarded to the screen.
+		// Screens that don't handle Esc return BackCmd.
+		return s.forwardToScreen(msg)
+
+	case BackMsg:
+		return s.navigateBack()
+
+	case DoneMsg:
+		return s.advance()
+	}
+
+	return s.forwardToScreen(msg)
+}
+
+// View renders the chrome (breadcrumb, title, help bar) around the
+// active screen's content.
+func (s Session) View() tea.View {
+	if s.done || len(s.screens) == 0 {
+		return tea.NewView("")
+	}
+
+	screen := s.screens[s.cursor]
+	sty := s.styles
+
+	var b strings.Builder
+
+	// Breadcrumb: show selections from completed screens before the
+	// current one.
+	if crumbs := s.breadcrumb(); crumbs != "" {
+		b.WriteString(sty.Breadcrumb.Render(crumbs))
+		b.WriteString("\n")
+	}
+
+	// Title of the current screen.
+	if title := screen.Title(); title != "" {
+		b.WriteString(sty.Title.Render(title))
+		b.WriteString("\n\n")
+	}
+
+	// Screen content.
+	b.WriteString(screen.View())
+
+	// Help bar: screen-specific bindings + global bindings. Copy
+	// the slice to avoid mutating the screen's backing array.
+	screenBindings := screen.KeyBindings()
+	bindings := make([]key.Binding, 0, len(screenBindings)+2)
+	bindings = append(bindings, screenBindings...)
+	bindings = append(bindings, keys.Back, keys.Quit)
+	b.WriteString("\n")
+	b.WriteString(s.help.ShortHelpView(bindings))
+	b.WriteString("\n")
+
+	return tea.NewView(b.String())
+}
+
+// breadcrumb builds the breadcrumb string from selections of all
+// completed screens before the current one.
+func (s Session) breadcrumb() string {
+	var parts []string
+	for i := 0; i < s.cursor; i++ {
+		if sel := s.screens[i].Selection(); sel != "" {
+			parts = append(parts, sel)
+		}
+	}
+	if len(parts) == 0 {
+		return ""
+	}
+	return strings.Join(parts, " "+lipgloss.NewStyle().Foreground(s.styles.Accent).Render("▸")+" ")
+}
+
+// navigateBack moves to the previous screen, or exits if already on
+// the first screen. Sends the adjusted window size directly to the
+// re-activated screen so it has correct dimensions immediately.
+func (s Session) navigateBack() (tea.Model, tea.Cmd) {
+	if s.cursor <= 0 {
+		// On the first screen: back exits.
+		s.done = true
+		return s, tea.Quit
+	}
+	s.cursor--
+	cmd := s.screens[s.cursor].Init()
+	if s.lastSize != nil {
+		updated, sizeCmd := s.screens[s.cursor].Update(s.adjustedSizeMsg())
+		s.screens[s.cursor] = updated
+		cmd = tea.Batch(cmd, sizeCmd)
+	}
+	return s, cmd
+}
+
+// advance moves to the next screen. If the current screen is the last
+// one, the flow is complete — the session marks itself done and quits.
+// Sends the adjusted window size directly to the newly activated
+// screen so it has correct dimensions immediately.
+func (s Session) advance() (tea.Model, tea.Cmd) {
+	if s.cursor >= len(s.screens)-1 {
+		// Last screen completed: flow is done.
+		s.done = true
+		s.completed = true
+		return s, tea.Quit
+	}
+	s.cursor++
+	cmd := s.screens[s.cursor].Init()
+	if s.lastSize != nil {
+		updated, sizeCmd := s.screens[s.cursor].Update(s.adjustedSizeMsg())
+		s.screens[s.cursor] = updated
+		cmd = tea.Batch(cmd, sizeCmd)
+	}
+	return s, cmd
+}
+
+// forwardToScreen sends a message to the active screen and updates it.
+func (s Session) forwardToScreen(msg tea.Msg) (tea.Model, tea.Cmd) {
+	if len(s.screens) == 0 {
+		return s, nil
+	}
+
+	updated, cmd := s.screens[s.cursor].Update(msg)
+	s.screens[s.cursor] = updated
+	return s, cmd
+}
+
+// adjustedSizeMsg returns a WindowSizeMsg with the height reduced by
+// the chrome lines so screens can size themselves correctly.
+func (s Session) adjustedSizeMsg() tea.WindowSizeMsg {
+	return tea.WindowSizeMsg{Width: s.width, Height: max(1, s.height-chromeLines)}
+}

internal/ui/session_test.go 🔗

@@ -0,0 +1,553 @@
+package ui
+
+import (
+	"strings"
+	"testing"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+)
+
+// fakeScreen is a minimal Screen implementation for testing session
+// navigation without depending on real UI components. It returns
+// BackCmd on Esc to signal back-navigation, matching the expected
+// behaviour for screens that don't use Esc internally.
+type fakeScreen struct {
+	title     string
+	selection string
+	initCalls int
+}
+
+func newFake(title string) *fakeScreen {
+	return &fakeScreen{title: title}
+}
+
+func (f *fakeScreen) Init() tea.Cmd { f.initCalls++; return nil }
+
+func (f *fakeScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
+	if kp, ok := msg.(tea.KeyPressMsg); ok {
+		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
+			return f, BackCmd
+		}
+	}
+	return f, nil
+}
+
+func (f *fakeScreen) View() string               { return f.title + " view" }
+func (f *fakeScreen) Title() string              { return f.title }
+func (f *fakeScreen) KeyBindings() []key.Binding { return nil }
+func (f *fakeScreen) Selection() string          { return f.selection }
+
+// complete marks the fake screen as having a selection for breadcrumb
+// display purposes.
+func (f *fakeScreen) complete(sel string) { f.selection = sel }
+
+// selectingScreen completes itself on any non-Esc key press by setting
+// its selection and returning DoneCmd to signal advancement.
+type selectingScreen struct {
+	fakeScreen
+}
+
+func newSelecting(title string) *selectingScreen {
+	return &selectingScreen{fakeScreen: fakeScreen{title: title}}
+}
+
+func (s *selectingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
+	if kp, ok := msg.(tea.KeyPressMsg); ok {
+		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
+			return s, BackCmd
+		}
+		s.selection = s.title
+		return s, DoneCmd
+	}
+	return s, nil
+}
+
+// escConsumingScreen handles Esc internally (e.g. like a file picker)
+// and does not return BackCmd.
+type escConsumingScreen struct {
+	fakeScreen
+	escHandled bool
+}
+
+func newEscConsuming(title string) *escConsumingScreen {
+	return &escConsumingScreen{fakeScreen: fakeScreen{title: title}}
+}
+
+func (e *escConsumingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
+	if kp, ok := msg.(tea.KeyPressMsg); ok {
+		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
+			e.escHandled = true
+			return e, nil // consume Esc, no BackMsg
+		}
+	}
+	return e, nil
+}
+
+// sizeCaptureScreen records the height from WindowSizeMsg for testing
+// that the session adjusts dimensions correctly.
+type sizeCaptureScreen struct {
+	fakeScreen
+	onSize func(int)
+}
+
+func (s *sizeCaptureScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
+	if wsm, ok := msg.(tea.WindowSizeMsg); ok {
+		if s.onSize != nil {
+			s.onSize(wsm.Height)
+		}
+	}
+	return s.fakeScreen.Update(msg)
+}
+
+func escMsg() tea.Msg {
+	return tea.KeyPressMsg{Code: tea.KeyEscape}
+}
+
+func ctrlCMsg() tea.Msg {
+	return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
+}
+
+func enterMsg() tea.Msg {
+	return tea.KeyPressMsg{Code: tea.KeyEnter}
+}
+
+// resizeMsg simulates a window resize — an unrelated background message.
+func resizeMsg() tea.Msg {
+	return tea.WindowSizeMsg{Width: 80, Height: 24}
+}
+
+func TestSessionInit(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("first")
+	b := newFake("second")
+	s := New([]Screen{a, b})
+	s.Init()
+
+	if a.initCalls != 1 {
+		t.Errorf("first screen Init called %d times, want 1", a.initCalls)
+	}
+	if b.initCalls != 0 {
+		t.Errorf("second screen Init called %d times, want 0", b.initCalls)
+	}
+}
+
+func TestCtrlCExits(t *testing.T) {
+	t.Parallel()
+
+	s := New([]Screen{newFake("one")})
+	s.Init()
+
+	result, cmd := s.Update(ctrlCMsg())
+	sess := result.(Session)
+
+	if !sess.done {
+		t.Error("session should be done after Ctrl+C")
+	}
+	if cmd == nil {
+		t.Error("expected tea.Quit command")
+	}
+}
+
+func TestEscOnFirstScreenExits(t *testing.T) {
+	t.Parallel()
+
+	s := New([]Screen{newFake("one")})
+	s.Init()
+
+	// Esc is forwarded to the screen, which returns BackCmd.
+	result, _ := s.Update(escMsg())
+	// BackMsg triggers navigateBack.
+	result, cmd := result.(Session).Update(BackMsg{})
+	sess := result.(Session)
+
+	if !sess.done {
+		t.Error("Esc on first screen should exit")
+	}
+	if cmd == nil {
+		t.Error("expected tea.Quit command")
+	}
+}
+
+func TestEscNavigatesBack(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("first")
+	b := newFake("second")
+	a.complete("picked")
+
+	s := New([]Screen{a, b})
+	s.cursor = 1
+
+	// Esc → screen returns BackCmd → session receives BackMsg.
+	result, _ := s.Update(escMsg())
+	result, _ = result.(Session).Update(BackMsg{})
+	sess := result.(Session)
+
+	if sess.cursor != 0 {
+		t.Errorf("cursor = %d, want 0 after Esc", sess.cursor)
+	}
+}
+
+func TestBackNavigationPreservesState(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("first")
+	b := newFake("second")
+	a.complete("picked")
+
+	s := New([]Screen{a, b})
+	s.cursor = 1
+
+	result, _ := s.Update(escMsg())
+	result, _ = result.(Session).Update(BackMsg{})
+	sess := result.(Session)
+
+	screen := sess.screens[0].(*fakeScreen)
+	if screen.Selection() != "picked" {
+		t.Errorf("selection = %q, want %q after back navigation", screen.Selection(), "picked")
+	}
+}
+
+func TestBackNavDoesNotReAdvance(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("first")
+	b := newFake("second")
+	a.complete("picked")
+
+	s := New([]Screen{a, b})
+	s.cursor = 1
+
+	// Navigate back to the first screen.
+	result, _ := s.Update(escMsg())
+	result, _ = result.(Session).Update(BackMsg{})
+	sess := result.(Session)
+
+	if sess.cursor != 0 {
+		t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor)
+	}
+
+	// Send an unrelated message (window resize). The session should
+	// NOT re-advance because advancement only happens via DoneMsg,
+	// not by polling Selection().
+	result, _ = sess.Update(resizeMsg())
+	sess = result.(Session)
+
+	if sess.cursor != 0 {
+		t.Errorf("cursor = %d after resize on completed screen, want 0 (should not re-advance)", sess.cursor)
+	}
+}
+
+func TestScreenAdvancesOnDoneMsg(t *testing.T) {
+	t.Parallel()
+
+	a := newSelecting("first")
+	b := newFake("second")
+
+	s := New([]Screen{a, b})
+	s.Init()
+
+	// Send a key to trigger selection on the first screen. The
+	// selectingScreen returns DoneCmd, which produces DoneMsg.
+	result, cmd := s.Update(enterMsg())
+	// The cmd contains DoneCmd; execute it to produce DoneMsg.
+	if cmd == nil {
+		t.Fatal("expected DoneCmd from selecting screen")
+	}
+	// Simulate the runtime delivering the DoneMsg.
+	result, _ = result.(Session).Update(DoneMsg{})
+	sess := result.(Session)
+
+	if sess.cursor != 1 {
+		t.Errorf("cursor = %d, want 1 after DoneMsg", sess.cursor)
+	}
+	if b.initCalls != 1 {
+		t.Errorf("second screen Init called %d times, want 1", b.initCalls)
+	}
+}
+
+func TestReAdvanceAfterBackAndNewSelection(t *testing.T) {
+	t.Parallel()
+
+	a := newSelecting("first")
+	b := newFake("second")
+
+	s := New([]Screen{a, b})
+	s.Init()
+
+	// Advance past the first screen.
+	result, _ := s.Update(enterMsg())
+	result, _ = result.(Session).Update(DoneMsg{})
+	sess := result.(Session)
+
+	if sess.cursor != 1 {
+		t.Fatalf("cursor = %d, want 1 after first advance", sess.cursor)
+	}
+
+	// Navigate back.
+	result, _ = sess.Update(escMsg())
+	result, _ = result.(Session).Update(BackMsg{})
+	sess = result.(Session)
+
+	if sess.cursor != 0 {
+		t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor)
+	}
+
+	// Make a new selection. This should advance again.
+	result, _ = sess.Update(enterMsg())
+	result, _ = result.(Session).Update(DoneMsg{})
+	sess = result.(Session)
+
+	if sess.cursor != 1 {
+		t.Errorf("cursor = %d, want 1 after re-selection", sess.cursor)
+	}
+}
+
+func TestEscConsumedByScreen(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("first")
+	b := newEscConsuming("picker")
+	a.complete("restore")
+
+	s := New([]Screen{a, b})
+	s.cursor = 1
+
+	// Esc is forwarded to the screen, which handles it internally
+	// and does NOT return BackCmd.
+	result, _ := s.Update(escMsg())
+	sess := result.(Session)
+
+	// Should still be on the same screen.
+	if sess.cursor != 1 {
+		t.Errorf("cursor = %d, want 1 (screen consumed Esc)", sess.cursor)
+	}
+
+	screen := sess.screens[1].(*escConsumingScreen)
+	if !screen.escHandled {
+		t.Error("screen should have handled Esc internally")
+	}
+}
+
+func TestBreadcrumbEmpty(t *testing.T) {
+	t.Parallel()
+
+	s := New([]Screen{newFake("one")})
+	crumb := s.breadcrumb()
+
+	if crumb != "" {
+		t.Errorf("breadcrumb on first screen = %q, want empty", crumb)
+	}
+}
+
+func TestBreadcrumbShowsCompletedSelections(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("cmd")
+	b := newFake("preset")
+	c := newFake("details")
+
+	a.complete("restore")
+	b.complete("home@cloud")
+
+	s := New([]Screen{a, b, c})
+	s.cursor = 2
+
+	crumb := s.breadcrumb()
+	if !strings.Contains(crumb, "restore") {
+		t.Errorf("breadcrumb missing 'restore': %q", crumb)
+	}
+	if !strings.Contains(crumb, "home@cloud") {
+		t.Errorf("breadcrumb missing 'home@cloud': %q", crumb)
+	}
+}
+
+func TestBreadcrumbUpdatesOnBack(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("cmd")
+	b := newFake("preset")
+	c := newFake("details")
+
+	a.complete("restore")
+	b.complete("home@cloud")
+
+	s := New([]Screen{a, b, c})
+	s.cursor = 2
+
+	result, _ := s.Update(escMsg())
+	result, _ = result.(Session).Update(BackMsg{})
+	sess := result.(Session)
+
+	crumb := sess.breadcrumb()
+	if !strings.Contains(crumb, "restore") {
+		t.Errorf("breadcrumb after back missing 'restore': %q", crumb)
+	}
+	// On screen b now — b's selection should not be in the crumb
+	// because the breadcrumb only shows screens before the current one.
+	if strings.Contains(crumb, "home@cloud") {
+		t.Errorf("breadcrumb after back should not contain current screen's selection: %q", crumb)
+	}
+}
+
+func TestViewIncludesChrome(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("first")
+	a.complete("restore")
+	b := newFake("Second Step")
+
+	s := New([]Screen{a, b})
+	s.cursor = 1
+	s.width = 80
+	s.height = 24
+
+	view := s.View()
+
+	if !strings.Contains(view.Content, "Second Step") {
+		t.Error("view should contain the screen title")
+	}
+	if !strings.Contains(view.Content, "restore") {
+		t.Error("view should contain the breadcrumb")
+	}
+	if !strings.Contains(view.Content, "esc") {
+		t.Error("view should contain the help bar with esc binding")
+	}
+}
+
+func TestViewEmptyScreens(t *testing.T) {
+	t.Parallel()
+
+	s := New([]Screen{})
+	view := s.View()
+	if view.Content != "" {
+		t.Errorf("view with no screens should be empty, got %q", view.Content)
+	}
+}
+
+func TestBackNavReInitsScreen(t *testing.T) {
+	t.Parallel()
+
+	a := newFake("first")
+	b := newFake("second")
+	a.complete("cmd")
+
+	s := New([]Screen{a, b})
+	s.cursor = 1
+
+	result, _ := s.Update(escMsg())
+	result.(Session).Update(BackMsg{})
+
+	// After navigating back, the first screen should be re-inited.
+	if a.initCalls != 1 {
+		t.Errorf("first screen Init called %d times after back nav, want 1", a.initCalls)
+	}
+}
+
+func TestDoneOnLastScreenCompletes(t *testing.T) {
+	t.Parallel()
+
+	a := newSelecting("only")
+	s := New([]Screen{a})
+	s.Init()
+
+	// Select on the only screen — DoneMsg should complete the session.
+	result, _ := s.Update(enterMsg())
+	result, cmd := result.(Session).Update(DoneMsg{})
+	sess := result.(Session)
+
+	if !sess.done {
+		t.Error("session should be done after DoneMsg on last screen")
+	}
+	if !sess.Completed() {
+		t.Error("session should be completed (not cancelled)")
+	}
+	if cmd == nil {
+		t.Error("expected tea.Quit command")
+	}
+}
+
+func TestCtrlCIsNotCompleted(t *testing.T) {
+	t.Parallel()
+
+	s := New([]Screen{newFake("one")})
+	s.Init()
+
+	result, _ := s.Update(ctrlCMsg())
+	sess := result.(Session)
+
+	if !sess.done {
+		t.Error("session should be done after Ctrl+C")
+	}
+	if sess.Completed() {
+		t.Error("session should NOT be completed after cancellation")
+	}
+}
+
+func TestEscExitIsNotCompleted(t *testing.T) {
+	t.Parallel()
+
+	s := New([]Screen{newFake("one")})
+	s.Init()
+
+	result, _ := s.Update(escMsg())
+	result, _ = result.(Session).Update(BackMsg{})
+	sess := result.(Session)
+
+	if !sess.done {
+		t.Error("session should be done after Esc on first screen")
+	}
+	if sess.Completed() {
+		t.Error("session should NOT be completed after Esc exit")
+	}
+}
+
+func TestWindowSizeAdjustedForChrome(t *testing.T) {
+	t.Parallel()
+
+	var receivedHeight int
+	screen := &sizeCaptureScreen{
+		fakeScreen: fakeScreen{title: "test"},
+		onSize:     func(h int) { receivedHeight = h },
+	}
+
+	s := New([]Screen{screen})
+	s.Init()
+
+	s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
+
+	wantHeight := 30 - chromeLines
+	if receivedHeight != wantHeight {
+		t.Errorf("screen received height %d, want %d (terminal height minus chrome)", receivedHeight, wantHeight)
+	}
+}
+
+func TestSizeReplayedOnAdvance(t *testing.T) {
+	t.Parallel()
+
+	var receivedHeight int
+	a := newSelecting("first")
+	b := &sizeCaptureScreen{
+		fakeScreen: fakeScreen{title: "second"},
+		onSize:     func(h int) { receivedHeight = h },
+	}
+
+	s := New([]Screen{a, b})
+	s.Init()
+
+	// Set terminal size so the session caches it.
+	result, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
+	sess := result.(Session)
+
+	// Advance to second screen. The session sends the adjusted size
+	// directly to the new screen during advance().
+	result, _ = sess.Update(enterMsg())
+	result.(Session).Update(DoneMsg{})
+
+	wantHeight := 30 - chromeLines
+	if receivedHeight != wantHeight {
+		t.Errorf("second screen received height %d after advance, want %d", receivedHeight, wantHeight)
+	}
+}