launch: rename Codex App integration to ChatGPT (#17161)
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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`,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" +
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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{})}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user