Add menu and preset screen adapters

Amolith created

Implement the first two Screen adapters for the unified TUI session:

Menu screen (internal/ui/screens/menu.go):
- Fresh implementation with its own MenuItem type, no dependency on
the legacy internal/menu package
- Cursor navigation with clamping, hotkey instant-selection, enter to
confirm
- Rune-aware hotkey matching and label highlighting
- Uses theme.Cursor and accent colour from shared *theme.Styles
- Returns BackCmd on Esc, DoneCmd on selection
- No help text, quit handling, or background detection (session's job)

Preset selector screen (internal/ui/screens/preset.go):
- Wraps a *huh.Form with a filterable Select field
- Form construction deferred to Init() so it picks up the settled theme
- Esc handling checks selectFld.GetFiltering(): if filtering, Esc
closes the filter (delegated to huh); if not, returns BackCmd
- Selection() returns "(global defaults)" for the empty-preset choice,
avoiding ambiguity with the "" not-yet-selected state
- Rebuilds the form on BackgroundColorMsg to pick up updated styles.Huh
- Preserves the previously selected value across re-init for back
navigation

Change summary

internal/ui/screens/menu.go        | 176 ++++++++++++++++++++
internal/ui/screens/menu_test.go   | 275 ++++++++++++++++++++++++++++++++
internal/ui/screens/preset.go      | 149 +++++++++++++++++
internal/ui/screens/preset_test.go | 265 ++++++++++++++++++++++++++++++
4 files changed, 865 insertions(+)

Detailed changes

internal/ui/screens/menu.go šŸ”—

@@ -0,0 +1,176 @@
+// Package screens provides Screen implementations that wrap keld's
+// interactive UI components for use with the unified session.
+package screens
+
+import (
+	"fmt"
+	"strings"
+	"unicode"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+
+	"git.secluded.site/keld/internal/theme"
+	"git.secluded.site/keld/internal/ui"
+)
+
+// MenuItem represents a single entry in the command menu.
+type MenuItem struct {
+	// Label is the display text (e.g. "backup").
+	Label string
+	// Hotkey is the single character that instantly selects this item.
+	Hotkey rune
+	// Value is the string returned by Selection() when chosen. If
+	// empty, Label is used.
+	Value string
+}
+
+// itemValue returns the effective value for an item.
+func (mi MenuItem) itemValue() string {
+	if mi.Value != "" {
+		return mi.Value
+	}
+	return mi.Label
+}
+
+// menuKeys defines the key bindings for the menu screen.
+var menuKeys = struct {
+	Up    key.Binding
+	Down  key.Binding
+	Enter key.Binding
+	Esc   key.Binding
+}{
+	Up:    key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "navigate")),
+	Down:  key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↑/↓", "navigate")),
+	Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
+	Esc:   key.NewBinding(key.WithKeys("esc")),
+}
+
+// Menu is a Screen adapter that presents a selectable list of
+// commands. It replaces the standalone menu.Model with a version
+// that integrates into the unified session.
+type Menu struct {
+	items     []MenuItem
+	cursor    int
+	selection string
+	styles    *theme.Styles
+}
+
+// NewMenu creates a menu screen for the given items. The styles
+// pointer should come from Session.Styles() so theme updates
+// propagate automatically.
+func NewMenu(items []MenuItem, styles *theme.Styles) *Menu {
+	owned := make([]MenuItem, len(items))
+	copy(owned, items)
+	return &Menu{
+		items:  owned,
+		styles: styles,
+	}
+}
+
+// Init is a no-op for the menu — it has no async startup work.
+func (m *Menu) Init() tea.Cmd { return nil }
+
+// Update handles key presses for navigation, selection, and hotkeys.
+func (m *Menu) Update(msg tea.Msg) (ui.Screen, tea.Cmd) {
+	kp, ok := msg.(tea.KeyPressMsg)
+	if !ok {
+		return m, nil
+	}
+
+	switch {
+	case key.Matches(kp, menuKeys.Esc):
+		return m, ui.BackCmd
+
+	case key.Matches(kp, menuKeys.Up):
+		if m.cursor > 0 {
+			m.cursor--
+		}
+		return m, nil
+
+	case key.Matches(kp, menuKeys.Down):
+		if m.cursor < len(m.items)-1 {
+			m.cursor++
+		}
+		return m, nil
+
+	case key.Matches(kp, menuKeys.Enter):
+		m.selection = m.items[m.cursor].itemValue()
+		return m, ui.DoneCmd
+	}
+
+	// Check for hotkey match. Use rune decoding so multi-byte
+	// UTF-8 characters (e.g. 'Ʊ') are handled correctly.
+	runes := []rune(kp.Text)
+	if len(runes) == 1 {
+		for i, item := range m.items {
+			if item.Hotkey == runes[0] {
+				m.cursor = i
+				m.selection = item.itemValue()
+				return m, ui.DoneCmd
+			}
+		}
+	}
+
+	return m, nil
+}
+
+// View renders the menu as a vertical list with a cursor indicator.
+func (m *Menu) View() string {
+	accent := m.styles.Accent
+	hotStyle := lipgloss.NewStyle().Bold(true).Foreground(accent)
+	labelStyle := lipgloss.NewStyle()
+	cursorStyle := lipgloss.NewStyle().Foreground(accent).Bold(true)
+
+	var b strings.Builder
+	for i, item := range m.items {
+		cursor := "  "
+		if i == m.cursor {
+			cursor = cursorStyle.Render(theme.Cursor)
+		}
+
+		line := renderMenuItem(item, hotStyle, labelStyle)
+		fmt.Fprintf(&b, "%s%s\n", cursor, line)
+	}
+
+	return b.String()
+}
+
+// Title returns the menu's display title.
+func (m *Menu) Title() string { return "Select a command" }
+
+// KeyBindings returns the key bindings for the help bar.
+func (m *Menu) KeyBindings() []key.Binding {
+	return []key.Binding{menuKeys.Up, menuKeys.Enter}
+}
+
+// Selection returns the chosen command name, or "" if nothing has
+// been selected yet.
+func (m *Menu) Selection() string { return m.selection }
+
+// renderMenuItem formats a menu item with the hotkey highlighted.
+// For example, with hotkey 'b' and label "backup", it renders
+// "[b]ackup" where [b] is in the accent style.
+//
+// The search is rune-based so multi-byte characters are handled
+// correctly.
+func renderMenuItem(item MenuItem, hotStyle, labelStyle lipgloss.Style) string {
+	label := item.Label
+	hk := unicode.ToLower(item.Hotkey)
+
+	runes := []rune(label)
+	for i, r := range runes {
+		if unicode.ToLower(r) == hk {
+			before := string(runes[:i])
+			match := string(runes[i : i+1])
+			after := string(runes[i+1:])
+			return labelStyle.Render(before) +
+				hotStyle.Render("["+match+"]") +
+				labelStyle.Render(after)
+		}
+	}
+
+	// Hotkey not found in label — show as prefix.
+	return hotStyle.Render("["+string(item.Hotkey)+"]") + " " + labelStyle.Render(label)
+}

