launch: rename Codex App integration to ChatGPT (#17161)

This commit is contained in:
Parth Sareen
2026-07-13 14:34:17 -07:00
committed by GitHub
parent cd600e19a3
commit f1a0ffd621
12 changed files with 553 additions and 71 deletions
+4 -4
View File
@@ -22,10 +22,10 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
iconClassName: "h-7 w-7", iconClassName: "h-7 w-7",
}, },
{ {
id: "codex-app", id: "chatgpt",
name: "Codex App", name: "ChatGPT",
command: "ollama launch codex-app", command: "ollama launch chatgpt",
description: "An AI agent you can delegate real work to, by OpenAI", description: "Complete work with ChatGPT",
icon: "/launch-icons/codex-app.png", icon: "/launch-icons/codex-app.png",
iconClassName: "h-full w-full", iconClassName: "h-full w-full",
}, },
+3 -1
View File
@@ -95,6 +95,8 @@ func init() {
} }
launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions
launch.DefaultSpinner = tui.RunSpinner
} }
func runTUISingleSelector(title string, items []launch.SelectionItem, current string, updates <-chan []launch.SelectionItem) (string, error) { 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 { func launcherActionExitsLoop(integration string) bool {
switch integration { switch integration {
case "codex-app", "vscode": case "chatgpt", "codex-app", "vscode":
return true return true
default: default:
return false return false
+1 -1
View File
@@ -249,7 +249,7 @@ func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) {
cmd := &cobra.Command{} cmd := &cobra.Command{}
cmd.SetContext(context.Background()) 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{ continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{
resolveRunModel: unexpectedRunModelResolution(t), resolveRunModel: unexpectedRunModelResolution(t),
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
+124 -40
View File
@@ -2,6 +2,7 @@ package launch
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -16,13 +17,14 @@ import (
) )
const ( const (
chatGPTIntegrationName = "chatgpt"
codexAppIntegrationName = "codex-app" codexAppIntegrationName = "codex-app"
codexAppProfileName = "ollama-launch-codex-app" codexAppProfileName = "ollama-launch-codex-app"
codexAppBundleID = "com.openai.codex" codexAppBundleID = "com.openai.codex"
codexAppModelCatalogFilename = "ollama-launch-models.json" codexAppModelCatalogFilename = "ollama-launch-models.json"
codexAppRestoreHint = "To restore your usual Codex profile, run: ollama launch codex-app --restore" codexAppRestoreHint = "To restore your usual ChatGPT profile, run: ollama launch chatgpt --restore"
codexAppConfigurationSuccess = "Codex App profile changed to Ollama." codexAppConfigurationSuccess = "ChatGPT profile changed to Ollama."
codexAppRestoreSuccess = "Codex App restored to your usual profile." codexAppRestoreSuccess = "ChatGPT restored to your usual profile."
) )
var ( var (
@@ -49,7 +51,7 @@ var (
// model while leaving model discovery and switching to Codex's Ollama provider. // model while leaving model discovery and switching to Codex's Ollama provider.
type CodexApp struct{} 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() } 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 { func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) error {
primary = strings.TrimSpace(primary) primary = strings.TrimSpace(primary)
if primary == "" { if primary == "" {
return fmt.Errorf("codex-app requires a model") return fmt.Errorf("chatgpt requires a model")
} }
configPath, err := codexConfigPath() configPath, err := codexConfigPath()
@@ -250,10 +252,10 @@ func writeCodexAppConfig(configPath, model, modelCatalogPath string) error {
func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPath, baseURL string) error { func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPath, baseURL string) error {
if got, ok := config.RootStringOK(codexRootProfileKey); ok { 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) { 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 { for _, check := range []struct {
path []string path []string
@@ -267,14 +269,14 @@ func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPat
{[]string{"model_providers", codexAppProfileName, "wire_api"}, "responses"}, {[]string{"model_providers", codexAppProfileName, "wire_api"}, "responses"},
} { } {
if got, ok := config.String(check.path...); !ok || got != check.want { 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 return nil
} }
func (c *CodexApp) Onboard() error { func (c *CodexApp) Onboard() error {
return config.MarkIntegrationOnboarded(codexAppIntegrationName) return config.MarkIntegrationOnboarded(chatGPTIntegrationName)
} }
func (c *CodexApp) RequiresInteractiveOnboarding() bool { func (c *CodexApp) RequiresInteractiveOnboarding() bool {
@@ -298,9 +300,9 @@ func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error {
return err return err
} }
if len(args) > 0 { 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 { func (c *CodexApp) Restore() error {
@@ -324,7 +326,7 @@ func (c *CodexApp) Restore() error {
if err := codexAppRemoveOwnedCatalog(); err != nil { if err := codexAppRemoveOwnedCatalog(); err != nil {
return codexAppRestoreFailure(configPath, err) 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) return codexAppRestoreFailure(configPath, err)
} }
@@ -360,11 +362,11 @@ func (c *CodexApp) Restore() error {
if err := removeCodexAppRestoreState(); err != nil { if err := removeCodexAppRestoreState(); err != nil {
return codexAppRestoreFailure(configPath, err) 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 { 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, err,
configPath, configPath,
codexAppRestoreStatePath(), codexAppRestoreStatePath(),
@@ -378,7 +380,7 @@ func codexAppSupported() error {
case "darwin", "windows": case "darwin", "windows":
return nil return nil
default: 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 { func writeCodexAppModelCatalog(path, primary string, models []LaunchModel) error {
if len(models) == 0 { 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() baseInstructions := codexAppBaseInstructions()
@@ -585,9 +587,12 @@ func codexAppAppPath() string {
} }
func codexAppDarwinAppCandidates() []string { func codexAppDarwinAppCandidates() []string {
candidates := []string{"/Applications/Codex.app"} candidates := []string{"/Applications/ChatGPT.app", "/Applications/Codex.app"}
if home, err := os.UserHomeDir(); err == nil { 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 return candidates
} }
@@ -599,6 +604,11 @@ func codexAppWindowsAppCandidates() []string {
} }
candidates := []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", "Codex", "Codex.exe"),
filepath.Join(local, "Programs", "OpenAI Codex", "Codex.exe"), filepath.Join(local, "Programs", "OpenAI Codex", "Codex.exe"),
filepath.Join(local, "Codex", "Codex.exe"), filepath.Join(local, "Codex", "Codex.exe"),
@@ -607,6 +617,11 @@ func codexAppWindowsAppCandidates() []string {
filepath.Join(local, "openai-codex-electron", "Codex.exe"), filepath.Join(local, "openai-codex-electron", "Codex.exe"),
} }
for _, pattern := range []string{ 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", "Codex", "app-*", "Codex.exe"),
filepath.Join(local, "Programs", "OpenAI Codex", "app-*", "Codex.exe"), filepath.Join(local, "Programs", "OpenAI Codex", "app-*", "Codex.exe"),
filepath.Join(local, "Codex", "app-*", "Codex.exe"), filepath.Join(local, "Codex", "app-*", "Codex.exe"),
@@ -669,22 +684,54 @@ func codexAppLaunchOrRestart(prompt string, launchArgs []string) error {
return err return err
} }
if !restart { 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 return nil
} }
if err := codexAppQuitApp(); err != nil { // A single spinner and cancellation channel span the entire restart flow
return fmt.Errorf("quit Codex: %w", err) // (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() { if gracefulErr != nil && !codexAppForceQuitSupported() {
return gracefulErr return gracefulErr
} }
if codexAppForceQuitSupported() && codexAppIsRunning() { if codexAppForceQuitSupported() && codexAppIsRunning() {
if forceErr := codexAppForceQuit(); forceErr != nil { if isCancelled() {
return fmt.Errorf("force stop Codex: %w", forceErr) 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 return err
} }
} else if gracefulErr != nil { } else if gracefulErr != nil {
@@ -692,6 +739,10 @@ func codexAppLaunchOrRestart(prompt string, launchArgs []string) error {
return gracefulErr return gracefulErr
} }
} }
if isCancelled() {
return ErrCancelled
}
sp.Stop()
if restartAppID != "" { if restartAppID != "" {
return codexAppOpenStart(restartAppID) return codexAppOpenStart(restartAppID)
} }
@@ -705,8 +756,8 @@ func codexAppForceQuitSupported() bool {
return codexAppGOOS == "darwin" || codexAppGOOS == "windows" return codexAppGOOS == "darwin" || codexAppGOOS == "windows"
} }
func waitForCodexAppGracefulExit(timeout time.Duration) error { func waitForCodexAppGracefulExit(timeout time.Duration, cancel <-chan struct{}) error {
return waitForCodexAppCondition(timeout, func() bool { return waitForCodexAppCondition(timeout, cancel, func() bool {
if codexAppGOOS == "windows" { if codexAppGOOS == "windows" {
return !codexAppHasWindow() return !codexAppHasWindow()
} }
@@ -714,21 +765,41 @@ func waitForCodexAppGracefulExit(timeout time.Duration) error {
}) })
} }
func waitForCodexAppExit(timeout time.Duration) error { func waitForCodexAppExit(timeout time.Duration, cancel <-chan struct{}) error {
return waitForCodexAppCondition(timeout, func() bool { return waitForCodexAppCondition(timeout, cancel, func() bool {
return !codexAppIsRunning() 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) deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if cancel != nil {
select {
case <-cancel:
return ErrCancelled
default:
}
}
if done() { if done() {
return nil return nil
} }
codexAppSleep(200 * time.Millisecond) 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 { func defaultCodexAppOpenApp(args []string) error {
@@ -751,7 +822,7 @@ func defaultCodexAppOpenApp(args []string) error {
if appID := codexAppStartID(); appID != "" { if appID := codexAppStartID(); appID != "" {
return codexAppOpenStart(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": case "darwin":
if path := codexAppAppPath(); path != "" { if path := codexAppAppPath(); path != "" {
cmd := exec.Command("open", path) cmd := exec.Command("open", path)
@@ -788,14 +859,17 @@ func defaultCodexAppOpenStartAppID(appID string) error {
func defaultCodexAppQuitApp() error { func defaultCodexAppQuitApp() error {
if codexAppGOOS == "windows" { 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() 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 { if scriptErr != nil {
scriptErr = exec.Command("osascript", "-e", `tell application id "`+codexAppBundleID+`" to quit`).Run() 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 return scriptErr
} }
@@ -834,7 +908,7 @@ func defaultCodexAppHasOpenWindow() bool {
if codexAppGOOS != "windows" { if codexAppGOOS != "windows" {
return codexAppIsRunning() 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() out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
return err == nil && strings.TrimSpace(string(out)) != "" return err == nil && strings.TrimSpace(string(out)) != ""
} }
@@ -844,7 +918,11 @@ func defaultCodexAppIsRunning() bool {
case "windows": case "windows":
return len(codexAppMatchingProcessIDs()) > 0 return len(codexAppMatchingProcessIDs()) > 0
case "darwin": 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" { if err == nil && strings.TrimSpace(string(out)) == "true" {
return true return true
} }
@@ -886,7 +964,7 @@ func codexAppMatchingProcessIDs() []int {
} }
func codexAppWindowsMatchingProcessIDs() []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() out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil { if err != nil {
return nil return nil
@@ -906,7 +984,7 @@ func defaultCodexAppRunningAppPath() string {
if codexAppGOOS != "windows" { if codexAppGOOS != "windows" {
return "" 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() out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil { if err != nil {
return "" return ""
@@ -918,7 +996,7 @@ func defaultCodexAppStartAppID() string {
if codexAppGOOS != "windows" { if codexAppGOOS != "windows" {
return "" 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() out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil { if err != nil {
return "" return ""
@@ -936,7 +1014,7 @@ func defaultCodexAppCanOpenBundleID() bool {
} }
func codexAppProcessMatches(command string) 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 return false
} }
for _, pattern := range codexAppProcessPatterns() { for _, pattern := range codexAppProcessPatterns() {
@@ -949,8 +1027,14 @@ func codexAppProcessMatches(command string) bool {
func codexAppProcessPatterns() []string { func codexAppProcessPatterns() []string {
return []string{ return []string{
"ChatGPT.app/Contents/MacOS/ChatGPT",
"ChatGPT.app/Contents/Resources/codex app-server",
"Codex.app/Contents/MacOS/Codex", "Codex.app/Contents/MacOS/Codex",
"Codex.app/Contents/Resources/codex app-server", "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`, `\Codex.exe`,
`resources\codex.exe app-server`, `resources\codex.exe app-server`,
`resources\codex.exe" app-server`, `resources\codex.exe" app-server`,
+153 -2
View File
@@ -2,9 +2,11 @@ package launch
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
setTestHome(t, tmpDir) setTestHome(t, tmpDir)
@@ -291,6 +314,58 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`) 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) { func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
setTestHome(t, tmpDir) 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) { func TestCodexAppRunForceStopsMacAfterGracefulTimeout(t *testing.T) {
withCodexAppPlatform(t, "darwin") withCodexAppPlatform(t, "darwin")
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
@@ -1499,7 +1629,7 @@ func TestCodexAppRunReturnsMacForceStopError(t *testing.T) {
} }
err := (&CodexApp{}).Run("qwen3.5", nil, nil) 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) 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) 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) t.Fatalf("Run error = %v, want force stop failure", err)
} }
} }
@@ -1656,6 +1786,8 @@ func TestCodexAppRunRejectsExtraArgs(t *testing.T) {
func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) { func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) {
for _, command := range []string{ 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/MacOS/Codex",
"/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled", "/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled",
`C:\Users\parth\AppData\Local\Programs\Codex\Codex.exe`, `C:\Users\parth\AppData\Local\Programs\Codex\Codex.exe`,
@@ -1668,6 +1800,7 @@ func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) {
} }
for _, command := range []string{ 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/Codex Helper.app/Contents/MacOS/Codex Helper",
"/Applications/Codex.app/Contents/Frameworks/Electron Framework.framework/Helpers/chrome_crashpad_handler", "/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"`, `"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 { func catalogSlugs(models []map[string]any) []string {
slugs := make([]string, 0, len(models)) slugs := make([]string, 0, len(models))
for _, model := range models { for _, model := range models {
+1
View File
@@ -732,6 +732,7 @@ func TestHermesDesktopRun(t *testing.T) {
}) })
} }
} }
func writeHermesVersionedTestBinary(t *testing.T, dir, version string) { func writeHermesVersionedTestBinary(t *testing.T, dir, version string) {
t.Helper() t.Helper()
script := "#!/bin/sh\n" + script := "#!/bin/sh\n" +
+35 -10
View File
@@ -58,9 +58,10 @@ func TestIntegrationLookup(t *testing.T) {
{"claude desktop", "claude-desktop", true, "Claude Desktop"}, {"claude desktop", "claude-desktop", true, "Claude Desktop"},
{"claude desktop alias", "claude-app", true, "Claude Desktop"}, {"claude desktop alias", "claude-app", true, "Claude Desktop"},
{"codex", "codex", true, "Codex"}, {"codex", "codex", true, "Codex"},
{"codex app", "codex-app", true, "Codex App"}, {"chatgpt", "chatgpt", true, "ChatGPT"},
{"codex app desktop alias", "codex-desktop", true, "Codex App"}, {"codex app legacy alias", "codex-app", true, "ChatGPT"},
{"codex app gui alias", "codex-gui", true, "Codex App"}, {"codex app desktop alias", "codex-desktop", true, "ChatGPT"},
{"codex app gui alias", "codex-gui", true, "ChatGPT"},
{"hermes desktop", "hermes-desktop", true, "Hermes Desktop"}, {"hermes desktop", "hermes-desktop", true, "Hermes Desktop"},
{"kimi", "kimi", true, "Kimi Code CLI"}, {"kimi", "kimi", true, "Kimi Code CLI"},
{"droid", "droid", true, "Droid"}, {"droid", "droid", true, "Droid"},
@@ -85,7 +86,7 @@ func TestIntegrationLookup(t *testing.T) {
} }
func TestIntegrationRegistry(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 { for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
r, ok := integrations[name] 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) { func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
for _, info := range ListIntegrationInfos() { for _, info := range ListIntegrationInfos() {
switch info.Name { switch info.Name {
@@ -1788,9 +1813,9 @@ func TestIntegration_InstallHint(t *testing.T) {
wantURL: "https://developers.openai.com/codex/cli/", wantURL: "https://developers.openai.com/codex/cli/",
}, },
{ {
name: "codex app has hint", name: "chatgpt has hint",
input: "codex-app", input: "chatgpt",
wantURL: "https://developers.openai.com/codex/quickstart", wantURL: "https://chatgpt.com/download",
}, },
{ {
name: "openclaw has hint", name: "openclaw has hint",
@@ -1876,7 +1901,7 @@ func TestListIntegrationInfos(t *testing.T) {
if codexAppSupported() != nil { if codexAppSupported() != nil {
filtered := make([]string, 0, len(want)) filtered := make([]string, 0, len(want))
for _, name := range want { for _, name := range want {
if name != "codex-app" { if name != "chatgpt" {
filtered = append(filtered, name) filtered = append(filtered, name)
} }
} }
@@ -1893,7 +1918,7 @@ func TestListIntegrationInfos(t *testing.T) {
for _, info := range infos { for _, info := range infos {
got = append(got, info.Name) 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 { if codexAppSupported() != nil {
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"} 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) { t.Run("includes known integrations", func(t *testing.T) {
known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false} known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false}
if codexAppSupported() == nil { if codexAppSupported() == nil {
known["codex-app"] = false known["chatgpt"] = false
} }
if poolsideGOOS != "windows" { if poolsideGOOS != "windows" {
known["pool"] = false known["pool"] = false
+3 -3
View File
@@ -287,7 +287,7 @@ Flags and extra arguments require an integration name.
Supported integrations: Supported integrations:
claude Claude Code claude Claude Code
codex-app Codex App (aliases: codex-desktop, codex-gui) chatgpt ChatGPT (aliases: codex-app, codex-desktop, codex-gui)
hermes Hermes Agent hermes Hermes Agent
openclaw OpenClaw (aliases: clawdbot, moltbot) openclaw OpenClaw (aliases: clawdbot, moltbot)
opencode OpenCode opencode OpenCode
@@ -307,8 +307,8 @@ Examples:
ollama launch ollama launch
ollama launch claude ollama launch claude
ollama launch claude --model <model> ollama launch claude --model <model>
ollama launch codex-app ollama launch chatgpt
ollama launch codex-app --restore ollama launch chatgpt --restore
ollama launch hermes ollama launch hermes
ollama launch hermes-desktop ollama launch hermes-desktop
ollama launch droid --config (does not auto-launch) ollama launch droid --config (does not auto-launch)
+5 -5
View File
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string 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{ var integrationSpecs = []*IntegrationSpec{
{ {
@@ -95,15 +95,15 @@ var integrationSpecs = []*IntegrationSpec{
}, },
}, },
{ {
Name: "codex-app", Name: chatGPTIntegrationName,
Runner: &CodexApp{}, Runner: &CodexApp{},
Aliases: []string{"codex-desktop", "codex-gui"}, Aliases: []string{codexAppIntegrationName, "codex-desktop", "codex-gui"},
Description: "An AI agent you can delegate real work to, by OpenAI", Description: "Complete work with ChatGPT",
Install: IntegrationInstallSpec{ Install: IntegrationInstallSpec{
CheckInstalled: func() bool { CheckInstalled: func() bool {
return codexAppInstalled() return codexAppInstalled()
}, },
URL: "https://developers.openai.com/codex/quickstart", URL: "https://chatgpt.com/download",
}, },
}, },
{ {
+109
View File
@@ -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{})}
}
+110
View File
@@ -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)
}
+5 -5
View File
@@ -29,10 +29,10 @@ func launcherTestState() *launch.LauncherState {
Selectable: true, Selectable: true,
Changeable: true, Changeable: true,
}, },
"codex-app": { "chatgpt": {
Name: "codex-app", Name: "chatgpt",
DisplayName: "Codex App", DisplayName: "ChatGPT",
Description: "An AI agent you can delegate real work to, by OpenAI", Description: "Complete work with ChatGPT",
Selectable: true, Selectable: true,
Changeable: true, Changeable: true,
}, },
@@ -123,7 +123,7 @@ func TestMenuRendersRootLaunchChoices(t *testing.T) {
t.Fatalf("expected menu view to contain %q\n%s", want, view) 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) { if strings.Contains(view, hidden) {
t.Fatalf("expected root menu to omit %q\n%s", hidden, view) t.Fatalf("expected root menu to omit %q\n%s", hidden, view)
} }