diff --git a/cmd/cmd.go b/cmd/cmd.go index 250b63f40..0f6213141 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -92,13 +92,7 @@ func init() { return userName, err } - launch.DefaultConfirmPrompt = func(prompt string) (bool, error) { - ok, err := tui.RunConfirm(prompt) - if errors.Is(err, tui.ErrCancelled) { - return false, launch.ErrCancelled - } - return ok, err - } + launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions } const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n" diff --git a/cmd/launch/command_test.go b/cmd/launch/command_test.go index 632ae459f..da168438b 100644 --- a/cmd/launch/command_test.go +++ b/cmd/launch/command_test.go @@ -347,7 +347,7 @@ func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) { restore := OverrideIntegration("stubeditor", stub) defer restore() - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("unexpected prompt with --yes: %q", prompt) return false, nil } @@ -393,7 +393,7 @@ func TestLaunchCmdHeadlessWithYes_AutoPullsMissingLocalModel(t *testing.T) { restore := OverrideIntegration("stubapp", stub) defer restore() - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("unexpected prompt with --yes in headless autopull path: %q", prompt) return false, nil } @@ -436,7 +436,7 @@ func TestLaunchCmdHeadlessWithoutYes_ReturnsActionableConfirmError(t *testing.T) restore := OverrideIntegration("stubeditor", stub) defer restore() - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("unexpected prompt in headless non-yes mode: %q", prompt) return false, nil } diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 6d84869c1..8be31592a 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -787,7 +787,7 @@ func TestShowOrPullWithPolicy_ModelExists(t *testing.T) { func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T) { oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatal("confirm prompt should not be called with fail policy") return false, nil } @@ -826,7 +826,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) { oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { if !strings.Contains(prompt, "missing-model") { t.Fatalf("expected prompt to mention missing model, got %q", prompt) } @@ -864,7 +864,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) { func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *testing.T) { oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("confirm prompt should not be called with auto-pull policy: %q", prompt) return false, nil } @@ -900,7 +900,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t * func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *testing.T) { oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatal("confirm prompt should not be called for explicit cloud models") return false, nil } @@ -946,7 +946,7 @@ func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *tes func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *testing.T) { oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatal("confirm prompt should not be called for explicit cloud models") return false, nil } @@ -1035,7 +1035,7 @@ func TestShowOrPull_ShowCalledWithCorrectModel(t *testing.T) { func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) { // Set up hook so confirmPrompt doesn't need a terminal oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { if !strings.Contains(prompt, "missing-model") { t.Errorf("expected prompt to contain model name, got %q", prompt) } @@ -1073,7 +1073,7 @@ func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) { func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) { oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { return false, ErrCancelled } defer func() { DefaultConfirmPrompt = oldHook }() @@ -1103,7 +1103,7 @@ func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) { func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) { // Confirm prompt should NOT be called for explicit cloud models oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Error("confirm prompt should not be called for cloud models") return false, nil } @@ -1143,7 +1143,7 @@ func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) { func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) { // Confirm prompt should NOT be called for explicit cloud models oldHook := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Error("confirm prompt should not be called for cloud models") return false, nil } @@ -1183,7 +1183,7 @@ func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) { func TestConfirmPrompt_DelegatesToHook(t *testing.T) { oldHook := DefaultConfirmPrompt var hookCalled bool - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { hookCalled = true if prompt != "test prompt?" { t.Errorf("expected prompt %q, got %q", "test prompt?", prompt) @@ -1307,7 +1307,7 @@ func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) { func TestEnsureAuth_DeclinedFallbackReturnsCancelled(t *testing.T) { oldConfirm := DefaultConfirmPrompt - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { return false, nil } defer func() { DefaultConfirmPrompt = oldConfirm }() diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index cff61e15b..dd65466f8 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -756,7 +756,6 @@ func (c *launcherClient) loadModelInventoryOnce(ctx context.Context) error { } func runIntegration(runner Runner, modelName string, args []string) error { - fmt.Fprintf(os.Stderr, "\nLaunching %s with %s...\n", runner, modelName) return runner.Run(modelName, args) } diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index 5179504f3..a9bd53c7b 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -618,7 +618,7 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) { } var proceedPrompt bool - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { if prompt == "Proceed?" { proceedPrompt = true } @@ -847,7 +847,7 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsMissingLocalAndPersistsAccep DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { return []string{"glm-5:cloud", "missing-local"}, nil } - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { if prompt == "Proceed?" { return true, nil } @@ -929,7 +929,7 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsUnauthedCloudAndPersistsAcce DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { return []string{"llama3.2", "glm-5:cloud"}, nil } - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { if prompt == "Proceed?" { return true, nil } @@ -1014,7 +1014,7 @@ func TestLaunchIntegration_EditorConfigureMultiRemovesReselectedFailingModel(t * DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { return append([]string(nil), preChecked...), nil } - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { if prompt == "Proceed?" { return true, nil } @@ -1101,7 +1101,7 @@ func TestLaunchIntegration_EditorConfigureMultiAllFailuresKeepsExistingAndSkipsL DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { return []string{"missing-local-a", "missing-local-b"}, nil } - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { if prompt == "Download missing-local-a?" || prompt == "Download missing-local-b?" { return false, nil } @@ -1180,7 +1180,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing t.Fatalf("failed to seed config: %v", err) } - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("did not expect prompt during normal configured launch: %q", prompt) return false, nil } @@ -1245,7 +1245,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) t.Fatalf("failed to seed config: %v", err) } - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt) return false, nil } @@ -1410,7 +1410,7 @@ func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing } var prompts []string - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { prompts = append(prompts, prompt) if strings.Contains(prompt, "Launch LauncherEditor now?") { return false, nil @@ -1569,7 +1569,7 @@ func TestLaunchIntegration_ClaudeForceConfigureMissingSelectionDoesNotSave(t *te DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { return "missing-model", nil } - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { if prompt == "Download missing-model?" { return false, nil } @@ -1631,7 +1631,7 @@ func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) { } var confirmCalls int - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { confirmCalls++ if !strings.Contains(prompt, "glm-4") { t.Fatalf("expected download prompt for override model, got %q", prompt) @@ -1695,7 +1695,7 @@ func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) { } var prompts []string - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { prompts = append(prompts, prompt) if strings.Contains(prompt, "Launch StubSingle now?") { return false, nil @@ -1745,7 +1745,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessMissingFailsWithoutPrompt(t *tes withIntegrationOverride(t, "droid", runner) confirmCalled := false - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { confirmCalled = true return true, nil } @@ -1802,7 +1802,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessCanOverrideMissingModelPolicy(t withIntegrationOverride(t, "droid", runner) confirmCalled := false - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { confirmCalled = true if !strings.Contains(prompt, "missing-model") { t.Fatalf("expected prompt to mention missing model, got %q", prompt) @@ -1860,7 +1860,7 @@ func TestLaunchIntegration_ModelOverrideInteractiveMissingPromptsAndPulls(t *tes withIntegrationOverride(t, "droid", runner) confirmCalled := false - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { confirmCalled = true if !strings.Contains(prompt, "missing-model") { t.Fatalf("expected prompt to mention missing model, got %q", prompt) @@ -1921,7 +1921,7 @@ func TestLaunchIntegration_HeadlessSelectorFlowFailsWithoutPrompt(t *testing.T) } confirmCalled := false - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { confirmCalled = true return true, nil } diff --git a/cmd/launch/openclaw.go b/cmd/launch/openclaw.go index 5373a70db..19d4dcc45 100644 --- a/cmd/launch/openclaw.go +++ b/cmd/launch/openclaw.go @@ -102,8 +102,6 @@ func (c *Openclaw) Run(model string, args []string) error { registerWebSearchPlugin() } - fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset) - // When extra args are passed through, run exactly what the user asked for // after setup and skip the built-in gateway+TUI convenience flow. if len(args) > 0 { @@ -118,6 +116,15 @@ func (c *Openclaw) Run(model string, args []string) error { return nil } + if err := c.runChannelSetupPreflight(bin); err != nil { + return err + } + // Keep local pairing scopes up to date before the gateway lifecycle + // (restart/start) regardless of channel preflight branch behavior. + patchDeviceScopes() + + fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset) + token, port := c.gatewayInfo() addr := fmt.Sprintf("localhost:%d", port) @@ -172,6 +179,89 @@ func (c *Openclaw) Run(model string, args []string) error { return nil } +// runChannelSetupPreflight prompts users to connect a messaging channel before +// starting the built-in gateway+TUI flow. In interactive sessions, it loops +// until a channel is configured, unless the user chooses "Set up later". +func (c *Openclaw) runChannelSetupPreflight(bin string) error { + if !isInteractiveSession() { + return nil + } + + for { + if c.channelsConfigured() { + return nil + } + + fmt.Fprintf(os.Stderr, "\nYour assistant can message you on WhatsApp, Telegram, Discord, and more.\n\n") + ok, err := ConfirmPromptWithOptions("Connect a messaging app now?", ConfirmOptions{ + YesLabel: "Yes", + NoLabel: "Set up later", + }) + if err != nil { + return err + } + if !ok { + return nil + } + + cmd := exec.Command(bin, "channels", "add") + cmd.Env = openclawEnv() + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return windowsHint(fmt.Errorf("openclaw channel setup failed: %w\n\nTry running: %s channels add", err, bin)) + } + } +} + +// channelsConfigured reports whether local OpenClaw config contains at least +// one meaningfully configured channel entry. +func (c *Openclaw) channelsConfigured() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + + for _, path := range []string{ + filepath.Join(home, ".openclaw", "openclaw.json"), + filepath.Join(home, ".clawdbot", "clawdbot.json"), + } { + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var cfg map[string]any + if json.Unmarshal(data, &cfg) != nil { + continue + } + + channels, _ := cfg["channels"].(map[string]any) + if channels == nil { + return false + } + + for key, value := range channels { + if key == "defaults" || key == "modelByChannel" { + continue + } + entry, ok := value.(map[string]any) + if !ok { + continue + } + for entryKey := range entry { + if entryKey != "enabled" { + return true + } + } + } + return false + } + + return false +} + // gatewayInfo reads the gateway auth token and port from the OpenClaw config. func (c *Openclaw) gatewayInfo() (token string, port int) { port = defaultGatewayPort @@ -218,12 +308,9 @@ func printOpenclawReady(bin, token string, port int, firstLaunch bool) { if firstLaunch { fmt.Fprintf(os.Stderr, "%s Quick start:%s\n", ansiBold, ansiReset) fmt.Fprintf(os.Stderr, "%s /help see all commands%s\n", ansiGray, ansiReset) - fmt.Fprintf(os.Stderr, "%s %s configure --section channels connect WhatsApp, Telegram, etc.%s\n", ansiGray, bin, ansiReset) fmt.Fprintf(os.Stderr, "%s %s skills browse and install skills%s\n\n", ansiGray, bin, ansiReset) fmt.Fprintf(os.Stderr, "%s The OpenClaw gateway is running in the background.%s\n", ansiYellow, ansiReset) fmt.Fprintf(os.Stderr, "%s Stop it with: %s gateway stop%s\n\n", ansiYellow, bin, ansiReset) - } else { - fmt.Fprintf(os.Stderr, "%sTip: connect WhatsApp, Telegram, and more with: %s configure --section channels%s\n", ansiGray, bin, ansiReset) } } @@ -693,7 +780,7 @@ func ensureWebSearchPlugin() bool { return false } - fmt.Fprintf(os.Stderr, "%s ✓ Installed web search plugin%s\n", ansiGreen, ansiReset) + fmt.Fprintf(os.Stderr, "%s ✓ Installed Ollama web search %s\n", ansiGreen, ansiReset) return true } diff --git a/cmd/launch/openclaw_test.go b/cmd/launch/openclaw_test.go index 9b02720e1..80addab61 100644 --- a/cmd/launch/openclaw_test.go +++ b/cmd/launch/openclaw_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" "net/http/httptest" "net/url" @@ -64,6 +65,17 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) { t.Fatal(err) } + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return true } + defer func() { isInteractiveSession = oldInteractive }() + + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect confirmation prompt during passthrough launch: %s", prompt) + return false, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + c := &Openclaw{} if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil { t.Fatalf("Run() error = %v", err) @@ -82,6 +94,163 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) { } } +func TestOpenclawRun_ChannelSetupHappensBeforeGatewayRestart(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(fmt.Sprintf(`{ + "wizard": {"lastRunAt": "2026-01-01T00:00:00Z"}, + "gateway": {"port": %d} + }`, port)), 0o644); err != nil { + t.Fatal(err) + } + + bin := filepath.Join(tmpDir, "openclaw") + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$HOME/invocations.log" +if [ "$1" = "channels" ] && [ "$2" = "add" ]; then + /bin/mkdir -p "$HOME/.openclaw" + /bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF' +{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":%d},"channels":{"telegram":{"botToken":"configured"}}} +EOF +fi +`, port) + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return true } + defer func() { isInteractiveSession = oldInteractive }() + + promptCount := 0 + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + promptCount++ + if prompt != "Connect a messaging app now?" { + t.Fatalf("unexpected prompt: %q", prompt) + } + return true, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + + c := &Openclaw{} + if err := c.Run("llama3.2", nil); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if promptCount != 1 { + t.Fatalf("expected one channel setup prompt, got %d", promptCount) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 3 { + t.Fatalf("expected at least 3 invocations (channels add, daemon restart, tui), got %v", lines) + } + if lines[0] != "channels add" { + t.Fatalf("expected first invocation to be channels setup, got %q", lines[0]) + } + if lines[1] != "daemon restart" { + t.Fatalf("expected second invocation to be daemon restart, got %q", lines[1]) + } + if lines[2] != "tui" { + t.Fatalf("expected third invocation to be tui, got %q", lines[2]) + } +} + +func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(fmt.Sprintf(`{ + "wizard": {"lastRunAt": "2026-01-01T00:00:00Z"}, + "gateway": {"port": %d} + }`, port)), 0o644); err != nil { + t.Fatal(err) + } + + bin := filepath.Join(tmpDir, "openclaw") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return true } + defer func() { isInteractiveSession = oldInteractive }() + + promptCount := 0 + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + promptCount++ + return false, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + + c := &Openclaw{} + if err := c.Run("llama3.2", nil); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if promptCount != 1 { + t.Fatalf("expected one channel setup prompt, got %d", promptCount) + } + data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least 2 invocations (daemon restart, tui), got %v", lines) + } + if lines[0] != "daemon restart" { + t.Fatalf("expected first invocation to be daemon restart, got %q", lines[0]) + } + if lines[1] != "tui" { + t.Fatalf("expected second invocation to be tui, got %q", lines[1]) + } + for _, line := range lines { + if line == "channels add" { + t.Fatalf("did not expect channels add invocation after choosing set up later, got %v", lines) + } + } +} + func TestOpenclawEdit(t *testing.T) { c := &Openclaw{} tmpDir := t.TempDir() @@ -930,6 +1099,390 @@ func TestOpenclawOnboarded(t *testing.T) { }) } +func TestOpenclawChannelsConfigured(t *testing.T) { + c := &Openclaw{} + + t.Run("returns false when no config exists", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + if c.channelsConfigured() { + t.Error("expected false when no config exists") + } + }) + + t.Run("returns false for corrupted json", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{bad`), 0o644); err != nil { + t.Fatal(err) + } + + if c.channelsConfigured() { + t.Error("expected false for corrupted config") + } + }) + + t.Run("returns false when channels section is missing", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{"theme":"dark"}`), 0o644); err != nil { + t.Fatal(err) + } + + if c.channelsConfigured() { + t.Error("expected false when channels section is missing") + } + }) + + t.Run("returns false for channels defaults and modelByChannel only", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{ + "channels": { + "defaults": {"dmPolicy": "pairing"}, + "modelByChannel": {"telegram": "ollama/llama3.2"} + } + }`), 0o644); err != nil { + t.Fatal(err) + } + + if c.channelsConfigured() { + t.Error("expected false for channels metadata only") + } + }) + + t.Run("returns false when channel entry only has enabled", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{ + "channels": { + "telegram": {"enabled": true} + } + }`), 0o644); err != nil { + t.Fatal(err) + } + + if c.channelsConfigured() { + t.Error("expected false when channel config only has enabled") + } + }) + + t.Run("returns true when a channel has meaningful configuration", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{ + "channels": { + "telegram": {"botToken": "secret"} + } + }`), 0o644); err != nil { + t.Fatal(err) + } + + if !c.channelsConfigured() { + t.Error("expected true when channel has meaningful config") + } + }) + + t.Run("prefers new path over legacy", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + newDir := filepath.Join(tmpDir, ".openclaw") + legacyDir := filepath.Join(tmpDir, ".clawdbot") + if err := os.MkdirAll(newDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(legacyDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(newDir, "openclaw.json"), []byte(`{"channels":{"telegram":{"enabled":true}}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"channels":{"telegram":{"botToken":"configured"}}}`), 0o644); err != nil { + t.Fatal(err) + } + + if c.channelsConfigured() { + t.Error("expected false because new config should take precedence") + } + }) +} + +func TestOpenclawChannelSetupPreflight(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + c := &Openclaw{} + + t.Run("skips in non-interactive sessions", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(tmpDir, "openclaw") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return false } + defer func() { isInteractiveSession = oldInteractive }() + + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect prompt in non-interactive mode: %s", prompt) + return false, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + + if err := c.runChannelSetupPreflight("openclaw"); err != nil { + t.Fatalf("runChannelSetupPreflight() error = %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) { + t.Fatalf("expected no command invocation in non-interactive mode, got err=%v", err) + } + }) + + t.Run("already configured does not prompt or run channels add", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{"channels":{"telegram":{"botToken":"set"}}}`), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(tmpDir, "openclaw") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return true } + defer func() { isInteractiveSession = oldInteractive }() + + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect prompt when already configured: %s", prompt) + return false, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + + if err := c.runChannelSetupPreflight("openclaw"); err != nil { + t.Fatalf("runChannelSetupPreflight() error = %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) { + t.Fatalf("expected no channels add invocation, got err=%v", err) + } + }) + + t.Run("set up later prompts once and exits", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(tmpDir, "openclaw") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return true } + defer func() { isInteractiveSession = oldInteractive }() + + promptCount := 0 + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + promptCount++ + return false, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + + if err := c.runChannelSetupPreflight("openclaw"); err != nil { + t.Fatalf("runChannelSetupPreflight() error = %v", err) + } + if promptCount != 1 { + t.Fatalf("expected 1 prompt, got %d", promptCount) + } + if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) { + t.Fatalf("expected no channels add invocation, got err=%v", err) + } + }) + + t.Run("yes runs channels add and exits after configuration", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(tmpDir, "openclaw") + script := `#!/bin/sh +printf '%s\n' "$*" >> "$HOME/invocations.log" +if [ "$1" = "channels" ] && [ "$2" = "add" ]; then + /bin/mkdir -p "$HOME/.openclaw" + /bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF' +{"channels":{"telegram":{"botToken":"configured"}}} +EOF +fi +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return true } + defer func() { isInteractiveSession = oldInteractive }() + + promptCount := 0 + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + promptCount++ + return true, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + + if err := c.runChannelSetupPreflight("openclaw"); err != nil { + t.Fatalf("runChannelSetupPreflight() error = %v", err) + } + if promptCount != 1 { + t.Fatalf("expected 1 prompt, got %d", promptCount) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 || lines[0] != "channels add" { + t.Fatalf("expected one 'channels add' invocation, got %v", lines) + } + }) + + t.Run("re-prompts when channels add does not configure anything", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(tmpDir, "openclaw") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return true } + defer func() { isInteractiveSession = oldInteractive }() + + promptCount := 0 + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + promptCount++ + return promptCount == 1, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + + if err := c.runChannelSetupPreflight("openclaw"); err != nil { + t.Fatalf("runChannelSetupPreflight() error = %v", err) + } + if promptCount != 2 { + t.Fatalf("expected 2 prompts, got %d", promptCount) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 || lines[0] != "channels add" { + t.Fatalf("expected one 'channels add' invocation, got %v", lines) + } + }) + + t.Run("returns actionable error when channels add fails", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(tmpDir, "openclaw") + script := `#!/bin/sh +if [ "$1" = "channels" ] && [ "$2" = "add" ]; then + exit 42 +fi +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + oldInteractive := isInteractiveSession + isInteractiveSession = func() bool { return true } + defer func() { isInteractiveSession = oldInteractive }() + + oldConfirmPrompt := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + defer func() { DefaultConfirmPrompt = oldConfirmPrompt }() + + err := c.runChannelSetupPreflight("openclaw") + if err == nil { + t.Fatal("expected error when channels add fails") + } + if !strings.Contains(err.Error(), "Try running: openclaw channels add") { + t.Fatalf("expected actionable remediation hint, got: %v", err) + } + }) +} + func TestOpenclawGatewayInfo(t *testing.T) { c := &Openclaw{} @@ -1253,14 +1806,17 @@ func TestPrintOpenclawReady(t *testing.T) { buf.ReadFrom(r) output := buf.String() - for _, want := range []string{"/help", "channels", "skills", "gateway"} { + for _, want := range []string{"/help", "skills", "gateway"} { if !strings.Contains(output, want) { t.Errorf("expected %q in first-launch output, got:\n%s", want, output) } } + if strings.Contains(output, "configure --section channels") { + t.Errorf("did not expect channels configure tip in first-launch output, got:\n%s", output) + } }) - t.Run("subsequent launch shows single tip", func(t *testing.T) { + t.Run("subsequent launch omits quick start tips", func(t *testing.T) { var buf bytes.Buffer old := os.Stderr r, w, _ := os.Pipe() @@ -1273,12 +1829,15 @@ func TestPrintOpenclawReady(t *testing.T) { buf.ReadFrom(r) output := buf.String() - if !strings.Contains(output, "Tip:") { - t.Errorf("expected single tip line, got:\n%s", output) - } if strings.Contains(output, "Quick start") { t.Errorf("should not show quick start on subsequent launch") } + if strings.Contains(output, "browse skills with") { + t.Errorf("should not show repeated skills tip on subsequent launch") + } + if strings.Contains(output, "configure --section channels") { + t.Errorf("did not expect channels configure tip on subsequent launch, got:\n%s", output) + } }) } diff --git a/cmd/launch/pi_test.go b/cmd/launch/pi_test.go index 4e6241031..46dc069ee 100644 --- a/cmd/launch/pi_test.go +++ b/cmd/launch/pi_test.go @@ -80,7 +80,9 @@ exit 0 withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) { t.Helper() oldConfirm := DefaultConfirmPrompt - DefaultConfirmPrompt = fn + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return fn(prompt) + } t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm }) } diff --git a/cmd/launch/selector_hooks.go b/cmd/launch/selector_hooks.go index 8710bb4f8..0f55aadea 100644 --- a/cmd/launch/selector_hooks.go +++ b/cmd/launch/selector_hooks.go @@ -25,7 +25,13 @@ var errCancelled = ErrCancelled // DefaultConfirmPrompt provides a TUI-based confirmation prompt. // When set, ConfirmPrompt delegates to it instead of using raw terminal I/O. -var DefaultConfirmPrompt func(prompt string) (bool, error) +var DefaultConfirmPrompt func(prompt string, options ConfirmOptions) (bool, error) + +// ConfirmOptions customizes labels for confirmation prompts. +type ConfirmOptions struct { + YesLabel string + NoLabel string +} // SingleSelector is a function type for single item selection. // current is the name of the previously selected item to highlight; empty means no pre-selection. @@ -65,6 +71,12 @@ func withLaunchConfirmPolicy(policy launchConfirmPolicy) func() { // Behavior is controlled by currentLaunchConfirmPolicy, typically scoped by // withLaunchConfirmPolicy in LaunchCmd (e.g. auto-approve with --yes). func ConfirmPrompt(prompt string) (bool, error) { + return ConfirmPromptWithOptions(prompt, ConfirmOptions{}) +} + +// ConfirmPromptWithOptions is the shared confirmation gate for launch flows +// that need custom yes/no labels in interactive UIs. +func ConfirmPromptWithOptions(prompt string, options ConfirmOptions) (bool, error) { if currentLaunchConfirmPolicy.yes { return true, nil } @@ -73,7 +85,7 @@ func ConfirmPrompt(prompt string) (bool, error) { } if DefaultConfirmPrompt != nil { - return DefaultConfirmPrompt(prompt) + return DefaultConfirmPrompt(prompt, options) } fd := int(os.Stdin.Fd()) diff --git a/cmd/launch/selector_test.go b/cmd/launch/selector_test.go index fa6a635b0..a5d6fb100 100644 --- a/cmd/launch/selector_test.go +++ b/cmd/launch/selector_test.go @@ -29,7 +29,7 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) { currentLaunchConfirmPolicy = launchConfirmPolicy{} var hookCalls int - DefaultConfirmPrompt = func(prompt string) (bool, error) { + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { hookCalls++ return true, nil } @@ -74,3 +74,39 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) { t.Fatalf("expected one hook call after restore, got %d", hookCalls) } } + +func TestConfirmPromptWithOptions_DelegatesToOptionsHook(t *testing.T) { + oldPolicy := currentLaunchConfirmPolicy + oldHook := DefaultConfirmPrompt + t.Cleanup(func() { + currentLaunchConfirmPolicy = oldPolicy + DefaultConfirmPrompt = oldHook + }) + + currentLaunchConfirmPolicy = launchConfirmPolicy{} + called := false + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + called = true + if prompt != "Connect now?" { + t.Fatalf("unexpected prompt: %q", prompt) + } + if options.YesLabel != "Yes" || options.NoLabel != "Set up later" { + t.Fatalf("unexpected options: %+v", options) + } + return true, nil + } + + ok, err := ConfirmPromptWithOptions("Connect now?", ConfirmOptions{ + YesLabel: "Yes", + NoLabel: "Set up later", + }) + if err != nil { + t.Fatalf("ConfirmPromptWithOptions() error = %v", err) + } + if !ok { + t.Fatal("expected confirm to return true") + } + if !called { + t.Fatal("expected options hook to be called") + } +} diff --git a/cmd/tui/confirm.go b/cmd/tui/confirm.go index b8f92b124..d8122a797 100644 --- a/cmd/tui/confirm.go +++ b/cmd/tui/confirm.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/ollama/ollama/cmd/launch" ) var ( @@ -18,12 +19,16 @@ var ( type confirmModel struct { prompt string + yesLabel string + noLabel string yes bool confirmed bool cancelled bool width int } +type ConfirmOptions = launch.ConfirmOptions + func (m confirmModel) Init() tea.Cmd { return nil } @@ -40,22 +45,16 @@ func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { - case "ctrl+c", "esc", "n": + case "ctrl+c", "esc": m.cancelled = true return m, tea.Quit - case "y": - m.yes = true - m.confirmed = true - return m, tea.Quit case "enter": m.confirmed = true return m, tea.Quit - case "left", "h": + case "left": m.yes = true - case "right", "l": + case "right": m.yes = false - case "tab": - m.yes = !m.yes } } @@ -68,12 +67,20 @@ func (m confirmModel) View() string { } var yesBtn, noBtn string + yesLabel := m.yesLabel + if yesLabel == "" { + yesLabel = "Yes" + } + noLabel := m.noLabel + if noLabel == "" { + noLabel = "No" + } if m.yes { - yesBtn = confirmActiveStyle.Render(" Yes ") - noBtn = confirmInactiveStyle.Render(" No ") + yesBtn = confirmActiveStyle.Render(" " + yesLabel + " ") + noBtn = confirmInactiveStyle.Render(" " + noLabel + " ") } else { - yesBtn = confirmInactiveStyle.Render(" Yes ") - noBtn = confirmActiveStyle.Render(" No ") + yesBtn = confirmInactiveStyle.Render(" " + yesLabel + " ") + noBtn = confirmActiveStyle.Render(" " + noLabel + " ") } s := selectorTitleStyle.Render(m.prompt) + "\n\n" @@ -89,9 +96,26 @@ func (m confirmModel) View() string { // RunConfirm shows a bubbletea yes/no confirmation prompt. // Returns true if the user confirmed, false if cancelled. func RunConfirm(prompt string) (bool, error) { + return RunConfirmWithOptions(prompt, ConfirmOptions{}) +} + +// RunConfirmWithOptions shows a bubbletea yes/no confirmation prompt with +// optional custom button labels. +func RunConfirmWithOptions(prompt string, options ConfirmOptions) (bool, error) { + yesLabel := options.YesLabel + if yesLabel == "" { + yesLabel = "Yes" + } + noLabel := options.NoLabel + if noLabel == "" { + noLabel = "No" + } + m := confirmModel{ - prompt: prompt, - yes: true, // default to yes + prompt: prompt, + yesLabel: yesLabel, + noLabel: noLabel, + yes: true, // default to yes } p := tea.NewProgram(m) diff --git a/cmd/tui/confirm_test.go b/cmd/tui/confirm_test.go index 4279d18eb..0e8940bd7 100644 --- a/cmd/tui/confirm_test.go +++ b/cmd/tui/confirm_test.go @@ -33,6 +33,22 @@ func TestConfirmModel_View_ContainsButtons(t *testing.T) { } } +func TestConfirmModel_View_ContainsCustomButtons(t *testing.T) { + m := confirmModel{ + prompt: "Connect a messaging app now?", + yesLabel: "Yes", + noLabel: "Set up later", + yes: true, + } + got := m.View() + if !strings.Contains(got, "Yes") { + t.Error("should contain custom yes button") + } + if !strings.Contains(got, "Set up later") { + t.Error("should contain custom no button") + } +} + func TestConfirmModel_View_ContainsHelp(t *testing.T) { m := confirmModel{prompt: "Download?", yes: true} got := m.View() @@ -109,30 +125,33 @@ func TestConfirmModel_CtrlCCancels(t *testing.T) { } } -func TestConfirmModel_NCancels(t *testing.T) { +func TestConfirmModel_NDoesNothing(t *testing.T) { m := confirmModel{prompt: "Download?", yes: true} updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) fm := updated.(confirmModel) - if !fm.cancelled { - t.Error("'n' should set cancelled=true") + if fm.cancelled { + t.Error("'n' should not cancel") } - if cmd == nil { - t.Error("'n' should return tea.Quit") + if fm.confirmed { + t.Error("'n' should not confirm") + } + if cmd != nil { + t.Error("'n' should not quit") } } -func TestConfirmModel_YConfirmsYes(t *testing.T) { +func TestConfirmModel_YDoesNothing(t *testing.T) { m := confirmModel{prompt: "Download?", yes: false} updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) fm := updated.(confirmModel) - if !fm.confirmed { - t.Error("'y' should set confirmed=true") + if fm.confirmed { + t.Error("'y' should not confirm") } - if !fm.yes { - t.Error("'y' should set yes=true") + if fm.yes { + t.Error("'y' should not change selection") } - if cmd == nil { - t.Error("'y' should return tea.Quit") + if cmd != nil { + t.Error("'y' should not quit") } } @@ -140,36 +159,33 @@ func TestConfirmModel_ArrowKeysNavigate(t *testing.T) { m := confirmModel{prompt: "Download?", yes: true} // Right moves to No - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) fm := updated.(confirmModel) if fm.yes { - t.Error("right/l should move to No") + t.Error("right should move to No") } if fm.confirmed || fm.cancelled { t.Error("navigation should not confirm or cancel") } // Left moves back to Yes - updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyLeft}) fm = updated.(confirmModel) if !fm.yes { - t.Error("left/h should move to Yes") + t.Error("left should move to Yes") } } -func TestConfirmModel_TabToggles(t *testing.T) { +func TestConfirmModel_TabDoesNothing(t *testing.T) { m := confirmModel{prompt: "Download?", yes: true} updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) fm := updated.(confirmModel) - if fm.yes { - t.Error("tab should toggle from Yes to No") - } - - updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyTab}) - fm = updated.(confirmModel) if !fm.yes { - t.Error("tab should toggle from No to Yes") + t.Error("tab should not change selection") + } + if fm.confirmed || fm.cancelled { + t.Error("tab should not confirm or cancel") } } diff --git a/cmd/tui/selector.go b/cmd/tui/selector.go index b93d1847f..b9baa3b6d 100644 --- a/cmd/tui/selector.go +++ b/cmd/tui/selector.go @@ -1,7 +1,6 @@ package tui import ( - "errors" "fmt" "strings" @@ -56,7 +55,7 @@ var ( const maxSelectorItems = 10 // ErrCancelled is returned when the user cancels the selection. -var ErrCancelled = errors.New("cancelled") +var ErrCancelled = launch.ErrCancelled type SelectItem struct { Name string diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index e1210b215..15bd9cf4f 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -51,14 +51,14 @@ var mainMenuItems = []menuItem{ description: "Start an interactive chat with a model", isRunModel: true, }, + { + integration: "openclaw", + }, { integration: "claude", }, { - integration: "codex", - }, - { - integration: "openclaw", + integration: "opencode", }, } @@ -136,9 +136,9 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem { func otherIntegrationItems(state *launch.LauncherState) []menuItem { pinned := map[string]bool{ - "claude": true, - "codex": true, "openclaw": true, + "claude": true, + "opencode": true, } var items []menuItem diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index 699b578b6..1f7720c98 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -36,6 +36,13 @@ func launcherTestState() *launch.LauncherState { Changeable: true, AutoInstallable: true, }, + "opencode": { + Name: "opencode", + DisplayName: "OpenCode", + Description: "Anomaly's open-source coding agent", + Selectable: true, + Changeable: true, + }, "droid": { Name: "droid", DisplayName: "Droid", @@ -54,13 +61,25 @@ func launcherTestState() *launch.LauncherState { } } +func findMenuCursorByIntegration(items []menuItem, name string) int { + for i, item := range items { + if item.integration == name { + return i + } + } + return -1 +} + func TestMenuRendersPinnedItemsAndMore(t *testing.T) { view := newModel(launcherTestState()).View() - for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Codex", "Launch OpenClaw", "More..."} { + for _, want := range []string{"Chat with a model", "Launch OpenClaw", "Launch Claude Code", "Launch OpenCode", "More..."} { if !strings.Contains(view, want) { t.Fatalf("expected menu view to contain %q\n%s", want, view) } } + if strings.Contains(view, "Launch Codex") { + t.Fatalf("expected Codex to be under More, not pinned\n%s", view) + } } func TestMenuExpandsOthersFromLastSelection(t *testing.T) { @@ -102,7 +121,10 @@ func TestMenuRightOnRunSelectsChangeRun(t *testing.T) { func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) { menu := newModel(launcherTestState()) - menu.cursor = 1 + menu.cursor = findMenuCursorByIntegration(menu.items, "claude") + if menu.cursor == -1 { + t.Fatal("expected claude menu item") + } updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter}) got := updated.(model) want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude"} @@ -113,7 +135,10 @@ func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) { func TestMenuRightOnIntegrationSelectsConfigure(t *testing.T) { menu := newModel(launcherTestState()) - menu.cursor = 1 + menu.cursor = findMenuCursorByIntegration(menu.items, "claude") + if menu.cursor == -1 { + t.Fatal("expected claude menu item") + } updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyRight}) got := updated.(model) want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude", ForceConfigure: true} @@ -130,7 +155,10 @@ func TestMenuIgnoresDisabledActions(t *testing.T) { state.Integrations["claude"] = claude menu := newModel(state) - menu.cursor = 1 + menu.cursor = findMenuCursorByIntegration(menu.items, "claude") + if menu.cursor == -1 { + t.Fatal("expected claude menu item") + } updatedEnter, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter}) if updatedEnter.(model).selected { @@ -150,7 +178,10 @@ func TestMenuShowsCurrentModelSuffixes(t *testing.T) { t.Fatalf("expected run row to show current model suffix\n%s", runView) } - menu.cursor = 1 + menu.cursor = findMenuCursorByIntegration(menu.items, "claude") + if menu.cursor == -1 { + t.Fatal("expected claude menu item") + } integrationView := menu.View() if !strings.Contains(integrationView, "(glm-5:cloud)") { t.Fatalf("expected integration row to show current model suffix\n%s", integrationView) @@ -166,8 +197,12 @@ func TestMenuShowsInstallStatusAndHint(t *testing.T) { codex.InstallHint = "Install from https://example.com/codex" state.Integrations["codex"] = codex + state.LastSelection = "codex" menu := newModel(state) - menu.cursor = 2 + menu.cursor = findMenuCursorByIntegration(menu.items, "codex") + if menu.cursor == -1 { + t.Fatal("expected codex menu item in overflow section") + } view := menu.View() if !strings.Contains(view, "(not installed)") { t.Fatalf("expected not-installed marker\n%s", view)