internal/ui/screens/menu_test.go šŸ”—

@@ -0,0 +1,275 @@
+package screens
+
+import (
+	"strings"
+	"testing"
+
+	tea "charm.land/bubbletea/v2"
+
+	"git.secluded.site/keld/internal/theme"
+	"git.secluded.site/keld/internal/ui"
+)
+
+// testStyles returns a *theme.Styles for testing. Dark mode is used
+// because it's the session default.
+func testStyles() *theme.Styles {
+	s := theme.New(true)
+	return &s
+}
+
+func testItems() []MenuItem {
+	return []MenuItem{
+		{Label: "backup", Hotkey: 'b'},
+		{Label: "restore", Hotkey: 'r'},
+		{Label: "snapshots", Hotkey: 's'},
+	}
+}
+
+func TestMenuTitle(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+	if got := m.Title(); got != "Select a command" {
+		t.Errorf("Title() = %q, want %q", got, "Select a command")
+	}
+}
+
+func TestMenuKeyBindings(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+	bindings := m.KeyBindings()
+
+	if len(bindings) == 0 {
+		t.Fatal("KeyBindings() returned no bindings")
+	}
+
+	// Should include bindings for navigation and selection.
+	helpKeys := make(map[string]bool)
+	for _, b := range bindings {
+		helpKeys[b.Help().Key] = true
+	}
+
+	for _, want := range []string{"↑/↓", "enter"} {
+		if !helpKeys[want] {
+			t.Errorf("KeyBindings() missing help key %q, got %v", want, helpKeys)
+		}
+	}
+}
+
+func TestMenuSelectionEmpty(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+	if got := m.Selection(); got != "" {
+		t.Errorf("Selection() before interaction = %q, want empty", got)
+	}
+}
+
+func TestMenuEnterSelects(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+
+	// Cursor starts at 0 ("backup"). Press enter.
+	updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	menu := updated.(*Menu)
+
+	if cmd == nil {
+		t.Fatal("expected DoneCmd on enter")
+	}
+	// Verify the cmd produces a DoneMsg.
+	msg := cmd()
+	if _, ok := msg.(ui.DoneMsg); !ok {
+		t.Errorf("cmd produced %T, want ui.DoneMsg", msg)
+	}
+
+	if got := menu.Selection(); got != "backup" {
+		t.Errorf("Selection() = %q, want %q", got, "backup")
+	}
+}
+
+func TestMenuHotkeySelects(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+
+	// Press 'r' — should jump to "restore" and select it.
+	updated, cmd := m.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
+	menu := updated.(*Menu)
+
+	if cmd == nil {
+		t.Fatal("expected DoneCmd on hotkey")
+	}
+	msg := cmd()
+	if _, ok := msg.(ui.DoneMsg); !ok {
+		t.Errorf("cmd produced %T, want ui.DoneMsg", msg)
+	}
+
+	if got := menu.Selection(); got != "restore" {
+		t.Errorf("Selection() = %q, want %q", got, "restore")
+	}
+}
+
+func TestMenuEscReturnsBack(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+
+	_, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
+
+	if cmd == nil {
+		t.Fatal("expected BackCmd on Esc")
+	}
+	msg := cmd()
+	if _, ok := msg.(ui.BackMsg); !ok {
+		t.Errorf("cmd produced %T, want ui.BackMsg", msg)
+	}
+}
+
+func TestMenuCursorNavigation(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+
+	// Start at 0. Move down twice.
+	updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	updated, _ = updated.(*Menu).Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	menu := updated.(*Menu)
+
+	// Select to verify cursor is at index 2 ("snapshots").
+	updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	menu = updated.(*Menu)
+	if got := menu.Selection(); got != "snapshots" {
+		t.Errorf("after two downs, Selection() = %q, want %q", got, "snapshots")
+	}
+}
+
+func TestMenuCursorClampsAtBounds(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+
+	// Try moving up past the top — should clamp at 0.
+	updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
+	menu := updated.(*Menu)
+
+	updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	menu = updated.(*Menu)
+	if got := menu.Selection(); got != "backup" {
+		t.Errorf("after up at top, Selection() = %q, want %q", got, "backup")
+	}
+}
+
+func TestMenuCursorClampsAtBottom(t *testing.T) {
+	t.Parallel()
+
+	items := []MenuItem{{Label: "one", Hotkey: 'o'}}
+	m := NewMenu(items, testStyles())
+
+	// Move down past the bottom — should clamp at last item.
+	updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	menu := updated.(*Menu)
+
+	updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	menu = updated.(*Menu)
+	if got := menu.Selection(); got != "one" {
+		t.Errorf("after down past end, Selection() = %q, want %q", got, "one")
+	}
+}
+
+func TestMenuViewShowsCursor(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+	view := m.View()
+
+	if !strings.Contains(view, theme.Cursor) {
+		t.Errorf("View() should contain cursor %q, got:\n%s", theme.Cursor, view)
+	}
+}
+
+func TestMenuViewNoHelpText(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+	view := m.View()
+
+	// The old menu rendered its own help string. The session now
+	// provides the help bar, so the menu view must not include it.
+	if strings.Contains(view, "navigate") {
+		t.Errorf("View() should not contain help text, got:\n%s", view)
+	}
+	if strings.Contains(view, "q to quit") {
+		t.Errorf("View() should not contain quit help, got:\n%s", view)
+	}
+}
+
+func TestMenuItemValueOverridesLabel(t *testing.T) {
+	t.Parallel()
+
+	items := []MenuItem{
+		{Label: "display name", Hotkey: 'd', Value: "actual-value"},
+	}
+	m := NewMenu(items, testStyles())
+
+	updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	menu := updated.(*Menu)
+
+	if got := menu.Selection(); got != "actual-value" {
+		t.Errorf("Selection() = %q, want %q (Value should override Label)", got, "actual-value")
+	}
+}
+
+func TestMenuQKeyNoSpecialBehavior(t *testing.T) {
+	t.Parallel()
+
+	// Without a quit item, 'q' should do nothing special — it's not
+	// a hotkey for any of these items.
+	m := NewMenu(testItems(), testStyles())
+
+	updated, cmd := m.Update(tea.KeyPressMsg{Code: 'q', Text: "q"})
+	menu := updated.(*Menu)
+
+	if cmd != nil {
+		t.Error("'q' should not produce any command when not a hotkey")
+	}
+	if menu.Selection() != "" {
+		t.Error("'q' should not select anything when not a hotkey")
+	}
+}
+
+func TestMenuKJNavigation(t *testing.T) {
+	t.Parallel()
+
+	m := NewMenu(testItems(), testStyles())
+
+	// 'j' should move down (vim-style).
+	updated, _ := m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
+	menu := updated.(*Menu)
+
+	updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	menu = updated.(*Menu)
+	if got := menu.Selection(); got != "restore" {
+		t.Errorf("after 'j', Selection() = %q, want %q", got, "restore")
+	}
+}
+
+func TestMenuRuneHotkey(t *testing.T) {
+	t.Parallel()
+
+	// A hotkey that is a multi-byte UTF-8 rune (like 'Ʊ') should
+	// still be matched correctly via rune comparison.
+	items := []MenuItem{{Label: "aƱo", Hotkey: 'Ʊ'}}
+	m := NewMenu(items, testStyles())
+
+	updated, cmd := m.Update(tea.KeyPressMsg{Code: 0, Text: "Ʊ"})
+	menu := updated.(*Menu)
+
+	if cmd == nil {
+		t.Fatal("expected DoneCmd for multi-byte rune hotkey")
+	}
+	if menu.Selection() != "aƱo" {
+		t.Errorf("Selection() = %q, want %q", menu.Selection(), "aƱo")
+	}
+}

