Add confirm screen for command preview

Amolith created

Add a Confirm screen adapter that shows the fully resolved restic
command line and environment variables before execution. Enter confirms,
Esc goes back. When --show-command is active, the screen auto-completes
so the session exits and the existing dry-run path prints the command.

The confirm screen is always appended as the last screen in the flow by
the resolve screen's builder function.

Change summary

cmd/root.go                         |  25 +++
internal/ui/screens/confirm.go      |  95 +++++++++++++
internal/ui/screens/confirm_test.go | 219 +++++++++++++++++++++++++++++++
3 files changed, 336 insertions(+), 3 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -258,10 +258,29 @@ func runInteractive() (command, preset string, overrides map[string][]string, er
 		}
 
 		cmdScreens = buildCommandScreens(cmd, p, &styles)
-		if cmdScreens == nil {
-			return nil
+
+		// Build the confirmation screen. Its preview function
+		// resolves the config with all current screen values so
+		// it shows exactly what will be executed.
+		previewFn := func() string {
+			var overrides map[string][]string
+			if cmdScreens != nil {
+				overrides = extractCommandOverrides(cmd, cmdScreens)
+			}
+			cfg, err := config.Resolve(p, cmd, overrides)
+			if err != nil {
+				return fmt.Sprintf("config error: %v\n", err)
+			}
+			return restic.DryRun(cfg)
+		}
+		confirmScreen := screens.NewConfirm(previewFn, flagShowCmd)
+
+		var result []ui.Screen
+		if cmdScreens != nil {
+			result = append(result, cmdScreens.list...)
 		}
-		return cmdScreens.list
+		result = append(result, confirmScreen)
+		return result
 	})
 	screenList = append(screenList, resolveScreen)
 

internal/ui/screens/confirm.go 🔗

@@ -0,0 +1,95 @@
+package screens
+
+import (
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+
+	"git.secluded.site/keld/internal/ui"
+)
+
+// PreviewFunc returns the formatted command preview text. It is
+// called asynchronously from Init via a tea.Cmd. The returned string
+// should match the format of [restic.DryRun].
+type PreviewFunc func() string
+
+// previewMsg carries the result of the async preview generation.
+type previewMsg struct {
+	text string
+}
+
+// Confirm is a Screen adapter that shows the resolved restic command
+// and waits for the user to confirm execution. Enter proceeds,
+// Esc goes back.
+//
+// When showCommand is true, Init returns DoneCmd immediately without
+// calling the preview function — the session completes and the caller
+// prints the dry-run output through the normal --show-command path.
+type Confirm struct {
+	previewFn   PreviewFunc
+	showCommand bool
+	preview     string
+	selection   string
+}
+
+// NewConfirm creates a confirmation screen. The previewFn is called
+// to generate the command preview text. When showCommand is true,
+// the screen auto-completes without user interaction.
+func NewConfirm(previewFn PreviewFunc, showCommand bool) *Confirm {
+	return &Confirm{
+		previewFn:   previewFn,
+		showCommand: showCommand,
+	}
+}
+
+// Init starts the preview generation. In --show-command mode, it
+// skips the preview and returns DoneCmd immediately.
+func (c *Confirm) Init() tea.Cmd {
+	c.selection = ""
+
+	if c.showCommand {
+		return ui.DoneCmd
+	}
+
+	return func() tea.Msg {
+		return previewMsg{text: c.previewFn()}
+	}
+}
+
+// Update handles messages. Esc navigates back, Enter confirms.
+func (c *Confirm) Update(msg tea.Msg) (ui.Screen, tea.Cmd) {
+	switch msg := msg.(type) {
+	case previewMsg:
+		c.preview = msg.text
+		return c, nil
+
+	case tea.KeyPressMsg:
+		if msg.Code == tea.KeyEscape {
+			return c, ui.BackCmd
+		}
+		if msg.Code == tea.KeyEnter && c.preview != "" {
+			c.selection = "confirmed"
+			return c, ui.DoneCmd
+		}
+	}
+
+	return c, nil
+}
+
+// View renders the command preview.
+func (c *Confirm) View() string {
+	return c.preview
+}
+
+// Title returns the screen's display title.
+func (c *Confirm) Title() string { return "Confirm execution" }
+
+// KeyBindings returns bindings for the help bar.
+func (c *Confirm) KeyBindings() []key.Binding {
+	return []key.Binding{
+		key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "execute")),
+	}
+}
+
+// Selection returns "confirmed" after the user presses Enter, or ""
+// if the screen has not been confirmed yet.
+func (c *Confirm) Selection() string { return c.selection }

internal/ui/screens/confirm_test.go 🔗

