From f1a0ffd6219b5ef82aee77254f895b383efb5486 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Mon, 13 Jul 2026 14:34:17 -0700 Subject: [PATCH] launch: rename Codex App integration to ChatGPT (#17161) --- app/ui/app/src/components/LaunchCommands.tsx | 8 +- cmd/cmd.go | 4 +- cmd/cmd_launcher_test.go | 2 +- cmd/launch/codex_app.go | 164 ++++++++++++++----- cmd/launch/codex_app_test.go | 155 +++++++++++++++++- cmd/launch/hermes_test.go | 1 + cmd/launch/integrations_test.go | 45 +++-- cmd/launch/launch.go | 6 +- cmd/launch/registry.go | 10 +- cmd/launch/spinner.go | 109 ++++++++++++ cmd/tui/spinner.go | 110 +++++++++++++ cmd/tui/tui_test.go | 10 +- 12 files changed, 553 insertions(+), 71 deletions(-) create mode 100644 cmd/launch/spinner.go create mode 100644 cmd/tui/spinner.go diff --git a/app/ui/app/src/components/LaunchCommands.tsx b/app/ui/app/src/components/LaunchCommands.tsx index 29afd209..dcf2ab93 100644 --- a/app/ui/app/src/components/LaunchCommands.tsx +++ b/app/ui/app/src/components/LaunchCommands.tsx @@ -22,10 +22,10 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [ iconClassName: "h-7 w-7", }, { - id: "codex-app", - name: "Codex App", - command: "ollama launch codex-app", - description: "An AI agent you can delegate real work to, by OpenAI", + id: "chatgpt", + name: "ChatGPT", + command: "ollama launch chatgpt", + description: "Complete work with ChatGPT", icon: "/launch-icons/codex-app.png", iconClassName: "h-full w-full", }, diff --git a/cmd/cmd.go b/cmd/cmd.go index cc1689bb..d30eb1a5 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -95,6 +95,8 @@ func init() { } launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions + + launch.DefaultSpinner = tui.RunSpinner } func runTUISingleSelector(title string, items []launch.SelectionItem, current string, updates <-chan []launch.SelectionItem) (string, error) { @@ -2273,7 +2275,7 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe func launcherActionExitsLoop(integration string) bool { switch integration { - case "codex-app", "vscode": + case "chatgpt", "codex-app", "vscode": return true default: return false diff --git a/cmd/cmd_launcher_test.go b/cmd/cmd_launcher_test.go index 3a766f4a..6762e2fd 100644 --- a/cmd/cmd_launcher_test.go +++ b/cmd/cmd_launcher_test.go @@ -249,7 +249,7 @@ func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) { cmd := &cobra.Command{} cmd.SetContext(context.Background()) - for _, integration := range []string{"codex-app", "vscode"} { + for _, integration := range []string{"chatgpt", "vscode"} { continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{ resolveRunModel: unexpectedRunModelResolution(t), launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go index 6c3c5093..1e15f02a 100644 --- a/cmd/launch/codex_app.go +++ b/cmd/launch/codex_app.go @@ -2,6 +2,7 @@ package launch import ( "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -16,13 +17,14 @@ import ( ) const ( + chatGPTIntegrationName = "chatgpt" codexAppIntegrationName = "codex-app" codexAppProfileName = "ollama-launch-codex-app" codexAppBundleID = "com.openai.codex" codexAppModelCatalogFilename = "ollama-launch-models.json" - codexAppRestoreHint = "To restore your usual Codex profile, run: ollama launch codex-app --restore" - codexAppConfigurationSuccess = "Codex App profile changed to Ollama." - codexAppRestoreSuccess = "Codex App restored to your usual profile." + codexAppRestoreHint = "To restore your usual ChatGPT profile, run: ollama launch chatgpt --restore" + codexAppConfigurationSuccess = "ChatGPT profile changed to Ollama." + codexAppRestoreSuccess = "ChatGPT restored to your usual profile." ) var ( @@ -49,7 +51,7 @@ var ( // model while leaving model discovery and switching to Codex's Ollama provider. type CodexApp struct{} -func (c *CodexApp) String() string { return "Codex App" } +func (c *CodexApp) String() string { return "ChatGPT" } func (c *CodexApp) Supported() error { return codexAppSupported() } @@ -68,7 +70,7 @@ func (c *CodexApp) Configure(model string) error { func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) error { primary = strings.TrimSpace(primary) if primary == "" { - return fmt.Errorf("codex-app requires a model") + return fmt.Errorf("chatgpt requires a model") } configPath, err := codexConfigPath() @@ -250,10 +252,10 @@ func writeCodexAppConfig(configPath, model, modelCatalogPath string) error { func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPath, baseURL string) error { if got, ok := config.RootStringOK(codexRootProfileKey); ok { - return fmt.Errorf("generated Codex App config still contains legacy profile = %q", got) + return fmt.Errorf("generated ChatGPT config still contains legacy profile = %q", got) } if config.Exists("profiles", codexAppProfileName) { - return fmt.Errorf("generated Codex App config still contains legacy profiles.%s table", codexAppProfileName) + return fmt.Errorf("generated ChatGPT config still contains legacy profiles.%s table", codexAppProfileName) } for _, check := range []struct { path []string @@ -267,14 +269,14 @@ func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPat {[]string{"model_providers", codexAppProfileName, "wire_api"}, "responses"}, } { if got, ok := config.String(check.path...); !ok || got != check.want { - return fmt.Errorf("generated Codex App config missing %s = %q", strings.Join(check.path, "."), check.want) + return fmt.Errorf("generated ChatGPT config missing %s = %q", strings.Join(check.path, "."), check.want) } } return nil } func (c *CodexApp) Onboard() error { - return config.MarkIntegrationOnboarded(codexAppIntegrationName) + return config.MarkIntegrationOnboarded(chatGPTIntegrationName) } func (c *CodexApp) RequiresInteractiveOnboarding() bool { @@ -298,9 +300,9 @@ func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error { return err } if len(args) > 0 { - return fmt.Errorf("codex-app does not accept extra arguments") + return fmt.Errorf("chatgpt does not accept extra arguments") } - return codexAppLaunchOrRestart("Restart Codex to use Ollama?", nil) + return codexAppLaunchOrRestart("Restart ChatGPT to use Ollama?", nil) } func (c *CodexApp) Restore() error { @@ -324,7 +326,7 @@ func (c *CodexApp) Restore() error { if err := codexAppRemoveOwnedCatalog(); err != nil { return codexAppRestoreFailure(configPath, err) } - return codexAppLaunchOrRestart("Restart Codex to use your usual profile?", nil) + return codexAppLaunchOrRestart("Restart ChatGPT to use your usual profile?", nil) } return codexAppRestoreFailure(configPath, err) } @@ -360,11 +362,11 @@ func (c *CodexApp) Restore() error { if err := removeCodexAppRestoreState(); err != nil { return codexAppRestoreFailure(configPath, err) } - return codexAppLaunchOrRestart("Restart Codex to use your usual profile?", nil) + return codexAppLaunchOrRestart("Restart ChatGPT to use your usual profile?", nil) } func codexAppRestoreFailure(configPath string, err error) error { - return fmt.Errorf("restore Codex App config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n Restore state: %s\n Model catalog: %s\n Backups: %s", + return fmt.Errorf("restore ChatGPT config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n Restore state: %s\n Model catalog: %s\n Backups: %s", err, configPath, codexAppRestoreStatePath(), @@ -378,7 +380,7 @@ func codexAppSupported() error { case "darwin", "windows": return nil default: - return fmt.Errorf("Codex App launch is only supported on macOS and Windows") + return fmt.Errorf("ChatGPT launch is only supported on macOS and Windows") } } @@ -422,7 +424,7 @@ func codexAppModelCatalogPathForConfig(configPath string) string { func writeCodexAppModelCatalog(path, primary string, models []LaunchModel) error { if len(models) == 0 { - return fmt.Errorf("codex-app model catalog cannot be empty") + return fmt.Errorf("chatgpt model catalog cannot be empty") } baseInstructions := codexAppBaseInstructions() @@ -585,9 +587,12 @@ func codexAppAppPath() string { } func codexAppDarwinAppCandidates() []string { - candidates := []string{"/Applications/Codex.app"} + candidates := []string{"/Applications/ChatGPT.app", "/Applications/Codex.app"} if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, "Applications", "Codex.app")) + candidates = append(candidates, + filepath.Join(home, "Applications", "ChatGPT.app"), + filepath.Join(home, "Applications", "Codex.app"), + ) } return candidates } @@ -599,6 +604,11 @@ func codexAppWindowsAppCandidates() []string { } candidates := []string{ + filepath.Join(local, "Programs", "ChatGPT", "ChatGPT.exe"), + filepath.Join(local, "Programs", "OpenAI ChatGPT", "ChatGPT.exe"), + filepath.Join(local, "ChatGPT", "ChatGPT.exe"), + filepath.Join(local, "OpenAI ChatGPT", "ChatGPT.exe"), + filepath.Join(local, "OpenAI", "ChatGPT", "ChatGPT.exe"), filepath.Join(local, "Programs", "Codex", "Codex.exe"), filepath.Join(local, "Programs", "OpenAI Codex", "Codex.exe"), filepath.Join(local, "Codex", "Codex.exe"), @@ -607,6 +617,11 @@ func codexAppWindowsAppCandidates() []string { filepath.Join(local, "openai-codex-electron", "Codex.exe"), } for _, pattern := range []string{ + filepath.Join(local, "Programs", "ChatGPT", "app-*", "ChatGPT.exe"), + filepath.Join(local, "Programs", "OpenAI ChatGPT", "app-*", "ChatGPT.exe"), + filepath.Join(local, "ChatGPT", "app-*", "ChatGPT.exe"), + filepath.Join(local, "OpenAI ChatGPT", "app-*", "ChatGPT.exe"), + filepath.Join(local, "OpenAI", "ChatGPT", "app-*", "ChatGPT.exe"), filepath.Join(local, "Programs", "Codex", "app-*", "Codex.exe"), filepath.Join(local, "Programs", "OpenAI Codex", "app-*", "Codex.exe"), filepath.Join(local, "Codex", "app-*", "Codex.exe"), @@ -669,22 +684,54 @@ func codexAppLaunchOrRestart(prompt string, launchArgs []string) error { return err } if !restart { - fmt.Fprintln(os.Stderr, "\nQuit and reopen Codex when you're ready for the profile change to take effect.") + fmt.Fprintln(os.Stderr, "\nQuit and reopen ChatGPT when you're ready for the profile change to take effect.") return nil } - if err := codexAppQuitApp(); err != nil { - return fmt.Errorf("quit Codex: %w", err) + // A single spinner and cancellation channel span the entire restart flow + // (quit, wait, force-quit, wait, reopen) so that one Ctrl+C aborts the + // whole sequence rather than just the currently-active wait. The bubbletea + // spinner closes Cancelled() from its raw-mode Ctrl+C handler; the ANSI + // fallback relies on SIGINT terminating the process directly. + sp := StartSpinner(codexAppRestartMessage) + defer sp.Stop() + cancelled := sp.Cancelled() + isCancelled := func() bool { + if cancelled == nil { + return false + } + select { + case <-cancelled: + return true + default: + return false + } + } + + if err := codexAppQuitApp(); err != nil { + return fmt.Errorf("quit ChatGPT: %w", err) + } + if isCancelled() { + return ErrCancelled + } + gracefulErr := waitForCodexAppGracefulExit(codexAppExitTimeout, cancelled) + if isCancelled() { + return ErrCancelled + } + if errors.Is(gracefulErr, ErrCancelled) { + return gracefulErr } - gracefulErr := waitForCodexAppGracefulExit(codexAppExitTimeout) if gracefulErr != nil && !codexAppForceQuitSupported() { return gracefulErr } if codexAppForceQuitSupported() && codexAppIsRunning() { - if forceErr := codexAppForceQuit(); forceErr != nil { - return fmt.Errorf("force stop Codex: %w", forceErr) + if isCancelled() { + return ErrCancelled } - if err := waitForCodexAppExit(codexAppForceExitTimeout); err != nil { + if forceErr := codexAppForceQuit(); forceErr != nil { + return fmt.Errorf("force stop ChatGPT: %w", forceErr) + } + if err := waitForCodexAppExit(codexAppForceExitTimeout, cancelled); err != nil { return err } } else if gracefulErr != nil { @@ -692,6 +739,10 @@ func codexAppLaunchOrRestart(prompt string, launchArgs []string) error { return gracefulErr } } + if isCancelled() { + return ErrCancelled + } + sp.Stop() if restartAppID != "" { return codexAppOpenStart(restartAppID) } @@ -705,8 +756,8 @@ func codexAppForceQuitSupported() bool { return codexAppGOOS == "darwin" || codexAppGOOS == "windows" } -func waitForCodexAppGracefulExit(timeout time.Duration) error { - return waitForCodexAppCondition(timeout, func() bool { +func waitForCodexAppGracefulExit(timeout time.Duration, cancel <-chan struct{}) error { + return waitForCodexAppCondition(timeout, cancel, func() bool { if codexAppGOOS == "windows" { return !codexAppHasWindow() } @@ -714,21 +765,41 @@ func waitForCodexAppGracefulExit(timeout time.Duration) error { }) } -func waitForCodexAppExit(timeout time.Duration) error { - return waitForCodexAppCondition(timeout, func() bool { +func waitForCodexAppExit(timeout time.Duration, cancel <-chan struct{}) error { + return waitForCodexAppCondition(timeout, cancel, func() bool { return !codexAppIsRunning() }) } -func waitForCodexAppCondition(timeout time.Duration, done func() bool) error { +// codexAppRestartMessage is the label shown next to the animated spinner while +// the ChatGPT desktop app is quitting before being reopened. +const codexAppRestartMessage = "Restarting ChatGPT..." + +// waitForCodexAppCondition polls done at a 200ms cadence until it reports the +// app has exited or timeout elapses. It watches cancel (closed by the spinner +// when the user hits Ctrl+C) and returns ErrCancelled if the flow is aborted. +// The spinner itself is owned by the caller so a single spinner spans the +// whole restart sequence. When timeout is zero the loop never runs, so +// force-quit paths that short-circuit the graceful wait return immediately. +func waitForCodexAppCondition(timeout time.Duration, cancel <-chan struct{}, done func() bool) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { + if cancel != nil { + select { + case <-cancel: + return ErrCancelled + default: + } + } if done() { return nil } codexAppSleep(200 * time.Millisecond) } - return fmt.Errorf("Codex did not quit; quit it manually and re-run the command") + if done() { + return nil + } + return fmt.Errorf("ChatGPT did not quit; quit it manually and re-run the command") } func defaultCodexAppOpenApp(args []string) error { @@ -751,7 +822,7 @@ func defaultCodexAppOpenApp(args []string) error { if appID := codexAppStartID(); appID != "" { return codexAppOpenStart(appID) } - return fmt.Errorf("Codex executable was not found; open Codex manually once and re-run 'ollama launch codex-app'") + return fmt.Errorf("ChatGPT was not found; install it from https://chatgpt.com/download, then re-run 'ollama launch chatgpt'") case "darwin": if path := codexAppAppPath(); path != "" { cmd := exec.Command("open", path) @@ -788,14 +859,17 @@ func defaultCodexAppOpenStartAppID(appID string) error { func defaultCodexAppQuitApp() error { if codexAppGOOS == "windows" { - script := `Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }` + script := `Get-Process ChatGPT,Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }` return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run() } - scriptErr := exec.Command("osascript", "-e", `tell application "Codex" to quit`).Run() + scriptErr := exec.Command("osascript", "-e", `tell application "ChatGPT" to quit`).Run() if scriptErr != nil { scriptErr = exec.Command("osascript", "-e", `tell application id "`+codexAppBundleID+`" to quit`).Run() } + if scriptErr != nil { + scriptErr = exec.Command("osascript", "-e", `tell application "Codex" to quit`).Run() + } return scriptErr } @@ -834,7 +908,7 @@ func defaultCodexAppHasOpenWindow() bool { if codexAppGOOS != "windows" { return codexAppIsRunning() } - script := `(Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id` + script := `(Get-Process ChatGPT,Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id` out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() return err == nil && strings.TrimSpace(string(out)) != "" } @@ -844,7 +918,11 @@ func defaultCodexAppIsRunning() bool { case "windows": return len(codexAppMatchingProcessIDs()) > 0 case "darwin": - out, err := exec.Command("osascript", "-e", `tell application "System Events" to exists process "Codex"`).Output() + out, err := exec.Command("osascript", "-e", `tell application "System Events" to exists process "ChatGPT"`).Output() + if err == nil && strings.TrimSpace(string(out)) == "true" { + return true + } + out, err = exec.Command("osascript", "-e", `tell application "System Events" to exists process "Codex"`).Output() if err == nil && strings.TrimSpace(string(out)) == "true" { return true } @@ -886,7 +964,7 @@ func codexAppMatchingProcessIDs() []int { } func codexAppWindowsMatchingProcessIDs() []int { - script := fmt.Sprintf(`$current = %d; Get-CimInstance Win32_Process -Filter "Name = 'Codex.exe' OR Name = 'codex.exe'" | Where-Object { $_.ProcessId -ne $current -and ((($_.Name -ieq 'Codex.exe') -and (($null -eq $_.CommandLine) -or ($_.CommandLine -notlike '* --type=*'))) -or (($_.Name -ieq 'codex.exe') -and ($_.CommandLine -like '*app-server*'))) } | Select-Object -ExpandProperty ProcessId`, os.Getpid()) + script := fmt.Sprintf(`$current = %d; Get-CimInstance Win32_Process -Filter "Name = 'Codex.exe' OR Name = 'codex.exe' OR Name = 'ChatGPT.exe' OR Name = 'chatgpt.exe'" | Where-Object { $_.ProcessId -ne $current -and ((($_.Name -ieq 'Codex.exe' -or $_.Name -ieq 'ChatGPT.exe') -and (($null -eq $_.CommandLine) -or ($_.CommandLine -notlike '* --type=*'))) -or ((($_.Name -ieq 'codex.exe') -or ($_.Name -ieq 'chatgpt.exe')) -and ($_.CommandLine -like '*app-server*'))) } | Select-Object -ExpandProperty ProcessId`, os.Getpid()) out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() if err != nil { return nil @@ -906,7 +984,7 @@ func defaultCodexAppRunningAppPath() string { if codexAppGOOS != "windows" { return "" } - script := `(Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)` + script := `(Get-Process ChatGPT,Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)` out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() if err != nil { return "" @@ -918,7 +996,7 @@ func defaultCodexAppStartAppID() string { if codexAppGOOS != "windows" { return "" } - script := `(Get-StartApps Codex | Where-Object { $_.Name -eq 'Codex' -or $_.Name -like 'Codex*' } | Select-Object -First 1 -ExpandProperty AppID)` + script := `(Get-StartApps | Where-Object { $_.Name -eq 'ChatGPT' -or $_.Name -like 'ChatGPT*' -or $_.Name -eq 'Codex' -or $_.Name -like 'Codex*' } | Select-Object -First 1 -ExpandProperty AppID)` out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() if err != nil { return "" @@ -936,7 +1014,7 @@ func defaultCodexAppCanOpenBundleID() bool { } func codexAppProcessMatches(command string) bool { - if strings.Contains(command, `\Codex.exe`) && strings.Contains(command, " --type=") { + if (strings.Contains(command, `\Codex.exe`) || strings.Contains(command, `\ChatGPT.exe`)) && strings.Contains(command, " --type=") { return false } for _, pattern := range codexAppProcessPatterns() { @@ -949,8 +1027,14 @@ func codexAppProcessMatches(command string) bool { func codexAppProcessPatterns() []string { return []string{ + "ChatGPT.app/Contents/MacOS/ChatGPT", + "ChatGPT.app/Contents/Resources/codex app-server", "Codex.app/Contents/MacOS/Codex", "Codex.app/Contents/Resources/codex app-server", + `\ChatGPT.exe`, + `resources\chatgpt.exe app-server`, + `resources\chatgpt.exe" app-server`, + `resources\chatgpt.exe" "app-server`, `\Codex.exe`, `resources\codex.exe app-server`, `resources\codex.exe" app-server`, diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go index 4693e176..6cb3e389 100644 --- a/cmd/launch/codex_app_test.go +++ b/cmd/launch/codex_app_test.go @@ -2,9 +2,11 @@ package launch import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -157,6 +159,27 @@ func TestCodexAppInstalledUsesMacBundleIDFallback(t *testing.T) { } } +func TestChatGPTMissingAppGivesDownloadRecovery(t *testing.T) { + withCodexAppPlatform(t, "darwin") + + oldCanOpenID := codexAppCanOpenID + oldStat := codexAppStat + codexAppCanOpenID = func() bool { return false } + codexAppStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } + t.Cleanup(func() { + codexAppCanOpenID = oldCanOpenID + codexAppStat = oldStat + }) + + err := EnsureIntegrationInstalled(chatGPTIntegrationName, &CodexApp{}) + if err == nil { + t.Fatal("expected missing ChatGPT install error") + } + if !strings.Contains(err.Error(), "chatgpt is not installed") || !strings.Contains(err.Error(), "https://chatgpt.com/download") { + t.Fatalf("missing-app error = %q, want ChatGPT download recovery", err) + } +} + func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -291,6 +314,58 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`) } +func TestCodexAppConfigureIsIdempotentAndPreservesUnrelatedProvider(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "[model_providers.custom]\n" + + `name = "Custom"` + "\n" + + `base_url = "https://example.invalid/v1"` + "\n" + + `env_key = "CUSTOM_API_KEY"` + "\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + app := &CodexApp{} + models := testLaunchModels("llama3.2", "qwen3:8b") + if err := app.ConfigureWithModels("llama3.2", models); err != nil { + t.Fatalf("first ConfigureWithModels returned error: %v", err) + } + first, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if err := app.ConfigureWithModels("llama3.2", models); err != nil { + t.Fatalf("second ConfigureWithModels returned error: %v", err) + } + second, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(second) != string(first) { + t.Fatalf("rerun changed generated config:\nfirst:\n%s\nsecond:\n%s", first, second) + } + + parsed, err := codexParseConfig(string(second)) + if err != nil { + t.Fatal(err) + } + if got := parsed.ProviderString("custom", "env_key"); got != "CUSTOM_API_KEY" { + t.Fatalf("custom provider env_key = %q, want preserved value", got) + } + if parsed.Exists("model_providers", codexAppProfileName, "env_key") { + t.Fatalf("managed local Ollama provider should not require an API key:\n%s", second) + } + if got := app.CurrentModel(); got != "llama3.2" { + t.Fatalf("CurrentModel = %q, want llama3.2", got) + } +} + func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -1446,6 +1521,61 @@ func TestCodexAppRunWaitsForGracefulExitBeforeReopening(t *testing.T) { } } +func TestCodexAppRunCtrlCAbortsEntireRestartFlow(t *testing.T) { + withCodexAppPlatform(t, "darwin") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + oldSleep := codexAppSleep + oldDefaultSpinner := DefaultSpinner + t.Cleanup(func() { + codexAppSleep = oldSleep + DefaultSpinner = oldDefaultSpinner + }) + + // Simulate the user pressing Ctrl+C during the graceful-exit wait: the + // shared spinner's cancellation channel is closed on the first poll, + // which only happens inside the wait loop. + cancel := make(chan struct{}) + var spinnerStopped bool + codexAppSleep = func(time.Duration) { + select { + case <-cancel: + default: + close(cancel) + } + } + DefaultSpinner = func(string) *Spinner { + return NewSpinner(func() { spinnerStopped = true }, cancel) + } + + var calls []string + withCodexAppProcessHooks(t, + func() bool { return true }, // app stays "running" so the wait polls + func() error { calls = append(calls, "quit"); return nil }, + func() error { calls = append(calls, "open"); return nil }, + ) + codexAppExitTimeout = 5 * time.Second + codexAppForceQuit = func() error { + calls = append(calls, "force") + return nil + } + + err := (&CodexApp{}).Run("qwen3.5", nil, nil) + if !errors.Is(err, ErrCancelled) { + t.Fatalf("Run error = %v, want ErrCancelled", err) + } + if !spinnerStopped { + t.Fatal("expected the shared spinner to be stopped on cancel") + } + // The flow must abort after quit: no force-quit, no reopen, despite the app + // still being "running" (which would otherwise trigger the force-quit path). + want := []string{"quit"} + if !slices.Equal(calls, want) { + t.Fatalf("calls = %v, want the whole flow to abort after quit: %v", calls, want) + } +} + func TestCodexAppRunForceStopsMacAfterGracefulTimeout(t *testing.T) { withCodexAppPlatform(t, "darwin") restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) @@ -1499,7 +1629,7 @@ func TestCodexAppRunReturnsMacForceStopError(t *testing.T) { } err := (&CodexApp{}).Run("qwen3.5", nil, nil) - if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "operation not permitted") { + if err == nil || !strings.Contains(err.Error(), "force stop ChatGPT") || !strings.Contains(err.Error(), "operation not permitted") { t.Fatalf("Run error = %v, want force stop failure", err) } } @@ -1641,7 +1771,7 @@ func TestCodexAppRunReturnsWindowsForceStopError(t *testing.T) { } err := (&CodexApp{}).Run("qwen3.5", nil, nil) - if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "access denied") { + if err == nil || !strings.Contains(err.Error(), "force stop ChatGPT") || !strings.Contains(err.Error(), "access denied") { t.Fatalf("Run error = %v, want force stop failure", err) } } @@ -1656,6 +1786,8 @@ func TestCodexAppRunRejectsExtraArgs(t *testing.T) { func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) { for _, command := range []string{ + "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", + "/Applications/ChatGPT.app/Contents/Resources/codex app-server --analytics-default-enabled", "/Applications/Codex.app/Contents/MacOS/Codex", "/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled", `C:\Users\parth\AppData\Local\Programs\Codex\Codex.exe`, @@ -1668,6 +1800,7 @@ func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) { } for _, command := range []string{ + "/Applications/ChatGPT.app/Contents/Frameworks/ChatGPT Helper.app/Contents/MacOS/ChatGPT Helper", "/Applications/Codex.app/Contents/Frameworks/Codex Helper.app/Contents/MacOS/Codex Helper", "/Applications/Codex.app/Contents/Frameworks/Electron Framework.framework/Helpers/chrome_crashpad_handler", `"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=renderer --user-data-dir="C:\Users\parth\AppData\Roaming\Codex"`, @@ -1679,6 +1812,24 @@ func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) { } } +func TestCodexAppCandidatesIncludeChatGPT(t *testing.T) { + withCodexAppPlatform(t, "darwin") + candidates := codexAppDarwinAppCandidates() + if len(candidates) == 0 || candidates[0] != "/Applications/ChatGPT.app" { + t.Fatalf("darwin candidates = %v, want ChatGPT first", candidates) + } + if !slices.Contains(candidates, "/Applications/Codex.app") { + t.Fatalf("darwin candidates = %v, want legacy Codex app", candidates) + } + + withCodexAppPlatform(t, "windows") + local := filepath.Join(t.TempDir(), "LocalAppData") + t.Setenv("LOCALAPPDATA", local) + if candidates := codexAppWindowsAppCandidates(); !slices.Contains(candidates, filepath.Join(local, "Programs", "ChatGPT", "ChatGPT.exe")) { + t.Fatalf("windows candidates = %v, want ChatGPT app", candidates) + } +} + func catalogSlugs(models []map[string]any) []string { slugs := make([]string, 0, len(models)) for _, model := range models { diff --git a/cmd/launch/hermes_test.go b/cmd/launch/hermes_test.go index 690ab8cf..bcdcaf71 100644 --- a/cmd/launch/hermes_test.go +++ b/cmd/launch/hermes_test.go @@ -732,6 +732,7 @@ func TestHermesDesktopRun(t *testing.T) { }) } } + func writeHermesVersionedTestBinary(t *testing.T, dir, version string) { t.Helper() script := "#!/bin/sh\n" + diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 27483624..8a0c03b5 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -58,9 +58,10 @@ func TestIntegrationLookup(t *testing.T) { {"claude desktop", "claude-desktop", true, "Claude Desktop"}, {"claude desktop alias", "claude-app", true, "Claude Desktop"}, {"codex", "codex", true, "Codex"}, - {"codex app", "codex-app", true, "Codex App"}, - {"codex app desktop alias", "codex-desktop", true, "Codex App"}, - {"codex app gui alias", "codex-gui", true, "Codex App"}, + {"chatgpt", "chatgpt", true, "ChatGPT"}, + {"codex app legacy alias", "codex-app", true, "ChatGPT"}, + {"codex app desktop alias", "codex-desktop", true, "ChatGPT"}, + {"codex app gui alias", "codex-gui", true, "ChatGPT"}, {"hermes desktop", "hermes-desktop", true, "Hermes Desktop"}, {"kimi", "kimi", true, "Kimi Code CLI"}, {"droid", "droid", true, "Droid"}, @@ -85,7 +86,7 @@ func TestIntegrationLookup(t *testing.T) { } func TestIntegrationRegistry(t *testing.T) { - expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"} + expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { r, ok := integrations[name] @@ -99,6 +100,30 @@ func TestIntegrationRegistry(t *testing.T) { } } +func TestChatGPTMigratesLegacyCodexAppLaunchConfig(t *testing.T) { + setTestHome(t, t.TempDir()) + if err := config.SaveIntegration(codexAppIntegrationName, []string{"qwen3.5"}); err != nil { + t.Fatal(err) + } + if err := config.MarkIntegrationOnboarded(codexAppIntegrationName); err != nil { + t.Fatal(err) + } + + got, err := loadStoredIntegrationConfig(chatGPTIntegrationName) + if err != nil { + t.Fatalf("loadStoredIntegrationConfig returned error: %v", err) + } + if diff := compareStrings(got.Models, []string{"qwen3.5"}); diff != "" { + t.Fatalf("migrated models mismatch: %s", diff) + } + if !got.Onboarded { + t.Fatal("migrated integration should remain onboarded") + } + if _, err := config.LoadIntegration(chatGPTIntegrationName); err != nil { + t.Fatalf("canonical ChatGPT config was not written: %v", err) + } +} + func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) { for _, info := range ListIntegrationInfos() { switch info.Name { @@ -1788,9 +1813,9 @@ func TestIntegration_InstallHint(t *testing.T) { wantURL: "https://developers.openai.com/codex/cli/", }, { - name: "codex app has hint", - input: "codex-app", - wantURL: "https://developers.openai.com/codex/quickstart", + name: "chatgpt has hint", + input: "chatgpt", + wantURL: "https://chatgpt.com/download", }, { name: "openclaw has hint", @@ -1876,7 +1901,7 @@ func TestListIntegrationInfos(t *testing.T) { if codexAppSupported() != nil { filtered := make([]string, 0, len(want)) for _, name := range want { - if name != "codex-app" { + if name != "chatgpt" { filtered = append(filtered, name) } } @@ -1893,7 +1918,7 @@ func TestListIntegrationInfos(t *testing.T) { for _, info := range infos { got = append(got, info.Name) } - wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"} + wantPrefix := []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"} if codexAppSupported() != nil { wantPrefix = []string{"claude", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"} } @@ -1919,7 +1944,7 @@ func TestListIntegrationInfos(t *testing.T) { t.Run("includes known integrations", func(t *testing.T) { known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false} if codexAppSupported() == nil { - known["codex-app"] = false + known["chatgpt"] = false } if poolsideGOOS != "windows" { known["pool"] = false diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index fdab3aba..0bd92943 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -287,7 +287,7 @@ Flags and extra arguments require an integration name. Supported integrations: claude Claude Code - codex-app Codex App (aliases: codex-desktop, codex-gui) + chatgpt ChatGPT (aliases: codex-app, codex-desktop, codex-gui) hermes Hermes Agent openclaw OpenClaw (aliases: clawdbot, moltbot) opencode OpenCode @@ -307,8 +307,8 @@ Examples: ollama launch ollama launch claude ollama launch claude --model - ollama launch codex-app - ollama launch codex-app --restore + ollama launch chatgpt + ollama launch chatgpt --restore ollama launch hermes ollama launch hermes-desktop ollama launch droid --config (does not auto-launch) diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 46f2879f..52c93a24 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -33,7 +33,7 @@ type IntegrationInfo struct { Description string } -var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"} +var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"} var integrationSpecs = []*IntegrationSpec{ { @@ -95,15 +95,15 @@ var integrationSpecs = []*IntegrationSpec{ }, }, { - Name: "codex-app", + Name: chatGPTIntegrationName, Runner: &CodexApp{}, - Aliases: []string{"codex-desktop", "codex-gui"}, - Description: "An AI agent you can delegate real work to, by OpenAI", + Aliases: []string{codexAppIntegrationName, "codex-desktop", "codex-gui"}, + Description: "Complete work with ChatGPT", Install: IntegrationInstallSpec{ CheckInstalled: func() bool { return codexAppInstalled() }, - URL: "https://developers.openai.com/codex/quickstart", + URL: "https://chatgpt.com/download", }, }, { diff --git a/cmd/launch/spinner.go b/cmd/launch/spinner.go new file mode 100644 index 00000000..d962ba79 --- /dev/null +++ b/cmd/launch/spinner.go @@ -0,0 +1,109 @@ +package launch + +import ( + "fmt" + "os" + "sync" + "time" +) + +// SpinnerFrames are the braille spinner frames used by the bubbletea TUIs in +// this codebase (sign-in, upgrade). StartSpinner uses the same frames for its +// fallback so the restart spinner matches the look of those flows. +var SpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// DefaultSpinner, when set, starts an animated spinner displaying message and +// returns a *Spinner. cmd/cmd.go registers a bubbletea implementation from +// cmd/tui; when unset (or when it returns nil, e.g. no TTY) StartSpinner falls +// back to a simple ANSI spinner using SpinnerFrames. +var DefaultSpinner func(message string) *Spinner + +// Spinner is a handle on a running animated spinner. Stop halts the spinner +// and clears its line (it blocks until the spinner has fully stopped and is +// safe to call multiple times). Cancelled returns a channel that is closed if +// the user interrupts the spinner (e.g. with Ctrl+C); wait loops can select on +// it to abort early. For the non-interactive ANSI fallback the channel is never +// closed because Ctrl+C raises SIGINT and terminates the process directly. +type Spinner struct { + stop func() + cancelled chan struct{} +} + +// NewSpinner builds a Spinner from a stop function and a cancellation channel. +// It is intended for implementations of DefaultSpinner (e.g. the bubbletea +// spinner in cmd/tui). stop must be safe to call multiple times; cancelled is +// closed by the implementation when the user interrupts the spinner, or left +// open when interruption is handled another way (e.g. SIGINT). +func NewSpinner(stop func(), cancelled chan struct{}) *Spinner { + return &Spinner{stop: stop, cancelled: cancelled} +} + +// Stop halts the spinner and clears its line. It is a no-op when the spinner +// already stopped (for example after the user cancelled it). +func (s *Spinner) Stop() { + if s != nil && s.stop != nil { + s.stop() + } +} + +// Cancelled returns a channel that is closed when the user interrupts the +// spinner. Callers may select on it to abort a blocking wait. +func (s *Spinner) Cancelled() <-chan struct{} { + if s == nil { + return nil + } + return s.cancelled +} + +// StartSpinner begins an animated spinner displaying message and returns a +// *Spinner handle. It uses DefaultSpinner when available, otherwise a simple +// ANSI fallback that renders SpinnerFrames to stderr without requiring a TTY. +func StartSpinner(message string) *Spinner { + if DefaultSpinner != nil { + if s := DefaultSpinner(message); s != nil { + return s + } + } + return defaultSpinner(message) +} + +// defaultSpinner renders SpinnerFrames to stderr without requiring a TTY. It +// runs in its own goroutine so it can animate while a caller polls; Stop +// signals the goroutine to exit, waits for it, and clears the spinner line. +func defaultSpinner(message string) *Spinner { + frames := SpinnerFrames + frame := 0 + fmt.Fprintf(os.Stderr, "\r\033[90m%s %s\033[0m", message, frames[0]) + + done := make(chan struct{}) + exited := make(chan struct{}) + var once sync.Once + + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-done: + close(exited) + return + case <-ticker.C: + frame++ + fmt.Fprintf(os.Stderr, "\r\033[90m%s %s\033[0m", message, frames[frame%len(frames)]) + } + } + }() + + stop := func() { + once.Do(func() { + close(done) + <-exited + fmt.Fprintf(os.Stderr, "\r\033[K") + }) + } + + // Ctrl+C in non-raw mode raises SIGINT and terminates the process by + // default (the launch flow installs no SIGINT handler), so this cancelled + // channel is intentionally never closed. + return &Spinner{stop: stop, cancelled: make(chan struct{})} +} diff --git a/cmd/tui/spinner.go b/cmd/tui/spinner.go new file mode 100644 index 00000000..5d0f2613 --- /dev/null +++ b/cmd/tui/spinner.go @@ -0,0 +1,110 @@ +package tui + +import ( + "os" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ollama/ollama/cmd/launch" + "golang.org/x/term" +) + +// spinnerStyle dims the spinner so it reads as ancillary status text, matching +// the sign-in/upgrade spinners in signin.go. +var spinnerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}) + +type spinnerTickMsg struct{} + +// spinnerQuitMsg is sent by Stop to ask the program to quit cleanly. +type spinnerQuitMsg struct{} + +type spinnerModel struct { + message string + frame int + quitting bool + cancelled chan struct{} + once sync.Once +} + +func (m *spinnerModel) Init() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return spinnerTickMsg{} }) +} + +func (m *spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinnerTickMsg: + if m.quitting { + return m, nil + } + m.frame++ + return m, tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return spinnerTickMsg{} }) + case tea.KeyMsg: + // bubbletea runs the terminal in raw mode, so Ctrl+C is delivered here + // as a key rather than as a SIGINT. Treat it as a user cancellation: + // close the cancelled channel (so the caller's wait loop can abort) and + // quit the program so bubbletea restores the terminal before control + // returns to the caller. + if msg.String() == "ctrl+c" { + m.once.Do(func() { close(m.cancelled) }) + m.quitting = true + return m, tea.Quit + } + case spinnerQuitMsg: + m.quitting = true + // Returning "" from View on quit clears the spinner line, mirroring how + // confirm.go blanks its view when it quits. + return m, tea.Quit + } + return m, nil +} + +func (m *spinnerModel) View() string { + if m.quitting { + return "" + } + frame := launch.SpinnerFrames[m.frame%len(launch.SpinnerFrames)] + return spinnerStyle.Render(frame + " " + m.message) +} + +// RunSpinner runs a bubbletea spinner displaying message until the returned +// Spinner's Stop is called. Stop signals the program to quit and blocks until +// it has exited and cleared its line. If the user presses Ctrl+C while the +// spinner is running, Spinner.Cancelled() is closed so the caller can abort +// its wait; the program quits and the terminal is restored before Stop +// returns. RunSpinner returns nil when there is no interactive terminal, so +// launch.StartSpinner can fall back to its ANSI spinner for headless/--yes +// runs. +func RunSpinner(message string) *launch.Spinner { + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { + return nil + } + + cancelled := make(chan struct{}) + m := &spinnerModel{message: message, cancelled: cancelled} + p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) + done := make(chan struct{}) + go func() { + _, _ = p.Run() + close(done) + }() + + var once sync.Once + stop := func() { + once.Do(func() { + select { + case <-done: + // Program already finished (e.g. the user cancelled), so don't + // send to it; just ensure it has exited. + return + default: + } + p.Send(spinnerQuitMsg{}) + <-done + }) + } + + return launch.NewSpinner(stop, cancelled) +} diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index 62adf7a8..2f2b1e81 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -29,10 +29,10 @@ func launcherTestState() *launch.LauncherState { Selectable: true, Changeable: true, }, - "codex-app": { - Name: "codex-app", - DisplayName: "Codex App", - Description: "An AI agent you can delegate real work to, by OpenAI", + "chatgpt": { + Name: "chatgpt", + DisplayName: "ChatGPT", + Description: "Complete work with ChatGPT", Selectable: true, Changeable: true, }, @@ -123,7 +123,7 @@ func TestMenuRendersRootLaunchChoices(t *testing.T) { t.Fatalf("expected menu view to contain %q\n%s", want, view) } } - for _, hidden := range []string{"Launch Codex App", "Launch Codex", "Launch Droid", "Launch Pi", "More..."} { + for _, hidden := range []string{"Launch ChatGPT", "Launch Codex", "Launch Droid", "Launch Pi", "More..."} { if strings.Contains(view, hidden) { t.Fatalf("expected root menu to omit %q\n%s", hidden, view) }