internal/ui/screens/preset.go šŸ”—

@@ -0,0 +1,149 @@
+package screens
+
+import (
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+
+	"charm.land/huh/v2"
+
+	"git.secluded.site/keld/internal/theme"
+	"git.secluded.site/keld/internal/ui"
+)
+
+// globalDefaultLabel is the breadcrumb display text when the user
+// selects "(global defaults only)". This avoids the ambiguity of
+// Selection() returning "" for both "not yet selected" and "selected
+// global defaults".
+const globalDefaultLabel = "(global defaults)"
+
+// Preset is a Screen adapter that wraps a huh Select form for
+// choosing a configuration preset. It intercepts Esc for back
+// navigation when the filter is not active, and delegates all other
+// input to the embedded form.
+type Preset struct {
+	presets   []string
+	styles    *theme.Styles
+	form      *huh.Form
+	selectFld *huh.Select[string]
+	selected  string
+	selection string
+}
+
+// NewPreset creates a preset selector screen. The styles pointer
+// should come from Session.Styles() so theme updates propagate
+// automatically.
+//
+// The huh form is not built until Init is called, so the form
+// picks up whatever theme the session has settled on by then
+// (after background colour detection).
+func NewPreset(presets []string, styles *theme.Styles) *Preset {
+	return &Preset{
+		presets: presets,
+		styles:  styles,
+	}
+}
+
+// buildForm constructs the huh form and select field. Called from
+// the constructor and from Init when the form needs resetting
+// (e.g. after back navigation).
+func (p *Preset) buildForm() {
+	opts := make([]huh.Option[string], 0, len(p.presets)+1)
+	opts = append(opts, huh.NewOption("(global defaults only)", ""))
+	for _, name := range p.presets {
+		opts = append(opts, huh.NewOption(name, name))
+	}
+
+	// Preserve any previous selection so back-navigation shows
+	// the user's earlier choice. Reset the breadcrumb label since
+	// the screen is being re-activated (no longer "completed").
+	p.selection = ""
+
+	sel := huh.NewSelect[string]().
+		Options(opts...).
+		Value(&p.selected)
+
+	p.selectFld = sel
+
+	p.form = huh.NewForm(
+		huh.NewGroup(sel),
+	).WithTheme(p.styles.Huh).WithShowHelp(false)
+}
+
+// Init initialises the embedded form. On first call or after
+// completion, the form is (re)built so it picks up the current
+// theme and can accept input again.
+func (p *Preset) Init() tea.Cmd {
+	if p.form == nil || p.form.State != huh.StateNormal {
+		p.buildForm()
+	}
+	return p.form.Init()
+}
+
+// Update handles messages. Esc is intercepted for back navigation
+// when the select field is not in filter mode. All other messages
+// are forwarded to the huh form.
+func (p *Preset) Update(msg tea.Msg) (ui.Screen, tea.Cmd) {
+	if p.form == nil {
+		return p, nil
+	}
+
+	switch msg.(type) {
+	case tea.BackgroundColorMsg:
+		// The session has already updated *styles. Rebuild the
+		// form so it picks up the new huh theme.
+		p.buildForm()
+		return p, p.form.Init()
+	}
+
+	// Intercept Esc for back navigation, but only when the select
+	// field is not filtering. When filtering, Esc should close the
+	// filter (handled by huh internally).
+	if kp, ok := msg.(tea.KeyPressMsg); ok {
+		if kp.Code == tea.KeyEscape && !p.selectFld.GetFiltering() {
+			return p, ui.BackCmd
+		}
+	}
+
+	// Forward to huh.
+	model, cmd := p.form.Update(msg)
+	if f, ok := model.(*huh.Form); ok {
+		p.form = f
+	}
+
+	if p.form.State == huh.StateCompleted {
+		if p.selected == "" {
+			p.selection = globalDefaultLabel
+		} else {
+			p.selection = p.selected
+		}
+		return p, ui.DoneCmd
+	}
+
+	return p, cmd
+}
+
+// View renders the form.
+func (p *Preset) View() string {
+	if p.form == nil {
+		return ""
+	}
+	return p.form.View()
+}
+
+// Title returns the screen's display title.
+func (p *Preset) Title() string { return "Select a preset" }
+
+// KeyBindings returns bindings for the help bar. The huh form
+// handles its own key bindings internally; we expose the most
+// relevant ones for display.
+func (p *Preset) KeyBindings() []key.Binding {
+	return []key.Binding{
+		key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")),
+		key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
+		key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
+	}
+}
+
+// Selection returns the chosen preset name for breadcrumb display,
+// or "" if nothing has been selected yet.
+func (p *Preset) Selection() string { return p.selection }

