mirror of
https://github.com/ollama/ollama.git
synced 2026-04-17 15:53:27 +02:00
launch: add openclaw channels setup (#15407)
This commit is contained in:
@@ -92,13 +92,7 @@ func init() {
|
|||||||
return userName, err
|
return userName, err
|
||||||
}
|
}
|
||||||
|
|
||||||
launch.DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions
|
||||||
ok, err := tui.RunConfirm(prompt)
|
|
||||||
if errors.Is(err, tui.ErrCancelled) {
|
|
||||||
return false, launch.ErrCancelled
|
|
||||||
}
|
|
||||||
return ok, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n"
|
const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n"
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) {
|
|||||||
restore := OverrideIntegration("stubeditor", stub)
|
restore := OverrideIntegration("stubeditor", stub)
|
||||||
defer restore()
|
defer restore()
|
||||||
|
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatalf("unexpected prompt with --yes: %q", prompt)
|
t.Fatalf("unexpected prompt with --yes: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -393,7 +393,7 @@ func TestLaunchCmdHeadlessWithYes_AutoPullsMissingLocalModel(t *testing.T) {
|
|||||||
restore := OverrideIntegration("stubapp", stub)
|
restore := OverrideIntegration("stubapp", stub)
|
||||||
defer restore()
|
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)
|
t.Fatalf("unexpected prompt with --yes in headless autopull path: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -436,7 +436,7 @@ func TestLaunchCmdHeadlessWithoutYes_ReturnsActionableConfirmError(t *testing.T)
|
|||||||
restore := OverrideIntegration("stubeditor", stub)
|
restore := OverrideIntegration("stubeditor", stub)
|
||||||
defer restore()
|
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)
|
t.Fatalf("unexpected prompt in headless non-yes mode: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -787,7 +787,7 @@ func TestShowOrPullWithPolicy_ModelExists(t *testing.T) {
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T) {
|
func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
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")
|
t.Fatal("confirm prompt should not be called with fail policy")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -826,7 +826,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) {
|
func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if !strings.Contains(prompt, "missing-model") {
|
if !strings.Contains(prompt, "missing-model") {
|
||||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
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) {
|
func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
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)
|
t.Fatalf("confirm prompt should not be called with auto-pull policy: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -900,7 +900,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *testing.T) {
|
func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
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")
|
t.Fatal("confirm prompt should not be called for explicit cloud models")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -946,7 +946,7 @@ func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *tes
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *testing.T) {
|
func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
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")
|
t.Fatal("confirm prompt should not be called for explicit cloud models")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1035,7 +1035,7 @@ func TestShowOrPull_ShowCalledWithCorrectModel(t *testing.T) {
|
|||||||
func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) {
|
func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) {
|
||||||
// Set up hook so confirmPrompt doesn't need a terminal
|
// Set up hook so confirmPrompt doesn't need a terminal
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if !strings.Contains(prompt, "missing-model") {
|
if !strings.Contains(prompt, "missing-model") {
|
||||||
t.Errorf("expected prompt to contain model name, got %q", prompt)
|
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) {
|
func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
return false, ErrCancelled
|
return false, ErrCancelled
|
||||||
}
|
}
|
||||||
defer func() { DefaultConfirmPrompt = oldHook }()
|
defer func() { DefaultConfirmPrompt = oldHook }()
|
||||||
@@ -1103,7 +1103,7 @@ func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) {
|
|||||||
func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
||||||
// Confirm prompt should NOT be called for explicit cloud models
|
// Confirm prompt should NOT be called for explicit cloud models
|
||||||
oldHook := DefaultConfirmPrompt
|
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")
|
t.Error("confirm prompt should not be called for cloud models")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1143,7 +1143,7 @@ func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
|||||||
func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
||||||
// Confirm prompt should NOT be called for explicit cloud models
|
// Confirm prompt should NOT be called for explicit cloud models
|
||||||
oldHook := DefaultConfirmPrompt
|
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")
|
t.Error("confirm prompt should not be called for cloud models")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1183,7 +1183,7 @@ func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
|||||||
func TestConfirmPrompt_DelegatesToHook(t *testing.T) {
|
func TestConfirmPrompt_DelegatesToHook(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
var hookCalled bool
|
var hookCalled bool
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
hookCalled = true
|
hookCalled = true
|
||||||
if prompt != "test prompt?" {
|
if prompt != "test prompt?" {
|
||||||
t.Errorf("expected prompt %q, got %q", "test prompt?", 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) {
|
func TestEnsureAuth_DeclinedFallbackReturnsCancelled(t *testing.T) {
|
||||||
oldConfirm := DefaultConfirmPrompt
|
oldConfirm := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||||
|
|||||||
@@ -756,7 +756,6 @@ func (c *launcherClient) loadModelInventoryOnce(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runIntegration(runner Runner, modelName string, args []string) 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)
|
return runner.Run(modelName, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var proceedPrompt bool
|
var proceedPrompt bool
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if prompt == "Proceed?" {
|
if prompt == "Proceed?" {
|
||||||
proceedPrompt = true
|
proceedPrompt = true
|
||||||
}
|
}
|
||||||
@@ -847,7 +847,7 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsMissingLocalAndPersistsAccep
|
|||||||
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
return []string{"glm-5:cloud", "missing-local"}, nil
|
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?" {
|
if prompt == "Proceed?" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -929,7 +929,7 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsUnauthedCloudAndPersistsAcce
|
|||||||
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
return []string{"llama3.2", "glm-5:cloud"}, nil
|
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?" {
|
if prompt == "Proceed?" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -1014,7 +1014,7 @@ func TestLaunchIntegration_EditorConfigureMultiRemovesReselectedFailingModel(t *
|
|||||||
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
return append([]string(nil), preChecked...), nil
|
return append([]string(nil), preChecked...), nil
|
||||||
}
|
}
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if prompt == "Proceed?" {
|
if prompt == "Proceed?" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -1101,7 +1101,7 @@ func TestLaunchIntegration_EditorConfigureMultiAllFailuresKeepsExistingAndSkipsL
|
|||||||
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
return []string{"missing-local-a", "missing-local-b"}, nil
|
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?" {
|
if prompt == "Download missing-local-a?" || prompt == "Download missing-local-b?" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1180,7 +1180,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing
|
|||||||
t.Fatalf("failed to seed config: %v", err)
|
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)
|
t.Fatalf("did not expect prompt during normal configured launch: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1245,7 +1245,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
|
|||||||
t.Fatalf("failed to seed config: %v", err)
|
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)
|
t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1410,7 +1410,7 @@ func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
var prompts []string
|
var prompts []string
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
prompts = append(prompts, prompt)
|
prompts = append(prompts, prompt)
|
||||||
if strings.Contains(prompt, "Launch LauncherEditor now?") {
|
if strings.Contains(prompt, "Launch LauncherEditor now?") {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -1569,7 +1569,7 @@ func TestLaunchIntegration_ClaudeForceConfigureMissingSelectionDoesNotSave(t *te
|
|||||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||||
return "missing-model", nil
|
return "missing-model", nil
|
||||||
}
|
}
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if prompt == "Download missing-model?" {
|
if prompt == "Download missing-model?" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1631,7 +1631,7 @@ func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var confirmCalls int
|
var confirmCalls int
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalls++
|
confirmCalls++
|
||||||
if !strings.Contains(prompt, "glm-4") {
|
if !strings.Contains(prompt, "glm-4") {
|
||||||
t.Fatalf("expected download prompt for override model, got %q", prompt)
|
t.Fatalf("expected download prompt for override model, got %q", prompt)
|
||||||
@@ -1695,7 +1695,7 @@ func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var prompts []string
|
var prompts []string
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
prompts = append(prompts, prompt)
|
prompts = append(prompts, prompt)
|
||||||
if strings.Contains(prompt, "Launch StubSingle now?") {
|
if strings.Contains(prompt, "Launch StubSingle now?") {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -1745,7 +1745,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessMissingFailsWithoutPrompt(t *tes
|
|||||||
withIntegrationOverride(t, "droid", runner)
|
withIntegrationOverride(t, "droid", runner)
|
||||||
|
|
||||||
confirmCalled := false
|
confirmCalled := false
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalled = true
|
confirmCalled = true
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -1802,7 +1802,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessCanOverrideMissingModelPolicy(t
|
|||||||
withIntegrationOverride(t, "droid", runner)
|
withIntegrationOverride(t, "droid", runner)
|
||||||
|
|
||||||
confirmCalled := false
|
confirmCalled := false
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalled = true
|
confirmCalled = true
|
||||||
if !strings.Contains(prompt, "missing-model") {
|
if !strings.Contains(prompt, "missing-model") {
|
||||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||||
@@ -1860,7 +1860,7 @@ func TestLaunchIntegration_ModelOverrideInteractiveMissingPromptsAndPulls(t *tes
|
|||||||
withIntegrationOverride(t, "droid", runner)
|
withIntegrationOverride(t, "droid", runner)
|
||||||
|
|
||||||
confirmCalled := false
|
confirmCalled := false
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalled = true
|
confirmCalled = true
|
||||||
if !strings.Contains(prompt, "missing-model") {
|
if !strings.Contains(prompt, "missing-model") {
|
||||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||||
@@ -1921,7 +1921,7 @@ func TestLaunchIntegration_HeadlessSelectorFlowFailsWithoutPrompt(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
confirmCalled := false
|
confirmCalled := false
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalled = true
|
confirmCalled = true
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,8 +102,6 @@ func (c *Openclaw) Run(model string, args []string) error {
|
|||||||
registerWebSearchPlugin()
|
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
|
// When extra args are passed through, run exactly what the user asked for
|
||||||
// after setup and skip the built-in gateway+TUI convenience flow.
|
// after setup and skip the built-in gateway+TUI convenience flow.
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -118,6 +116,15 @@ func (c *Openclaw) Run(model string, args []string) error {
|
|||||||
return nil
|
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()
|
token, port := c.gatewayInfo()
|
||||||
addr := fmt.Sprintf("localhost:%d", port)
|
addr := fmt.Sprintf("localhost:%d", port)
|
||||||
|
|
||||||
@@ -172,6 +179,89 @@ func (c *Openclaw) Run(model string, args []string) error {
|
|||||||
return nil
|
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.
|
// gatewayInfo reads the gateway auth token and port from the OpenClaw config.
|
||||||
func (c *Openclaw) gatewayInfo() (token string, port int) {
|
func (c *Openclaw) gatewayInfo() (token string, port int) {
|
||||||
port = defaultGatewayPort
|
port = defaultGatewayPort
|
||||||
@@ -218,12 +308,9 @@ func printOpenclawReady(bin, token string, port int, firstLaunch bool) {
|
|||||||
if firstLaunch {
|
if firstLaunch {
|
||||||
fmt.Fprintf(os.Stderr, "%s Quick start:%s\n", ansiBold, ansiReset)
|
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 /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 %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 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)
|
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
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -64,6 +65,17 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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{}
|
c := &Openclaw{}
|
||||||
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
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) {
|
func TestOpenclawEdit(t *testing.T) {
|
||||||
c := &Openclaw{}
|
c := &Openclaw{}
|
||||||
tmpDir := t.TempDir()
|
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) {
|
func TestOpenclawGatewayInfo(t *testing.T) {
|
||||||
c := &Openclaw{}
|
c := &Openclaw{}
|
||||||
|
|
||||||
@@ -1253,14 +1806,17 @@ func TestPrintOpenclawReady(t *testing.T) {
|
|||||||
buf.ReadFrom(r)
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
for _, want := range []string{"/help", "channels", "skills", "gateway"} {
|
for _, want := range []string{"/help", "skills", "gateway"} {
|
||||||
if !strings.Contains(output, want) {
|
if !strings.Contains(output, want) {
|
||||||
t.Errorf("expected %q in first-launch output, got:\n%s", want, output)
|
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
|
var buf bytes.Buffer
|
||||||
old := os.Stderr
|
old := os.Stderr
|
||||||
r, w, _ := os.Pipe()
|
r, w, _ := os.Pipe()
|
||||||
@@ -1273,12 +1829,15 @@ func TestPrintOpenclawReady(t *testing.T) {
|
|||||||
buf.ReadFrom(r)
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if !strings.Contains(output, "Tip:") {
|
|
||||||
t.Errorf("expected single tip line, got:\n%s", output)
|
|
||||||
}
|
|
||||||
if strings.Contains(output, "Quick start") {
|
if strings.Contains(output, "Quick start") {
|
||||||
t.Errorf("should not show quick start on subsequent launch")
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ exit 0
|
|||||||
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
|
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
oldConfirm := DefaultConfirmPrompt
|
oldConfirm := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = fn
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
return fn(prompt)
|
||||||
|
}
|
||||||
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ var errCancelled = ErrCancelled
|
|||||||
|
|
||||||
// DefaultConfirmPrompt provides a TUI-based confirmation prompt.
|
// DefaultConfirmPrompt provides a TUI-based confirmation prompt.
|
||||||
// When set, ConfirmPrompt delegates to it instead of using raw terminal I/O.
|
// 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.
|
// 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.
|
// 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
|
// Behavior is controlled by currentLaunchConfirmPolicy, typically scoped by
|
||||||
// withLaunchConfirmPolicy in LaunchCmd (e.g. auto-approve with --yes).
|
// withLaunchConfirmPolicy in LaunchCmd (e.g. auto-approve with --yes).
|
||||||
func ConfirmPrompt(prompt string) (bool, error) {
|
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 {
|
if currentLaunchConfirmPolicy.yes {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -73,7 +85,7 @@ func ConfirmPrompt(prompt string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if DefaultConfirmPrompt != nil {
|
if DefaultConfirmPrompt != nil {
|
||||||
return DefaultConfirmPrompt(prompt)
|
return DefaultConfirmPrompt(prompt, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
fd := int(os.Stdin.Fd())
|
fd := int(os.Stdin.Fd())
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) {
|
|||||||
|
|
||||||
currentLaunchConfirmPolicy = launchConfirmPolicy{}
|
currentLaunchConfirmPolicy = launchConfirmPolicy{}
|
||||||
var hookCalls int
|
var hookCalls int
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
hookCalls++
|
hookCalls++
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -74,3 +74,39 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) {
|
|||||||
t.Fatalf("expected one hook call after restore, got %d", hookCalls)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/ollama/ollama/cmd/launch"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -18,12 +19,16 @@ var (
|
|||||||
|
|
||||||
type confirmModel struct {
|
type confirmModel struct {
|
||||||
prompt string
|
prompt string
|
||||||
|
yesLabel string
|
||||||
|
noLabel string
|
||||||
yes bool
|
yes bool
|
||||||
confirmed bool
|
confirmed bool
|
||||||
cancelled bool
|
cancelled bool
|
||||||
width int
|
width int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfirmOptions = launch.ConfirmOptions
|
||||||
|
|
||||||
func (m confirmModel) Init() tea.Cmd {
|
func (m confirmModel) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -40,22 +45,16 @@ func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc", "n":
|
case "ctrl+c", "esc":
|
||||||
m.cancelled = true
|
m.cancelled = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "y":
|
|
||||||
m.yes = true
|
|
||||||
m.confirmed = true
|
|
||||||
return m, tea.Quit
|
|
||||||
case "enter":
|
case "enter":
|
||||||
m.confirmed = true
|
m.confirmed = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "left", "h":
|
case "left":
|
||||||
m.yes = true
|
m.yes = true
|
||||||
case "right", "l":
|
case "right":
|
||||||
m.yes = false
|
m.yes = false
|
||||||
case "tab":
|
|
||||||
m.yes = !m.yes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +67,20 @@ func (m confirmModel) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var yesBtn, noBtn string
|
var yesBtn, noBtn string
|
||||||
|
yesLabel := m.yesLabel
|
||||||
|
if yesLabel == "" {
|
||||||
|
yesLabel = "Yes"
|
||||||
|
}
|
||||||
|
noLabel := m.noLabel
|
||||||
|
if noLabel == "" {
|
||||||
|
noLabel = "No"
|
||||||
|
}
|
||||||
if m.yes {
|
if m.yes {
|
||||||
yesBtn = confirmActiveStyle.Render(" Yes ")
|
yesBtn = confirmActiveStyle.Render(" " + yesLabel + " ")
|
||||||
noBtn = confirmInactiveStyle.Render(" No ")
|
noBtn = confirmInactiveStyle.Render(" " + noLabel + " ")
|
||||||
} else {
|
} else {
|
||||||
yesBtn = confirmInactiveStyle.Render(" Yes ")
|
yesBtn = confirmInactiveStyle.Render(" " + yesLabel + " ")
|
||||||
noBtn = confirmActiveStyle.Render(" No ")
|
noBtn = confirmActiveStyle.Render(" " + noLabel + " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
s := selectorTitleStyle.Render(m.prompt) + "\n\n"
|
s := selectorTitleStyle.Render(m.prompt) + "\n\n"
|
||||||
@@ -89,9 +96,26 @@ func (m confirmModel) View() string {
|
|||||||
// RunConfirm shows a bubbletea yes/no confirmation prompt.
|
// RunConfirm shows a bubbletea yes/no confirmation prompt.
|
||||||
// Returns true if the user confirmed, false if cancelled.
|
// Returns true if the user confirmed, false if cancelled.
|
||||||
func RunConfirm(prompt string) (bool, error) {
|
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{
|
m := confirmModel{
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
yes: true, // default to yes
|
yesLabel: yesLabel,
|
||||||
|
noLabel: noLabel,
|
||||||
|
yes: true, // default to yes
|
||||||
}
|
}
|
||||||
|
|
||||||
p := tea.NewProgram(m)
|
p := tea.NewProgram(m)
|
||||||
|
|||||||
@@ -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) {
|
func TestConfirmModel_View_ContainsHelp(t *testing.T) {
|
||||||
m := confirmModel{prompt: "Download?", yes: true}
|
m := confirmModel{prompt: "Download?", yes: true}
|
||||||
got := m.View()
|
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}
|
m := confirmModel{prompt: "Download?", yes: true}
|
||||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
||||||
fm := updated.(confirmModel)
|
fm := updated.(confirmModel)
|
||||||
if !fm.cancelled {
|
if fm.cancelled {
|
||||||
t.Error("'n' should set cancelled=true")
|
t.Error("'n' should not cancel")
|
||||||
}
|
}
|
||||||
if cmd == nil {
|
if fm.confirmed {
|
||||||
t.Error("'n' should return tea.Quit")
|
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}
|
m := confirmModel{prompt: "Download?", yes: false}
|
||||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
||||||
fm := updated.(confirmModel)
|
fm := updated.(confirmModel)
|
||||||
if !fm.confirmed {
|
if fm.confirmed {
|
||||||
t.Error("'y' should set confirmed=true")
|
t.Error("'y' should not confirm")
|
||||||
}
|
}
|
||||||
if !fm.yes {
|
if fm.yes {
|
||||||
t.Error("'y' should set yes=true")
|
t.Error("'y' should not change selection")
|
||||||
}
|
}
|
||||||
if cmd == nil {
|
if cmd != nil {
|
||||||
t.Error("'y' should return tea.Quit")
|
t.Error("'y' should not quit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,36 +159,33 @@ func TestConfirmModel_ArrowKeysNavigate(t *testing.T) {
|
|||||||
m := confirmModel{prompt: "Download?", yes: true}
|
m := confirmModel{prompt: "Download?", yes: true}
|
||||||
|
|
||||||
// Right moves to No
|
// 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)
|
fm := updated.(confirmModel)
|
||||||
if fm.yes {
|
if fm.yes {
|
||||||
t.Error("right/l should move to No")
|
t.Error("right should move to No")
|
||||||
}
|
}
|
||||||
if fm.confirmed || fm.cancelled {
|
if fm.confirmed || fm.cancelled {
|
||||||
t.Error("navigation should not confirm or cancel")
|
t.Error("navigation should not confirm or cancel")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left moves back to Yes
|
// 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)
|
fm = updated.(confirmModel)
|
||||||
if !fm.yes {
|
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}
|
m := confirmModel{prompt: "Download?", yes: true}
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
fm := updated.(confirmModel)
|
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 {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ var (
|
|||||||
const maxSelectorItems = 10
|
const maxSelectorItems = 10
|
||||||
|
|
||||||
// ErrCancelled is returned when the user cancels the selection.
|
// ErrCancelled is returned when the user cancels the selection.
|
||||||
var ErrCancelled = errors.New("cancelled")
|
var ErrCancelled = launch.ErrCancelled
|
||||||
|
|
||||||
type SelectItem struct {
|
type SelectItem struct {
|
||||||
Name string
|
Name string
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ var mainMenuItems = []menuItem{
|
|||||||
description: "Start an interactive chat with a model",
|
description: "Start an interactive chat with a model",
|
||||||
isRunModel: true,
|
isRunModel: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
integration: "openclaw",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
integration: "claude",
|
integration: "claude",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
integration: "codex",
|
integration: "opencode",
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: "openclaw",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +136,9 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem {
|
|||||||
|
|
||||||
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||||
pinned := map[string]bool{
|
pinned := map[string]bool{
|
||||||
"claude": true,
|
|
||||||
"codex": true,
|
|
||||||
"openclaw": true,
|
"openclaw": true,
|
||||||
|
"claude": true,
|
||||||
|
"opencode": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []menuItem
|
var items []menuItem
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ func launcherTestState() *launch.LauncherState {
|
|||||||
Changeable: true,
|
Changeable: true,
|
||||||
AutoInstallable: true,
|
AutoInstallable: true,
|
||||||
},
|
},
|
||||||
|
"opencode": {
|
||||||
|
Name: "opencode",
|
||||||
|
DisplayName: "OpenCode",
|
||||||
|
Description: "Anomaly's open-source coding agent",
|
||||||
|
Selectable: true,
|
||||||
|
Changeable: true,
|
||||||
|
},
|
||||||
"droid": {
|
"droid": {
|
||||||
Name: "droid",
|
Name: "droid",
|
||||||
DisplayName: "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) {
|
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||||
view := newModel(launcherTestState()).View()
|
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) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
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) {
|
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
|
||||||
@@ -102,7 +121,10 @@ func TestMenuRightOnRunSelectsChangeRun(t *testing.T) {
|
|||||||
|
|
||||||
func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
||||||
menu := newModel(launcherTestState())
|
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})
|
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
got := updated.(model)
|
got := updated.(model)
|
||||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude"}
|
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude"}
|
||||||
@@ -113,7 +135,10 @@ func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
|||||||
|
|
||||||
func TestMenuRightOnIntegrationSelectsConfigure(t *testing.T) {
|
func TestMenuRightOnIntegrationSelectsConfigure(t *testing.T) {
|
||||||
menu := newModel(launcherTestState())
|
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})
|
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
got := updated.(model)
|
got := updated.(model)
|
||||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude", ForceConfigure: true}
|
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude", ForceConfigure: true}
|
||||||
@@ -130,7 +155,10 @@ func TestMenuIgnoresDisabledActions(t *testing.T) {
|
|||||||
state.Integrations["claude"] = claude
|
state.Integrations["claude"] = claude
|
||||||
|
|
||||||
menu := newModel(state)
|
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})
|
updatedEnter, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
if updatedEnter.(model).selected {
|
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)
|
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()
|
integrationView := menu.View()
|
||||||
if !strings.Contains(integrationView, "(glm-5:cloud)") {
|
if !strings.Contains(integrationView, "(glm-5:cloud)") {
|
||||||
t.Fatalf("expected integration row to show current model suffix\n%s", integrationView)
|
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"
|
codex.InstallHint = "Install from https://example.com/codex"
|
||||||
state.Integrations["codex"] = codex
|
state.Integrations["codex"] = codex
|
||||||
|
|
||||||
|
state.LastSelection = "codex"
|
||||||
menu := newModel(state)
|
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()
|
view := menu.View()
|
||||||
if !strings.Contains(view, "(not installed)") {
|
if !strings.Contains(view, "(not installed)") {
|
||||||
t.Fatalf("expected not-installed marker\n%s", view)
|
t.Fatalf("expected not-installed marker\n%s", view)
|
||||||
|
|||||||
Reference in New Issue
Block a user