Wire unified TUI session into cmd

Amolith created

Replace the chain of independent tea.Programs (standalone menu, then
standalone huh form) with a single session that manages both the command
menu and preset selector as screens.

The interactive entry point now builds screens conditionally:
- Menu screen: always present
- Preset screen: only when >1 preset is defined; auto-selects when
exactly one preset exists; skips entirely when none are defined

After the session completes, the selected command and preset are passed
to runCommand. A presetResolved flag prevents the legacy promptPreset
path from re-prompting when the user chose global defaults (preset =
"").

Remove internal/menu since the new screens.Menu adapter replaces it.
Drop the "quit" menu item from wrapspec.go since the session handles
cancellation via Esc and Ctrl+C.

Add Preset.Value() to distinguish the resolved config preset name (which
may be "") from the breadcrumb display label returned by Selection().

Change summary

cmd/root.go                        |  83 +++++++++++---
cmd/wrapspec.go                    |  13 +-
internal/menu/menu.go              | 177 --------------------------------
internal/ui/screens/preset.go      |   9 +
internal/ui/screens/preset_test.go |  37 ++++++
5 files changed, 114 insertions(+), 205 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -16,15 +16,18 @@ import (
 
 	"git.secluded.site/keld/internal/config"
 	"git.secluded.site/keld/internal/form"
-	"git.secluded.site/keld/internal/menu"
 	"git.secluded.site/keld/internal/picker"
 	"git.secluded.site/keld/internal/restic"
+	"git.secluded.site/keld/internal/theme"
+	"git.secluded.site/keld/internal/ui"
+	"git.secluded.site/keld/internal/ui/screens"
 )
 
 var (
 	flagPreset     string
 	flagShowCmd    bool
 	flagConfigFile string
+	presetResolved bool
 )
 
 const overrideArgumentsKey = "_arguments"
@@ -46,18 +49,23 @@ var rootCmd = &cobra.Command{
 	TraverseChildren: true,
 
 	RunE: func(cmd *cobra.Command, _ []string) error {
-		selected, err := runMenu()
+		commandName, preset, err := runInteractive()
 		if err != nil {
-			return fmt.Errorf("menu: %w", err)
+			return err
 		}
-		if selected == "" || selected == "quit" {
+		if commandName == "" {
+			// User cancelled.
 			return nil
 		}
-		if !slices.Contains(knownCommands, selected) {
-			return fmt.Errorf("unknown menu command %q", selected)
-		}
 
-		return runCommand(selected, cmd, nil)
+		// Preset was resolved by the session; pass it directly
+		// so runCommand skips its own preset prompt.
+		flagPreset = preset
+		// Mark that the session already handled preset selection
+		// so runCommand does not re-prompt even when preset is "".
+		presetResolved = true
+
+		return runCommand(commandName, cmd, nil)
 	},
 }
 
@@ -92,8 +100,10 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string) error
 	overrides := parsePassthrough(rawArgs)
 
 	// In interactive mode, fill missing preset/command inputs via prompts.