internal/ui/screens/preset_test.go šŸ”—

@@ -0,0 +1,265 @@
+package screens
+
+import (
+	"image/color"
+	"testing"
+
+	tea "charm.land/bubbletea/v2"
+
+	"git.secluded.site/keld/internal/theme"
+	"git.secluded.site/keld/internal/ui"
+)
+
+func testPresets() []string {
+	return []string{"home@cloud", "work@local", "media"}
+}
+
+// drainPreset feeds commands back into the preset screen until a
+// DoneMsg or BackMsg is produced, or the command chain is exhausted.
+// This is necessary because huh uses internal message chains
+// (nextFieldMsg → nextGroupMsg → StateCompleted) that must be
+// processed sequentially.
+func drainPreset(p *Preset, initialCmd tea.Cmd) (*Preset, tea.Cmd) {
+	cmd := initialCmd
+	for cmd != nil {
+		msg := cmd()
+		if msg == nil {
+			return p, nil
+		}
+		// Check if this is one of our terminal messages.
+		switch msg.(type) {
+		case ui.DoneMsg:
+			return p, cmd
+		case ui.BackMsg:
+			return p, cmd
+		}
+		// Otherwise feed it back to the screen.
+		var screen ui.Screen
+		screen, cmd = p.Update(msg)
+		p = screen.(*Preset)
+	}
+	return p, nil
+}
+
+func TestPresetTitle(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	if got := p.Title(); got != "Select a preset" {
+		t.Errorf("Title() = %q, want %q", got, "Select a preset")
+	}
+}
+
+func TestPresetSelectionEmpty(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	if got := p.Selection(); got != "" {
+		t.Errorf("Selection() before interaction = %q, want empty", got)
+	}
+}
+
+func TestPresetKeyBindings(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	bindings := p.KeyBindings()
+
+	if len(bindings) == 0 {
+		t.Fatal("KeyBindings() returned no bindings")
+	}
+}
+
+func TestPresetEscWhenNotFilteringReturnsBack(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	// Initialise the form so huh is ready to receive input.
+	p.Init()
+	// Send a WindowSizeMsg so huh can lay out its fields.
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// Esc when not filtering should signal back navigation.
+	_, cmd := p.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
+
+	if cmd == nil {
+		t.Fatal("expected BackCmd on Esc when not filtering")
+	}
+	msg := cmd()
+	if _, ok := msg.(ui.BackMsg); !ok {
+		t.Errorf("cmd produced %T, want ui.BackMsg", msg)
+	}
+}
+
+func TestPresetEscWhileFilteringDoesNotBack(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// Activate filter mode by pressing '/'.
+	p.Update(tea.KeyPressMsg{Code: '/', Text: "/"})
+
+	// Esc while filtering should close the filter, not back out.
+	updated, cmd := p.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
+	preset := updated.(*Preset)
+
+	// Should still be on this screen (no BackCmd).
+	if cmd != nil {
+		msg := cmd()
+		if _, ok := msg.(ui.BackMsg); ok {
+			t.Error("Esc while filtering should not produce BackMsg")
+		}
+	}
+
+	// Screen should still be functional (not backed out).
+	if preset.Selection() != "" {
+		t.Error("preset should not have a selection after Esc from filter")
+	}
+}
+
+func TestPresetCompleteReturnsDone(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// Press enter to select the focused option (first one: global defaults).
+	// huh uses an internal message chain (nextFieldMsg → nextGroupMsg →
+	// StateCompleted), so we drain until DoneMsg appears.
+	var screen ui.Screen
+	var cmd tea.Cmd
+	screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	_, cmd = drainPreset(screen.(*Preset), cmd)
+
+	if cmd == nil {
+		t.Fatal("expected DoneCmd after form completion")
+	}
+	msg := cmd()
+	if _, ok := msg.(ui.DoneMsg); !ok {
+		t.Errorf("cmd produced %T, want ui.DoneMsg", msg)
+	}
+}
+
+func TestPresetSelectionValue(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// Move down to the first real preset ("home@cloud") and select it.
+	p.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	var screen ui.Screen
+	var cmd tea.Cmd
+	screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	p, _ = drainPreset(screen.(*Preset), cmd)
+
+	if got := p.Selection(); got != "home@cloud" {
+		t.Errorf("Selection() = %q, want %q", got, "home@cloud")
+	}
+}
+
+func TestPresetGlobalDefaultDisplayLabel(t *testing.T) {
+	t.Parallel()
+
+	// When the user selects "(global defaults only)", the resolved
+	// value is "" but the breadcrumb display should be meaningful.
+	p := NewPreset(testPresets(), testStyles())
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// First option is the global default. Select it.
+	var screen ui.Screen
+	var cmd tea.Cmd
+	screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	p, _ = drainPreset(screen.(*Preset), cmd)
+
+	// Selection() should return a display-friendly label, not "".
+	sel := p.Selection()
+	if sel == "" {
+		t.Error("Selection() for global defaults should return a display label, not empty string")
+	}
+}
+
+func TestPresetReinitPreservesSelection(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// Move down to "home@cloud" and select it.
+	p.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	var screen ui.Screen
+	var cmd tea.Cmd
+	screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	p, _ = drainPreset(screen.(*Preset), cmd)
+
+	if p.Selection() != "home@cloud" {
+		t.Fatalf("precondition: Selection() = %q, want %q", p.Selection(), "home@cloud")
+	}
+
+	// Re-initialise (simulates back navigation). The form state
+	// should carry the previous selection through.
+	p.Init()
+
+	// The internal selected value should still be "home@cloud".
+	if p.selected != "home@cloud" {
+		t.Errorf("after re-init, selected = %q, want %q (should preserve previous choice)", p.selected, "home@cloud")
+	}
+}
+
+func TestPresetReinitAfterBack(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// Complete the form first so the state is no longer StateNormal.
+	var screen ui.Screen
+	var cmd tea.Cmd
+	screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	p, _ = drainPreset(screen.(*Preset), cmd)
+
+	// Re-initialise (simulates the session navigating back to this screen).
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// Should be functional again — selecting should produce DoneCmd.
+	screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	_, cmd = drainPreset(screen.(*Preset), cmd)
+
+	if cmd == nil {
+		t.Fatal("expected DoneCmd after re-init, form may not have been reset")
+	}
+	msg := cmd()
+	if _, ok := msg.(ui.DoneMsg); !ok {
+		t.Errorf("after re-init, cmd produced %T, want ui.DoneMsg", msg)
+	}
+}
+
+func TestPresetRebuildsFormOnBackgroundChange(t *testing.T) {
+	t.Parallel()
+
+	styles := testStyles()
+	p := NewPreset(testPresets(), styles)
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	originalForm := p.form
+
+	// Simulate background detection switching to light. The session
+	// would have already mutated *styles before forwarding this msg.
+	*styles = theme.New(false)
+	updated, _ := p.Update(tea.BackgroundColorMsg{Color: color.White})
+	p = updated.(*Preset)
+
+	// The form should have been rebuilt with the new theme.
+	if p.form == originalForm {
+		t.Error("form was not rebuilt after BackgroundColorMsg; cached theme is stale")
+	}
+}