Wire restore flow into unified session

Amolith created

Add a Resolve screen adapter that bridges the menu/preset phase and the
command-specific phase within a single tea.Program. After the user
selects a command and preset, the resolve screen calls config.Resolve to
determine which screens are needed, builds them, and injects them via
ExtendMsg + DoneCmd (using tea.Sequence to guarantee ordering).

The restore flow now runs as one continuous session with full breadcrumb
and back-navigation through all screens: menu → preset → resolve →
snapshot → file picker → target → overwrite

Skip logic matches the existing promptRestore semantics:
- Snapshot screen omitted when config already has arguments
- File picker omitted when include flags are already set
- File picker's loader returns nil for snapshotID:subfolder syntax
- Target screen omitted when config has --target
- Overwrite screen omitted when config has --overwrite

runInteractive now returns overrides from completed screens, which are
converted to raw CLI args and passed to runCommand. This means
runCommand's existing promptForCommand path handles the overrides via
its normal merge logic.

Tested interactively: bare keld → menu → preset → snapshot
(manual) → file picker (notice) → target → overwrite → all
screens show in breadcrumb, Esc navigates back with state preserved,
Ctrl+C exits cleanly.

Change summary

cmd/root.go                    | 214 ++++++++++++++++++++++++++++++++++-
internal/ui/screens/resolve.go |  74 ++++++++++++
2 files changed, 277 insertions(+), 11 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -49,7 +49,7 @@ var rootCmd = &cobra.Command{
 	TraverseChildren: true,
 
 	RunE: func(cmd *cobra.Command, _ []string) error {
-		commandName, preset, err := runInteractive()
+		commandName, preset, overrides, err := runInteractive()
 		if err != nil {
 			return err
 		}
@@ -65,7 +65,28 @@ var rootCmd = &cobra.Command{
 		// so runCommand does not re-prompt even when preset is "".
 		presetResolved = true
 
-		return runCommand(commandName, cmd, nil)
+		// If the session collected command-specific overrides
+		// (e.g. restore flow), pass them as raw args so runCommand
+		// merges them and skips its own prompts.
+		var rawArgs []string
+		for k, vals := range overrides {
+			if k == overrideArgumentsKey {
+				// Positional args go after "--".
+				continue
+			}
+			for _, v := range vals {
+				rawArgs = append(rawArgs, "--"+k, v)
+			}
+			if vals == nil {
+				rawArgs = append(rawArgs, "--"+k)
+			}
+		}
+		if args := overrides[overrideArgumentsKey]; len(args) > 0 {
+			rawArgs = append(rawArgs, "--")
+			rawArgs = append(rawArgs, args...)
+		}
+
+		return runCommand(commandName, cmd, rawArgs)
 	},
 }
 
@@ -220,9 +241,10 @@ func isFlagToken(arg string) bool {
 }
 
 // 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) {
+// menu, preset selector (if needed), and command-specific screens
+// (e.g. the restore flow). Returns the chosen command, preset, any
+// collected overrides, or ("", "", nil, nil) if the user cancelled.
+func runInteractive() (command, preset string, overrides map[string][]string, err error) {
 	styles := theme.New(true)
 
 	var screenList []ui.Screen
@@ -246,28 +268,198 @@ func runInteractive() (command, preset string, err error) {
 		preset = presets[0]
 	}
 
+	// Screen references for extracting results after the session.
+	// These are populated by the resolve screen's builder function.
+	var snapshotScreen *screens.Snapshot
+	var filePickerScreen *screens.FilePicker
+	var targetScreen *screens.Target
+	var overwriteScreen *screens.Overwrite
+
+	// The resolve screen sits between menu/preset and command-specific
+	// screens. It resolves the config and dynamically builds the
+	// remaining screens via ExtendMsg.
+	resolveScreen := screens.NewResolve(func() []ui.Screen {
+		cmd := menuScreen.Selection()
+		p := preset
+		if presetScreen != nil {
+			p = presetScreen.Value()
+		}
+
+		return buildCommandScreens(cmd, p, &styles,
+			&snapshotScreen, &filePickerScreen, &targetScreen, &overwriteScreen)
+	})
+	screenList = append(screenList, resolveScreen)
+
 	session := ui.New(screenList, &styles)
-	p := tea.NewProgram(session)
-	result, err := p.Run()
+	prog := tea.NewProgram(session)
+	result, err := prog.Run()
 	if err != nil {
-		return "", "", fmt.Errorf("interactive session: %w", err)
+		return "", "", nil, fmt.Errorf("interactive session: %w", err)
 	}
 
 	s := result.(ui.Session)
 	if !s.Completed() {
-		return "", "", nil
+		return "", "", nil, nil
 	}
 
 	command = menuScreen.Selection()
 	if !slices.Contains(knownCommands, command) {
-		return "", "", fmt.Errorf("unknown menu command %q", command)
+		return "", "", nil, fmt.Errorf("unknown menu command %q", command)
 	}
 
 	if presetScreen != nil {
 		preset = presetScreen.Value()
 	}
 
-	return command, preset, nil
+	// Extract overrides from the completed command-specific screens.
+	overrides = extractCommandOverrides(command, snapshotScreen, filePickerScreen, targetScreen, overwriteScreen)
+
+	return command, preset, overrides, nil
+}
+
+// buildCommandScreens resolves the config for the given command and
+// preset, then builds the command-specific screens that need user
+// input. Screen pointers are stored in the provided output parameters
+// so the caller can extract results after the session completes.
+func buildCommandScreens(
+	command, preset string,
+	styles *theme.Styles,
+	snapshotOut **screens.Snapshot,
+	filePickerOut **screens.FilePicker,
+	targetOut **screens.Target,
+	overwriteOut **screens.Overwrite,
+) []ui.Screen {
+	switch command {
+	case "restore":
+		return buildRestoreScreens(preset, styles, snapshotOut, filePickerOut, targetOut, overwriteOut)
+	default:
+		return nil
+	}
+}
+
+// buildRestoreScreens creates the restore flow screens, skipping any
+// that are already provided by the resolved config.
+func buildRestoreScreens(
+	preset string,
+	styles *theme.Styles,
+	snapshotOut **screens.Snapshot,
+	filePickerOut **screens.FilePicker,
+	targetOut **screens.Target,
+	overwriteOut **screens.Overwrite,
+) []ui.Screen {
+	cfg, err := config.Resolve(preset, "restore", nil)
+	if err != nil {
+		// Config resolution failed. Return no screens so the session
+		// completes immediately; runCommand will call config.Resolve
+		// again and surface the error to the user.
+		return nil
+	}
+
+	var list []ui.Screen
+
+	// Step 1: Snapshot ID (skip if already provided as an argument).
+	if len(cfg.Arguments) == 0 {
+		loader := screens.SnapshotLoader(func() ([]restic.Snapshot, error) {
+			return restic.ListSnapshots(cfg)
+		})
+		ss := screens.NewSnapshot(loader, styles)
+		*snapshotOut = ss
+		list = append(list, ss)
+	}
+
+	// Step 2: File selection (skip if include flags set or snapshot
+	// uses snapshotID:subfolder syntax). The file picker needs the
+	// snapshot ID — it reads it from a closure.
+	if !hasIncludeFlags(cfg) {
+		// Determine the snapshot ID source: from the snapshot screen
+		// if it exists, otherwise from the resolved config.
+		snapshotIDFn := func() string {
+			if *snapshotOut != nil {
+				return (*snapshotOut).Value()
+			}
+			if len(cfg.Arguments) > 0 {
+				return cfg.Arguments[0]
+			}
+			return ""
+		}
+
+		// Build a loader that checks for the snapshotID:subfolder
+		// skip condition at call time (the ID may not be known until
+		// the snapshot screen completes).
+		fileLoader := func(snapshotID string) ([]restic.LsNode, error) {
+			if strings.Contains(snapshotID, ":") || snapshotID == "" {
+				// Signal that listing should be skipped. Returning
+				// nil nodes causes the picker to auto-advance.
+				return nil, nil
+			}
+			return restic.RunLs(cfg, snapshotID)
+		}
+
+		fps := screens.NewFilePicker(fileLoader, snapshotIDFn, styles)
+		*filePickerOut = fps
+		list = append(list, fps)
+	}
+
+	// Step 3: Target directory (skip if already set).
+	if !cfg.HasFlag("target") {
+		ts := screens.NewTarget(styles)
+		*targetOut = ts
+		list = append(list, ts)
+	}
+
+	// Step 4: Overwrite behaviour (skip if already set).
+	if !cfg.HasFlag("overwrite") {
+		ows := screens.NewOverwrite(styles)
+		*overwriteOut = ows
+		list = append(list, ows)
+	}
+
+	return list
+}
+
+// extractCommandOverrides reads the completed screen values and
+// returns a map suitable for passing to runCommand as overrides.
+func extractCommandOverrides(
+	command string,
+	snapshotScreen *screens.Snapshot,
+	filePickerScreen *screens.FilePicker,
+	targetScreen *screens.Target,
+	overwriteScreen *screens.Overwrite,
+) map[string][]string {
+	if command != "restore" {
+		return nil
+	}
+
+	overrides := make(map[string][]string)
+
+	if snapshotScreen != nil {
+		if v := snapshotScreen.Value(); v != "" {
+			overrides[overrideArgumentsKey] = []string{v}
+		}
+	}
+
+	if filePickerScreen != nil {
+		if includes := filePickerScreen.Includes(); len(includes) > 0 {
+			overrides["include"] = includes
+		}
+	}
+
+	if targetScreen != nil {
+		if v := targetScreen.Value(); v != "" {
+			overrides["target"] = []string{v}
+		}
+	}
+
+	if overwriteScreen != nil {
+		if v := overwriteScreen.Value(); v != "" {
+			overrides["overwrite"] = []string{v}
+		}
+	}
+
+	if len(overrides) == 0 {
+		return nil
+	}
+	return overrides
 }
 
 // validatePreset checks that the given preset name matches one of the usable

internal/ui/screens/resolve.go 🔗

@@ -0,0 +1,74 @@
+package screens
+
+import (
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+
+	"git.secluded.site/keld/internal/ui"
+)
+
+// ResolveFunc is called by the resolve screen to build the next set
+// of screens based on the selections made so far. It returns the
+// screens to append to the session, or nil if no additional screens
+// are needed (the session will complete immediately).
+type ResolveFunc func() []ui.Screen
+
+// resolvedMsg carries the result of an async resolve.
+type resolvedMsg struct {
+	screens []ui.Screen
+}
+
+// Resolve is a transparent Screen adapter that sits between the
+// menu/preset screens and the command-specific screens. It calls an
+// injected function to build the next screens, extends the session
+// via [ui.ExtendMsg], and immediately advances via [ui.DoneCmd].
+//
+// The user never sees this screen — it completes within a single
+// Update cycle.
+type Resolve struct {
+	fn ResolveFunc
+}
+
+// NewResolve creates a resolve screen with the given builder function.
+func NewResolve(fn ResolveFunc) *Resolve {
+	return &Resolve{fn: fn}
+}
+
+// Init triggers the async resolve.
+func (r *Resolve) Init() tea.Cmd {
+	return func() tea.Msg {
+		screens := r.fn()
+		return resolvedMsg{screens: screens}
+	}
+}
+
+// Update handles the resolve result.
+func (r *Resolve) Update(msg tea.Msg) (ui.Screen, tea.Cmd) {
+	switch msg := msg.(type) {
+	case resolvedMsg:
+		if len(msg.screens) == 0 {
+			return r, ui.DoneCmd
+		}
+		return r, tea.Sequence(
+			func() tea.Msg { return ui.ExtendMsg{Screens: msg.screens} },
+			ui.DoneCmd,
+		)
+	case tea.KeyPressMsg:
+		if msg.Code == tea.KeyEscape {
+			return r, ui.BackCmd
+		}
+	}
+	return r, nil
+}
+
+// View is intentionally empty — this screen should not be visible.
+func (r *Resolve) View() string { return "" }
+
+// Title returns empty — this screen has no visible title.
+func (r *Resolve) Title() string { return "" }
+
+// KeyBindings returns no bindings.
+func (r *Resolve) KeyBindings() []key.Binding { return nil }
+
+// Selection returns empty — this screen has no user-visible selection.
+func (r *Resolve) Selection() string { return "" }