diff --git a/go.mod b/go.mod index d2dd343615a..bf69d5f06ab 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,7 @@ require ( github.com/mattn/go-colorable v0.1.15 github.com/mattn/go-isatty v0.0.22 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d - github.com/microsoft/dev-tunnels v0.1.19 + github.com/microsoft/dev-tunnels v0.1.27 github.com/muhammadmuzzammil1998/jsonc v1.0.0 github.com/opentracing/opentracing-go v1.2.0 github.com/rivo/tview v0.42.0 diff --git a/go.sum b/go.sum index 21b79e809e8..09411587530 100644 --- a/go.sum +++ b/go.sum @@ -382,8 +382,8 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/microsoft/dev-tunnels v0.1.19 h1:uBJ4HEWazgiukfC8sSAUWDnUumu3Dew8pzpNfupZe6k= -github.com/microsoft/dev-tunnels v0.1.19/go.mod h1:Jvr6RlyjUXomM6KsDmIQbq+hhKd5mWrBcv3MEsa78dc= +github.com/microsoft/dev-tunnels v0.1.27 h1:6YcDVNoDYAQ/e4I61hHaCIdnS0NltxCNWCeOUoLqTbE= +github.com/microsoft/dev-tunnels v0.1.27/go.mod h1:Jvr6RlyjUXomM6KsDmIQbq+hhKd5mWrBcv3MEsa78dc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= diff --git a/internal/agents/detect.go b/internal/agents/detect.go index fd330ed67e3..c2366f60530 100644 --- a/internal/agents/detect.go +++ b/internal/agents/detect.go @@ -4,18 +4,28 @@ import ( "fmt" "os" "regexp" + "strings" ) // AgentName is a validated agent identifier safe for use in HTTP headers. type AgentName string const ( - agentAmp AgentName = "amp" - agentClaudeCode AgentName = "claude-code" - agentCodex AgentName = "codex" - agentCopilotCLI AgentName = "copilot-cli" - agentGeminiCLI AgentName = "gemini-cli" - agentOpencode AgentName = "opencode" + agentAmp AgentName = "amp" + agentClaudeCode AgentName = "claude-code" + agentCodex AgentName = "codex" + agentCopilotCLI AgentName = "copilot-cli" + agentGeminiCLI AgentName = "gemini-cli" + agentOpencode AgentName = "opencode" + agentAntigravity AgentName = "antigravity" + agentAugmentCLI AgentName = "augment-cli" + agentReplit AgentName = "replit" + agentGoose AgentName = "goose" + agentCowork AgentName = "cowork" + agentCursor AgentName = "cursor" + agentCursorCLI AgentName = "cursor-cli" + agentKiro AgentName = "kiro" + agentPi AgentName = "pi" ) var validAgentName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) @@ -46,7 +56,7 @@ func detectWith(lookup func(string) (string, bool)) AgentName { return v } - // Generic agent identifiers — checked first because they are the most specific signal. + // Generic agent identifiers - checked first because they are the most specific signal. if v, ok := lookup("AI_AGENT"); ok && v != "" { if name, err := parseAgentName(v); err == nil { return name @@ -60,7 +70,7 @@ func detectWith(lookup func(string) (string, bool)) AgentName { return agentAmp } - // OpenAI Codex CLI — https://github.com/openai/codex + // OpenAI Codex CLI - https://github.com/openai/codex // CODEX_SANDBOX: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/spawn.rs#L25 // CODEX_THREAD_ID: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/exec_env.rs#L8 // CODEX_CI: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/unified_exec/process_manager.rs#L64 @@ -68,7 +78,7 @@ func detectWith(lookup func(string) (string, bool)) AgentName { return agentCodex } - // Google Gemini CLI — https://github.com/google-gemini/gemini-cli + // Google Gemini CLI - https://github.com/google-gemini/gemini-cli // GEMINI_CLI: https://github.com/google-gemini/gemini-cli/blob/46fd7b4864111032a1c7dfa1821b2000fc7531da/docs/tools/shell.md#L96-L97 if isSet("GEMINI_CLI") { return agentGeminiCLI @@ -80,20 +90,92 @@ func detectWith(lookup func(string) (string, bool)) AgentName { return agentCopilotCLI } - // OpenCode — https://github.com/anomalyco/opencode + // OpenCode - https://github.com/anomalyco/opencode // OPENCODE: https://github.com/anomalyco/opencode/blob/fde201c286a83ff32dda9b41d61d734a4449fe70/packages/opencode/src/index.ts#L78-L80 + // Not OPENCODE_CALLER or OPENCODE_CLIENT: they name the client that launched + // opencode (e.g. the VS Code extension), not the running agent. if isSet("OPENCODE") { return agentOpencode } - // Anthropic Claude Code — https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview + // Antigravity + // No first-party docs + if isSet("ANTIGRAVITY_AGENT") { + return agentAntigravity + } + + // Augment CLI + // No first-party docs + if isSet("AUGMENT_AGENT") { + return agentAugmentCLI + } + + // Replit + // REPL_ID is present throughout any Replit environment, not only when a + // Replit agent is driving the CLI, so it is a broad, low-confidence signal. + // REPL_ID: https://github.com/replit/go-replidentity/blob/2966ea2d227d572f6054ee8f077ad16a1be02663/examples/extract.go#L25 + if isSet("REPL_ID") { + return agentReplit + } + + // Anthropic Claude Code - https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview // CLAUDECODE: https://code.claude.com/docs/en/env-vars (CLAUDECODE section) - // Checked last because other agents (e.g. Amp) set CLAUDECODE=1 alongside their own vars. - if isSet("CLAUDECODE") { + // CLAUDE_CODE, CLAUDE_CODE_IS_COWORK: no first-party docs + // + // Cowork is a Claude Code mode that also sets CLAUDECODE, so it is checked + // first to win over the generic Claude Code signal below. + if isSet("CLAUDE_CODE_IS_COWORK") { + return agentCowork + } + + // Claude Code is checked after Amp and Cowork, which also set CLAUDECODE, so + // those more specific agents are detected first. + if isSet("CLAUDECODE") || isSet("CLAUDE_CODE") { // There is a CLAUDE_CODE_ENTRYPOINT env var that is set to `cli` or `desktop` etc, but it's not documented // so we don't want to rely on it too heavily. We'll just return a generic claude-code agent name. return agentClaudeCode } + // Cursor + // No first-party docs + // CURSOR_TRACE_ID (IDE) takes precedence over the Cursor CLI signal below. + if isSet("CURSOR_TRACE_ID") { + return agentCursor + } + + // Cursor CLI + // No first-party docs + if isSet("CURSOR_AGENT") || valueOf("CURSOR_EXTENSION_HOST_ROLE") == "agent-exec" { + return agentCursorCLI + } + + // Single-source signals matched against one environment variable. These + // carry lower corroboration than the presence-based agents above, so they + // are checked after them. + + // Kiro + // No first-party docs + if valueOf("TERM_PROGRAM") == "kiro" { + return agentKiro + } + + // Pi + // No first-party docs + // Anchored to a path separator so it only matches ".pi/agent" as a real + // path segment, not an incidental substring. The Windows separator is + // matched too, though confidence there is lower since it is unconfirmed + // that pi uses this layout on Windows. + if strings.Contains(valueOf("PATH"), "/.pi/agent") || strings.Contains(valueOf("PATH"), `\.pi\agent`) { + return agentPi + } + + // Goose is checked last because GOOSE_PROVIDER only indicates that Goose is + // configured as a model provider, not that it is driving the CLI, so any + // more specific signal above should win. + // GOOSE_PROVIDER: https://github.com/aaif-goose/goose/blob/48a2a3d1804ae75eb7b208a5d0d73fd976511b80/crates/goose/src/config/providers.rs#L93 + if isSet("GOOSE_PROVIDER") { + return agentGoose + } + return "" } diff --git a/internal/agents/detect_test.go b/internal/agents/detect_test.go index 6cb4f461195..7afac9e97d0 100644 --- a/internal/agents/detect_test.go +++ b/internal/agents/detect_test.go @@ -138,6 +138,101 @@ func TestDetectWith(t *testing.T) { env: map[string]string{"AI_AGENT": "bad agent", "GEMINI_CLI": "1"}, wantAgent: "gemini-cli", }, + { + name: "ANTIGRAVITY_AGENT", + env: map[string]string{"ANTIGRAVITY_AGENT": "1"}, + wantAgent: "antigravity", + }, + { + name: "AUGMENT_AGENT", + env: map[string]string{"AUGMENT_AGENT": "1"}, + wantAgent: "augment-cli", + }, + { + name: "REPL_ID", + env: map[string]string{"REPL_ID": "abc123"}, + wantAgent: "replit", + }, + { + name: "GOOSE_PROVIDER", + env: map[string]string{"GOOSE_PROVIDER": "anthropic"}, + wantAgent: "goose", + }, + { + name: "claude-code takes priority over goose", + env: map[string]string{"GOOSE_PROVIDER": "anthropic", "CLAUDECODE": "1"}, + wantAgent: "claude-code", + }, + { + name: "kiro takes priority over goose", + env: map[string]string{"GOOSE_PROVIDER": "anthropic", "TERM_PROGRAM": "kiro"}, + wantAgent: "kiro", + }, + { + name: "CLAUDE_CODE_IS_COWORK detected as cowork", + env: map[string]string{"CLAUDE_CODE_IS_COWORK": "1"}, + wantAgent: "cowork", + }, + { + name: "cowork takes priority over CLAUDECODE", + env: map[string]string{"CLAUDE_CODE_IS_COWORK": "1", "CLAUDECODE": "1"}, + wantAgent: "cowork", + }, + { + name: "CLAUDE_CODE", + env: map[string]string{"CLAUDE_CODE": "1"}, + wantAgent: "claude-code", + }, + { + name: "CURSOR_TRACE_ID detected as cursor", + env: map[string]string{"CURSOR_TRACE_ID": "abc"}, + wantAgent: "cursor", + }, + { + name: "CURSOR_AGENT detected as cursor-cli", + env: map[string]string{"CURSOR_AGENT": "1"}, + wantAgent: "cursor-cli", + }, + { + name: "CURSOR_EXTENSION_HOST_ROLE agent-exec detected as cursor-cli", + env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "agent-exec"}, + wantAgent: "cursor-cli", + }, + { + name: "CURSOR_EXTENSION_HOST_ROLE with other value is ignored", + env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "worker"}, + wantAgent: "", + }, + { + name: "CURSOR_TRACE_ID takes priority over CURSOR_AGENT", + env: map[string]string{"CURSOR_TRACE_ID": "abc", "CURSOR_AGENT": "1"}, + wantAgent: "cursor", + }, + { + name: "TERM_PROGRAM kiro detected as kiro", + env: map[string]string{"TERM_PROGRAM": "kiro"}, + wantAgent: "kiro", + }, + { + name: "TERM_PROGRAM with kiro as a substring is ignored", + env: map[string]string{"TERM_PROGRAM": "kirostudio"}, + wantAgent: "", + }, + { + name: "PATH containing .pi/agent detected as pi", + env: map[string]string{"PATH": "/usr/bin:/home/user/.pi/agent/bin"}, + wantAgent: "pi", + }, + { + name: "PATH with .pi/agent not on a path boundary is ignored", + env: map[string]string{"PATH": "/usr/bin:/home/user/x.pi/agent"}, + wantAgent: "", + }, + { + name: "PATH with Windows .pi\\agent separators detected as pi", + env: map[string]string{"PATH": `C:\Windows;C:\Users\user\.pi\agent\bin`}, + wantAgent: "pi", + }, } for _, tt := range tests { diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go index 30ec22551b1..fcc1995138b 100644 --- a/internal/prompter/huh_prompter_test.go +++ b/internal/prompter/huh_prompter_test.go @@ -2,6 +2,7 @@ package prompter import ( "io" + "sync" "testing" "time" @@ -18,8 +19,9 @@ import ( // bubbletea event loop. type interactionStep struct { - bytes []byte - delay time.Duration // pause before sending (lets the event loop settle) + bytes []byte + delay time.Duration // pause before sending (lets the event loop settle) + waitFn func() // if non-nil, called instead of time.Sleep(delay) } type interaction struct { @@ -33,9 +35,15 @@ func newInteraction(steps ...interactionStep) interaction { func (ix interaction) run(t *testing.T, w *io.PipeWriter) { t.Helper() for _, s := range ix.steps { - time.Sleep(s.delay) - _, err := w.Write(s.bytes) - require.NoError(t, err) + if s.waitFn != nil { + s.waitFn() + } else { + time.Sleep(s.delay) + } + if s.bytes != nil { + _, err := w.Write(s.bytes) + require.NoError(t, err) + } } } @@ -98,11 +106,45 @@ func clearLine() interactionStep { return interactionStep{bytes: []byte{0x01, 0x0b}} } -// waitForOptions adds extra delay to let OptionsFunc load before continuing. +// waitForOptions adds extra delay to let the bubbletea event loop settle +// after switching modes when no async search is triggered. func waitForOptions() interactionStep { return interactionStep{bytes: nil, delay: 50 * time.Millisecond} } +// waitForSearch returns an interactionStep that blocks until the field's +// current async search completes. It wires a one-shot callback into the +// field's onSearchDone hook and waits for it to fire, avoiding fixed-duration +// sleeps that are too short on slow architectures such as s390x under QEMU. +// +// The wait is bounded by the test's deadline (from -timeout) so a hung search +// fails the test with a clear message rather than blocking the whole test run. +func waitForSearch(t *testing.T, field *multiSelectSearchField) interactionStep { + t.Helper() + done := make(chan struct{}) + var once sync.Once + field.onSearchDone.Store(func() { + once.Do(func() { close(done) }) + }) + + var timeout <-chan time.Time + if deadline, ok := t.Deadline(); ok { + timeout = time.After(time.Until(deadline)) + } else { + timeout = time.After(30 * time.Second) + } + + return interactionStep{ + waitFn: func() { + select { + case <-done: + case <-timeout: + t.Fatal("timed out waiting for async search to complete") + } + }, + } +} + // --- Test harness --- func newTestHuhPrompter() *huhPrompter { @@ -475,13 +517,16 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { f, result := p.buildMultiSelectWithSearchForm( "Select", "Search", nil, nil, staticSearchFunc, ) + // waitForSearch must be created before runForm so the hook is in place + // before the async search fires. + searchDone := waitForSearch(t, result) runForm(t, f, newInteraction( - tab(), waitForOptions(), - toggle(), // toggle result-a - shiftTab(), // back to search input - typeKeys("foo"), // change query - tab(), waitForOptions(), - enter(), // submit — result-a should persist + tab(), waitForOptions(), // switch to select mode (no async search) + toggle(), // toggle result-a + shiftTab(), // back to search input + typeKeys("foo"), // change query + tab(), searchDone, // submit query → async search; wait for completion + enter(), // submit form — guaranteed search is done )) assert.Equal(t, []string{"result-a"}, result.selectedKeys()) }) diff --git a/internal/prompter/multi_select_with_search.go b/internal/prompter/multi_select_with_search.go index 5eeb34d4507..33107b5615f 100644 --- a/internal/prompter/multi_select_with_search.go +++ b/internal/prompter/multi_select_with_search.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "strings" + "sync/atomic" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" @@ -47,6 +48,13 @@ type multiSelectSearchField struct { theme huh.Theme hasDarkBg bool position huh.FieldPosition + + // onSearchDone stores a func() that is called each time an async search + // completes. It is unset in production and used only in tests to + // synchronize on search completion without relying on fixed-duration + // sleeps. atomic.Value is used because the hook is written by the test + // goroutine and invoked by bubbletea's event-loop goroutine. + onSearchDone atomic.Value } type msMode int @@ -170,6 +178,10 @@ func (m *multiSelectSearchField) applySearchResult(query string, result MultiSel m.options = options m.cursor = 0 m.err = nil + + if hook, ok := m.onSearchDone.Load().(func()); ok { + hook() + } } func (m *multiSelectSearchField) selectedKeys() []string {