@@ -0,0 +1,219 @@
+package screens
+
+import (
+	"strings"
+	"testing"
+
+	tea "charm.land/bubbletea/v2"
+
+	"git.secluded.site/keld/internal/ui"
+)
+
+func TestConfirmTitle(t *testing.T) {
+	t.Parallel()
+
+	c := NewConfirm(func() string { return "command: restic backup\n" }, false)
+	if got := c.Title(); got != "Confirm execution" {
+		t.Errorf("Title() = %q, want %q", got, "Confirm execution")
+	}
+}
+
+func TestConfirmSelectionEmpty(t *testing.T) {
+	t.Parallel()
+
+	c := NewConfirm(func() string { return "command: restic backup\n" }, false)
+	if got := c.Selection(); got != "" {
+		t.Errorf("Selection() before interaction = %q, want empty", got)
+	}
+}
+
+func TestConfirmKeyBindings(t *testing.T) {
+	t.Parallel()
+
+	c := NewConfirm(func() string { return "command: restic backup\n" }, false)
+	bindings := c.KeyBindings()
+
+	if len(bindings) == 0 {
+		t.Fatal("KeyBindings() returned no bindings")
+	}
+}
+
+func TestConfirmInitCallsPreviewFunc(t *testing.T) {
+	t.Parallel()
+
+	called := false
+	c := NewConfirm(func() string {
+		called = true
+		return "command: restic backup\n"
+	}, false)
+
+	cmd := c.Init()
+	// The preview func is called asynchronously via a tea.Cmd.
+	// Execute the returned cmd to get the message.
+	if cmd != nil {
+		msg := cmd()
+		c.Update(msg)
+	}
+
+	if !called {
+		t.Error("Init() did not call the preview function")
+	}
+}
+
+func TestConfirmViewShowsPreviewText(t *testing.T) {
+	t.Parallel()
+
+	preview := "environ:\n  RESTIC_REPOSITORY=/repo\ncommand: \"restic\" \"backup\" \"/src\"\n"
+	c := NewConfirm(func() string { return preview }, false)
+
+	cmd := c.Init()
+	if cmd != nil {
+		msg := cmd()
+		c.Update(msg)
+	}
+
+	view := c.View()
+	if !strings.Contains(view, "RESTIC_REPOSITORY") {
+		t.Errorf("View() missing environ; got:\n%s", view)
+	}
+	if !strings.Contains(view, "restic") {
+		t.Errorf("View() missing command; got:\n%s", view)
+	}
+}
+
+func TestConfirmEscReturnsBack(t *testing.T) {
+	t.Parallel()
+
+	c := NewConfirm(func() string { return "command: restic backup\n" }, false)
+	cmd := c.Init()
+	if cmd != nil {
+		msg := cmd()
+		c.Update(msg)
+	}
+
+	_, cmd = c.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 TestConfirmEnterReturnsDone(t *testing.T) {
+	t.Parallel()
+
+	c := NewConfirm(func() string { return "command: restic backup\n" }, false)
+	cmd := c.Init()
+	if cmd != nil {
+		msg := cmd()
+		c.Update(msg)
+	}
+
+	_, cmd = c.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+
+	if cmd == nil {
+		t.Fatal("expected DoneCmd on Enter")
+	}
+	msg := cmd()
+	if _, ok := msg.(ui.DoneMsg); !ok {
+		t.Errorf("cmd produced %T, want ui.DoneMsg", msg)
+	}
+}
+
+func TestConfirmSelectionAfterEnter(t *testing.T) {
+	t.Parallel()
+
+	c := NewConfirm(func() string { return "command: restic backup\n" }, false)
+	cmd := c.Init()
+	if cmd != nil {
+		msg := cmd()
+		c.Update(msg)
+	}
+
+	c.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+
+	if got := c.Selection(); got != "confirmed" {
+		t.Errorf("Selection() after enter = %q, want %q", got, "confirmed")
+	}
+}
+
+func TestConfirmShowCommandAutoCompletes(t *testing.T) {
+	t.Parallel()
+
+	// When --show-command is active, Init should return DoneCmd
+	// immediately so the session completes without user interaction.
+	c := NewConfirm(func() string { return "command: restic backup\n" }, true)
+	cmd := c.Init()
+
+	if cmd == nil {
+		t.Fatal("expected DoneCmd from Init when showCommand is true")
+	}
+	msg := cmd()
+	if _, ok := msg.(ui.DoneMsg); !ok {
+		t.Errorf("Init() with showCommand produced %T, want ui.DoneMsg", msg)
+	}
+}
+
+func TestConfirmShowCommandSkipsPreview(t *testing.T) {
+	t.Parallel()
+
+	called := false
+	c := NewConfirm(func() string {
+		called = true
+		return "command: restic backup\n"
+	}, true)
+
+	c.Init()
+
+	if called {
+		t.Error("preview function should not be called when showCommand is true")
+	}
+}
+
+func TestConfirmViewEmptyBeforeInit(t *testing.T) {
+	t.Parallel()
+
+	c := NewConfirm(func() string { return "command: restic backup\n" }, false)
+	if got := c.View(); got != "" {
+		t.Errorf("View() before Init = %q, want empty", got)
+	}
+}
+
+func TestConfirmReinitAfterBack(t *testing.T) {
+	t.Parallel()
+
+	c := NewConfirm(func() string { return "command: restic backup\n" }, false)
+	cmd := c.Init()
+	if cmd != nil {
+		msg := cmd()
+		c.Update(msg)
+	}
+
+	// Confirm.
+	c.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+
+	// Re-init (simulates back navigation and return).
+	cmd = c.Init()
+	if cmd != nil {
+		msg := cmd()
+		c.Update(msg)
+	}
+
+	// Should be functional again — selection should be cleared.
+	if got := c.Selection(); got != "" {
+		t.Errorf("Selection() after re-init = %q, want empty", got)
+	}
+
+	// Should still respond to Enter.
+	_, cmd = c.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	if cmd == nil {
+		t.Fatal("expected DoneCmd after re-init and Enter")
+	}
+	msg := cmd()
+	if _, ok := msg.(ui.DoneMsg); !ok {
+		t.Errorf("after re-init, cmd produced %T, want ui.DoneMsg", msg)
+	}
+}