+	// When the unified session has already resolved the preset (presetResolved),
+	// skip the standalone preset prompt.
 	if interactive {
-		if preset == "" {
+		if !presetResolved && preset == "" {
 			p, err := promptPreset()
 			if err != nil {
 				return err
@@ -209,20 +219,55 @@ func isFlagToken(arg string) bool {
 	return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--"
 }
 
-// runMenu launches the interactive BubbleTea menu and returns the chosen
-// command, or "" if the user quit.
-func runMenu() (string, error) {
-	m := menu.New(menuItems)
-	p := tea.NewProgram(m)
+// runInteractive launches the unified TUI session with a command
+// menu and (if needed) a preset selector. Returns the chosen command
+// name and preset, or ("", "") if the user cancelled.
+func runInteractive() (command, preset string, err error) {
+	styles := theme.New(true)
+
+	var screenList []ui.Screen
+
+	menuScreen := screens.NewMenu(menuItems, &styles)
+	screenList = append(screenList, menuScreen)
+
+	// Build the preset screen only when needed:
+	// - --preset on CLI: skip (already resolved).
+	// - Zero presets: skip (global defaults only).
+	// - One preset: auto-select, skip.
+	// - Multiple presets: show the filterable selector.
+	presets := config.Presets()
+	var presetScreen *screens.Preset
+	if flagPreset != "" {
+		preset = flagPreset
+	} else if len(presets) > 1 {
+		presetScreen = screens.NewPreset(presets, &styles)
+		screenList = append(screenList, presetScreen)
+	} else if len(presets) == 1 {
+		preset = presets[0]
+	}
+
+	session := ui.New(screenList, &styles)
+	p := tea.NewProgram(session)
 	result, err := p.Run()
 	if err != nil {
-		return "", err
+		return "", "", fmt.Errorf("interactive session: %w", err)
 	}
-	model, ok := result.(menu.Model)
-	if !ok {
-		return "", fmt.Errorf("unexpected menu model type %T", result)
+
+	s := result.(ui.Session)
+	if !s.Completed() {
+		return "", "", nil
+	}
+
+	command = menuScreen.Selection()
+	if !slices.Contains(knownCommands, command) {
+		return "", "", fmt.Errorf("unknown menu command %q", command)
 	}
-	return model.Choice(), nil
+
+	if presetScreen != nil {
+		preset = presetScreen.Value()
+	}
+
+	return command, preset, nil
 }
 
 // validatePreset checks that the given preset name matches one of the usable

cmd/wrapspec.go 🔗

@@ -1,6 +1,6 @@
 package cmd
 
-import "git.secluded.site/keld/internal/menu"
+import "git.secluded.site/keld/internal/ui/screens"
 
 // wrappedCommand describes a restic command that keld exposes in its
 // interactive menu and shell completions.
@@ -21,13 +21,12 @@ var wrappedCommands = []wrappedCommand{
 	{Name: "init", Hotkey: 'i'},
 }
 
-// menuItems is derived from wrappedCommands with a trailing "quit" entry.
-var menuItems = func() []menu.Item {
-	items := make([]menu.Item, 0, len(wrappedCommands)+1)
-	for _, wc := range wrappedCommands {
-		items = append(items, menu.Item{Label: wc.Name, Hotkey: wc.Hotkey})
+// menuItems is derived from wrappedCommands for the interactive menu.
+var menuItems = func() []screens.MenuItem {
+	items := make([]screens.MenuItem, len(wrappedCommands))
+	for i, wc := range wrappedCommands {
+		items[i] = screens.MenuItem{Label: wc.Name, Hotkey: wc.Hotkey}
 	}
-	items = append(items, menu.Item{Label: "quit", Hotkey: 'q'})
 	return items
 }()
 

internal/menu/menu.go 🔗

@@ -1,177 +0,0 @@
-package menu
-
-import (
-	"fmt"
-	"strings"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-)
-
-// Item represents a single menu entry.
-type Item struct {
-	// Label is the full display text (e.g. "backup").
-	Label string
-	// Hotkey is the single character that instantly selects this item (e.g. 'b').
-	Hotkey rune
-	// Value is the string returned by Choice() when this item is selected.
-	// If empty, Label is used.
-	Value string
-}
-
-// Model is a hand-rolled BubbleTea v2 model for an interactive hotkey menu.
-type Model struct {
-	items    []Item
-	cursor   int
-	choice   string
-	quitting bool
-
-	hasDarkBG bool
-	lightDark lipgloss.LightDarkFunc
-}
-
-// New creates a menu Model with the given items.
-func New(items []Item) Model {
-	return Model{
-		items:     items,
-		hasDarkBG: true, // sensible default until we hear from the terminal
-		lightDark: lipgloss.LightDark(true),
-	}
-}
-
-// Choice returns the selected item's value, or "" if nothing was chosen.
-func (m Model) Choice() string {
-	return m.choice
-}
-
-// Init requests the terminal background color so we can adapt styling.
-func (m Model) Init() tea.Cmd {
-	return tea.RequestBackgroundColor
-}
-
-// Update handles key presses and background color detection.
-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.BackgroundColorMsg:
-		m.hasDarkBG = msg.IsDark()
-		m.lightDark = lipgloss.LightDark(m.hasDarkBG)
-		return m, nil
-
-	case tea.KeyPressMsg:
-		switch msg.String() {
-		case "ctrl+c", "q":
-			m.quitting = true
-			return m, tea.Quit
-
-		case "up", "k":
-			if m.cursor > 0 {
-				m.cursor--
-			}
-			return m, nil
-
-		case "down", "j":
-			if m.cursor < len(m.items)-1 {
-				m.cursor++
-			}
-			return m, nil
-
-		case "enter":
-			m.choice = m.itemValue(m.cursor)
-			return m, tea.Quit
-
-		default:
-			// Check if the keypress matches any item's hotkey.
-			if len(msg.Text) == 1 {
-				r := rune(msg.Text[0])
-				for i, item := range m.items {
-					if item.Hotkey == r {
-						m.cursor = i
-						m.choice = m.itemValue(i)
-						return m, tea.Quit
-					}
-				}
-			}
-		}
-	}
-
-	return m, nil
-}
-
-// View renders the menu as an inline vertical list.
-func (m Model) View() tea.View {
-	if m.quitting || m.choice != "" {
-		return tea.NewView("")
-	}
-
-	accentColor := m.lightDark(
-		lipgloss.Color("#7D56F4"),
-		lipgloss.Color("#AD8AFF"),
-	)
-	normalColor := m.lightDark(
-		lipgloss.Color("#333333"),
-		lipgloss.Color("#DDDDDD"),
-	)
-	cursorColor := m.lightDark(
-		lipgloss.Color("#7D56F4"),
-		lipgloss.Color("#AD8AFF"),
-	)
-
-	hotStyle := lipgloss.NewStyle().
-		Bold(true).
-		Foreground(accentColor)
-	labelStyle := lipgloss.NewStyle().
-		Foreground(normalColor)
-	cursorStyle := lipgloss.NewStyle().
-		Foreground(cursorColor).
-		Bold(true)
-
-	var b strings.Builder
-	for i, item := range m.items {
-		cursor := "  "
-		if i == m.cursor {
-			cursor = cursorStyle.Render("▸ ")
-		}
-
-		line := renderItem(item, hotStyle, labelStyle)
-		fmt.Fprintf(&b, "%s%s\n", cursor, line)
-	}
-
-	b.WriteString("\n")
-	b.WriteString(labelStyle.Render("↑/↓ navigate • hotkey or enter to select • q to quit"))
-	b.WriteString("\n")
-
-	return tea.NewView(b.String())
-}
-
-// renderItem formats a single menu item with the hotkey character styled
-// differently from the rest of the label. For example, with hotkey 'b' and
-// label "backup", it renders "[b]ackup" where [b] is in the accent style.
-func renderItem(item Item, hotStyle, labelStyle lipgloss.Style) string {
-	label := item.Label
-	hk := string(item.Hotkey)
-	idx := strings.Index(strings.ToLower(label), strings.ToLower(hk))
-
-	if idx < 0 {
-		// Hotkey not in label — show it as a prefix.
-		return hotStyle.Render("["+hk+"]") + " " + labelStyle.Render(label)
-	}
-
-	before := label[:idx]
-	match := label[idx : idx+len(hk)]
-	after := label[idx+len(hk):]
-
-	return labelStyle.Render(before) +
-		hotStyle.Render("["+match+"]") +
-		labelStyle.Render(after)
-}
-
-// itemValue returns the value for the item at index i.
-func (m Model) itemValue(i int) string {
-	if i < 0 || i >= len(m.items) {
-		return ""
-	}
-	if m.items[i].Value != "" {
-		return m.items[i].Value
-	}
-	return m.items[i].Label
-}

internal/ui/screens/preset.go 🔗

@@ -140,10 +140,15 @@ 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.
+// or "" if nothing has been selected yet. For global defaults this
+// returns a display label like "(global defaults)", not the empty
+// string — use [Value] for the actual config preset name.
 func (p *Preset) Selection() string { return p.selection }
+
+// Value returns the resolved preset name for use in config resolution.
+// Returns "" when the user selected global defaults.
+func (p *Preset) Value() string { return p.selected }

internal/ui/screens/preset_test.go 🔗

@@ -263,3 +263,40 @@ func TestPresetRebuildsFormOnBackgroundChange(t *testing.T) {
 		t.Error("form was not rebuilt after BackgroundColorMsg; cached theme is stale")
 	}
 }
+
+func TestPresetValueReturnsActualPresetName(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 got := p.Value(); got != "home@cloud" {
+		t.Errorf("Value() = %q, want %q", got, "home@cloud")
+	}
+}
+
+func TestPresetValueEmptyForGlobalDefaults(t *testing.T) {
+	t.Parallel()
+
+	p := NewPreset(testPresets(), testStyles())
+	p.Init()
+	p.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+
+	// First option is global defaults. Select it.
+	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.Value(); got != "" {
+		t.Errorf("Value() = %q, want empty for global defaults", got)
+	}
+}