@@ -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)}
+}
@@ -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)
+ }
+}