fix(agents): detect all user-installed CLIs by hydrating PATH from login shell (#843)

* fix(agents): detect opencode/pi CLIs installed under ~/.opencode/bin and ~/.vite-plus/bin

Packaged Electron inherits a minimal PATH without shell rc files, so
agent install-script fallback dirs stay invisible to `which` probes —
the Agents settings page then shows OpenCode/Pi as "Not installed"
even when the user can run them from Terminal.

Add ~/bin, ~/.opencode/bin, and ~/.vite-plus/bin to the packaged PATH
augmentation so preflight detection matches shell behavior.

Fixes #829

* feat(agents): hydrate PATH from user's login shell + Agents Refresh button

Packaged Electron inherits a minimal launchd PATH that misses whatever
the user's shell rc files append — ~/.opencode/bin, ~/.cargo/bin, nvm
shims, pyenv, custom tool dirs. The preceding commit hardcoded two known
install locations; this replaces that whack-a-mole pattern with a
generic approach.

On packaged startup (non-Windows), spawn `${SHELL} -ilc 'echo $PATH'`
with a 5s timeout, parse the delimited PATH, and prepend any new
segments to process.env.PATH. The result is cached for the app session
so we pay the shell-init cost at most once.

Surface a Refresh button in Settings > Agents that forces a re-probe
and re-detects installed agents — handy right after installing a new
CLI, no restart needed.

Live-verified that a `zsh -ilc` spawn with a minimal launchd-style env
still resolves the user's full PATH (32+ segments including the
rc-appended dirs).

* refactor(hydrate-shell-path): simplify dedup with Set + Set.difference

Set preserves insertion order, so PATH first-match-wins semantics are
preserved without manual tracking. Set.prototype.difference (Node 22+)
expresses the new-segments calculation in mergePathSegments as the
set-difference operation it always was.
This commit is contained in:
Neil 2026-04-19 14:36:30 -07:00 committed by GitHub
parent 660b5f4149
commit 9d56108e89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 555 additions and 10 deletions

View file

@ -27,6 +27,7 @@ import {
installUncaughtPipeErrorGuard,
patchPackagedProcessPath
} from './startup/configure-process'
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
import { RateLimitService } from './rate-limits/service'
import { attachMainWindowServices } from './window/attach-main-window-services'
import { createMainWindow } from './window/createMainWindow'
@ -53,6 +54,21 @@ let starNag: StarNagService | null = null
installUncaughtPipeErrorGuard()
patchPackagedProcessPath()
// Why: patchPackagedProcessPath seeds a minimal list of well-known system
// dirs synchronously so early IPC (e.g. preflight before the shell spawn
// completes) doesn't miss homebrew/nix. Kick off the login-shell probe in
// parallel for packaged runs — when it resolves, its PATH is prepended and
// detectInstalledAgents picks up whatever the user's rc files put on PATH
// (cargo/pyenv/volta/custom tool install dirs) without hardcoding each one.
// Dev runs already inherit a complete PATH from the launching terminal, so
// the spawn cost is only paid where it's needed.
if (app.isPackaged && process.platform !== 'win32') {
void hydrateShellPath().then((result) => {
if (result.ok) {
mergePathSegments(result.segments)
}
})
}
configureDevUserDataPath(is.dev)
installDevParentDisconnectQuit(is.dev)
installDevParentWatchdog(is.dev)

View file

@ -1,10 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, execFileMock, execFileAsyncMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
execFileMock: vi.fn(),
execFileAsyncMock: vi.fn()
}))
const { handleMock, execFileMock, execFileAsyncMock, hydrateShellPathMock, mergePathSegmentsMock } =
vi.hoisted(() => ({
handleMock: vi.fn(),
execFileMock: vi.fn(),
execFileAsyncMock: vi.fn(),
hydrateShellPathMock: vi.fn(),
mergePathSegmentsMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
@ -17,10 +20,16 @@ vi.mock('child_process', () => {
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
})
return {
execFile: execFileWithPromisify
execFile: execFileWithPromisify,
spawn: vi.fn()
}
})
vi.mock('../startup/hydrate-shell-path', () => ({
hydrateShellPath: hydrateShellPathMock,
mergePathSegments: mergePathSegmentsMock
}))
import {
_resetPreflightCache,
detectInstalledAgents,
@ -36,6 +45,8 @@ describe('preflight', () => {
beforeEach(() => {
handleMock.mockReset()
execFileAsyncMock.mockReset()
hydrateShellPathMock.mockReset()
mergePathSegmentsMock.mockReset()
_resetPreflightCache()
for (const key of Object.keys(handlers)) {
@ -180,4 +191,67 @@ describe('preflight', () => {
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor'])
})
it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => {
// Why: the Agents settings Refresh button calls this path. It must (1) ask
// the shell hydrator for a fresh PATH, (2) merge any new segments, then
// (3) re-run `which` so newly-installed CLIs appear without a restart.
hydrateShellPathMock.mockResolvedValueOnce({
segments: ['/Users/test/.opencode/bin'],
ok: true
})
mergePathSegmentsMock.mockReturnValueOnce(['/Users/test/.opencode/bin'])
execFileAsyncMock.mockImplementation(async (command, args) => {
if (command !== 'which') {
throw new Error(`unexpected command ${String(command)}`)
}
if (String(args[0]) === 'opencode') {
return { stdout: '/Users/test/.opencode/bin/opencode\n' }
}
throw new Error('not found')
})
registerPreflightHandlers()
const result = (await handlers['preflight:refreshAgents']()) as {
agents: string[]
addedPathSegments: string[]
shellHydrationOk: boolean
}
expect(result).toEqual({
agents: ['opencode'],
addedPathSegments: ['/Users/test/.opencode/bin'],
shellHydrationOk: true
})
expect(hydrateShellPathMock).toHaveBeenCalledWith({ force: true })
})
it('still re-detects when the shell spawn fails — relies on the existing PATH', async () => {
hydrateShellPathMock.mockResolvedValueOnce({ segments: [], ok: false })
execFileAsyncMock.mockImplementation(async (command, args) => {
if (command !== 'which') {
throw new Error(`unexpected command ${String(command)}`)
}
if (String(args[0]) === 'claude') {
return { stdout: '/Users/test/.local/bin/claude\n' }
}
throw new Error('not found')
})
registerPreflightHandlers()
const result = (await handlers['preflight:refreshAgents']()) as {
agents: string[]
addedPathSegments: string[]
shellHydrationOk: boolean
}
expect(result.shellHydrationOk).toBe(false)
expect(result.addedPathSegments).toEqual([])
expect(result.agents).toEqual(['claude'])
// Why: when hydration fails, we must not call merge — nothing to merge —
// otherwise we'd log a no-op "added 0 segments" event on every refresh.
expect(mergePathSegmentsMock).not.toHaveBeenCalled()
})
})

View file

@ -3,6 +3,7 @@ import { execFile } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
const execFileAsync = promisify(execFile)
@ -60,6 +61,32 @@ export async function detectInstalledAgents(): Promise<string[]> {
return checks.filter((c) => c.installed).map((c) => c.id)
}
export type RefreshAgentsResult = {
/** Agents detected after hydrating PATH from the user's login shell. */
agents: string[]
/** PATH segments that were added this refresh (empty if nothing new). */
addedPathSegments: string[]
/** True when the shell spawn succeeded. False = relied on existing PATH. */
shellHydrationOk: boolean
}
/**
* Re-spawn the user's login shell to refresh process.env.PATH, then re-run
* agent detection. Called by the Agents settings pane when the user clicks
* Refresh handles the "installed a new CLI, Orca doesn't see it yet" case
* without requiring an app restart.
*/
export async function refreshShellPathAndDetectAgents(): Promise<RefreshAgentsResult> {
const hydration = await hydrateShellPath({ force: true })
const added = hydration.ok ? mergePathSegments(hydration.segments) : []
const agents = await detectInstalledAgents()
return {
agents,
addedPathSegments: added,
shellHydrationOk: hydration.ok
}
}
async function isGhAuthenticated(): Promise<boolean> {
try {
await execFileAsync('gh', ['auth', 'status'], {
@ -110,4 +137,8 @@ export function registerPreflightHandlers(): void {
ipcMain.handle('preflight:detectAgents', async (): Promise<string[]> => {
return detectInstalledAgents()
})
ipcMain.handle('preflight:refreshAgents', async (): Promise<RefreshAgentsResult> => {
return refreshShellPathAndDetectAgents()
})
}

View file

@ -11,6 +11,7 @@ vi.mock('electron', () => {
}),
quit: vi.fn(),
exit: vi.fn(),
isPackaged: false,
commandLine: {
appendSwitch: vi.fn()
}
@ -23,6 +24,70 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('patchPackagedProcessPath', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const originalHome = process.env.HOME
const originalPath = process.env.PATH
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform
})
}
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
if (originalHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = originalHome
}
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('prepends agent-CLI install dirs (~/.opencode/bin, ~/.vite-plus/bin) for packaged darwin runs', async () => {
const { app } = await import('electron')
const { patchPackagedProcessPath } = await import('./configure-process')
setPlatform('darwin')
Object.defineProperty(app, 'isPackaged', { configurable: true, value: true })
process.env.HOME = '/Users/tester'
process.env.PATH = '/usr/bin:/bin'
patchPackagedProcessPath()
const segments = (process.env.PATH ?? '').split(':')
// Why: issue #829 — ~/.opencode/bin and ~/.vite-plus/bin are the documented
// fallback install locations for the opencode and Pi CLI install scripts.
// Without them on PATH, GUI-launched Orca reports both as "Not installed"
// even when `which` resolves them in the user's shell.
expect(segments).toContain('/Users/tester/.opencode/bin')
expect(segments).toContain('/Users/tester/.vite-plus/bin')
expect(segments).toContain('/Users/tester/bin')
})
it('leaves PATH untouched when the app is not packaged', async () => {
const { app } = await import('electron')
const { patchPackagedProcessPath } = await import('./configure-process')
setPlatform('darwin')
Object.defineProperty(app, 'isPackaged', { configurable: true, value: false })
process.env.HOME = '/Users/tester'
process.env.PATH = '/usr/bin:/bin'
patchPackagedProcessPath()
expect(process.env.PATH).toBe('/usr/bin:/bin')
})
})
describe('configureDevUserDataPath', () => {
it('uses an explicit dev userData override when provided', async () => {
const { app } = await import('electron')

View file

@ -52,7 +52,19 @@ export function patchPackagedProcessPath(): void {
]
if (home) {
extraPaths.push(join(home, '.local/bin'), join(home, '.nix-profile/bin'))
extraPaths.push(
join(home, 'bin'),
join(home, '.local/bin'),
join(home, '.nix-profile/bin'),
// Why: several agent CLIs ship install scripts that drop binaries into
// tool-specific ~/.<name>/bin directories (opencode's documented fallback,
// Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH
// without shell rc files, so these stay invisible to `which` probes — and
// the Agents settings page reports them as "Not installed" even when the
// user can run them from Terminal. See stablyai/orca#829.
join(home, '.opencode/bin'),
join(home, '.vite-plus/bin')
)
}
// Why: CLI tools installed via Node version managers (nvm, volta, asdf, fnm,

View file

@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
_resetHydrateShellPathCache,
hydrateShellPath,
mergePathSegments
} from './hydrate-shell-path'
describe('hydrateShellPath', () => {
const originalPath = process.env.PATH
beforeEach(() => {
_resetHydrateShellPathCache()
})
afterEach(() => {
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('invokes the provided shell with a custom spawner and returns its segments', async () => {
let capturedShell = ''
const result = await hydrateShellPath({
shellOverride: '/bin/zsh',
spawner: async (shell) => {
capturedShell = shell
return {
segments: ['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'],
ok: true
}
}
})
expect(capturedShell).toBe('/bin/zsh')
expect(result.ok).toBe(true)
expect(result.segments).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
})
it('caches the hydration result so repeated calls do not re-spawn', async () => {
let spawnCount = 0
const spawner = async (): Promise<{ segments: string[]; ok: boolean }> => {
spawnCount += 1
return { segments: ['/a'], ok: true }
}
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
expect(spawnCount).toBe(1)
})
it('re-spawns when force:true is passed — matches the Refresh button contract', async () => {
let spawnCount = 0
const spawner = async (): Promise<{ segments: string[]; ok: boolean }> => {
spawnCount += 1
return { segments: ['/a'], ok: true }
}
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner, force: true })
expect(spawnCount).toBe(2)
})
it('returns ok:false when no shell is available (Windows path)', async () => {
const result = await hydrateShellPath({
shellOverride: null,
spawner: async () => {
throw new Error('spawner must not run when shell is null')
}
})
expect(result).toEqual({ segments: [], ok: false })
})
})
describe('mergePathSegments', () => {
const originalPath = process.env.PATH
afterEach(() => {
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('prepends new segments ahead of existing PATH entries', () => {
process.env.PATH = '/usr/bin:/bin'
const added = mergePathSegments(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
expect(added).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
expect(process.env.PATH).toBe(
'/Users/tester/.opencode/bin:/Users/tester/.cargo/bin:/usr/bin:/bin'
)
})
it('skips segments already on PATH so re-hydration is a no-op', () => {
process.env.PATH = '/Users/tester/.cargo/bin:/usr/bin'
const added = mergePathSegments(['/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin'])
expect(added).toEqual(['/Users/tester/.opencode/bin'])
expect(process.env.PATH).toBe('/Users/tester/.opencode/bin:/Users/tester/.cargo/bin:/usr/bin')
})
it('returns [] and leaves PATH untouched when given nothing', () => {
process.env.PATH = '/usr/bin:/bin'
expect(mergePathSegments([])).toEqual([])
expect(process.env.PATH).toBe('/usr/bin:/bin')
})
})

View file

@ -0,0 +1,186 @@
import { spawn } from 'child_process'
import { delimiter } from 'path'
// Why: GUI-launched Electron on macOS/Linux inherits a minimal PATH from launchd
// that does not include dirs appended by the user's shell rc files (~/.zshrc,
// ~/.bashrc). Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta
// shims, and countless other user-local locations end up invisible to our
// `which` probe even though they work fine from Terminal (see stablyai/orca#829).
//
// Rather than play whack-a-mole adding every agent's install dir to a hardcoded
// list, we spawn the user's login shell once per app session and read the PATH
// it would export. This matches the behavior of every popular Electron app that
// handles this problem (Hyper, VS Code, Cursor, etc. via shell-env/fix-path) —
// we implement it inline to avoid adding a dependency.
const DELIMITER = '__ORCA_SHELL_PATH__'
const SPAWN_TIMEOUT_MS = 5000
// ANSI escape sequences can leak into the captured output when the user's rc
// files print banners or set colored prompts. Strip them before parsing.
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g // eslint-disable-line no-control-regex
type HydrationResult = {
/** PATH segments extracted from the login shell, in order, de-duplicated. */
segments: string[]
/** True when the shell spawn succeeded and returned a non-empty PATH. */
ok: boolean
}
let cached: Promise<HydrationResult> | null = null
/** @internal - tests need a clean hydration cache between cases. */
export function _resetHydrateShellPathCache(): void {
cached = null
}
function pickShell(): string | null {
if (process.platform === 'win32') {
return null
}
const shell = process.env.SHELL
if (shell && shell.length > 0) {
return shell
}
return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'
}
function parseCapturedPath(stdout: string): string[] {
const cleaned = stdout.replace(ANSI_RE, '')
const first = cleaned.indexOf(DELIMITER)
if (first < 0) {
return []
}
const second = cleaned.indexOf(DELIMITER, first + DELIMITER.length)
if (second < 0) {
return []
}
const value = cleaned.slice(first + DELIMITER.length, second).trim()
if (!value) {
return []
}
// Why: Set preserves insertion order, and PATH resolution is first-match-wins,
// so de-duping this way keeps the user's rc-file ordering intact.
return [
...new Set(
value
.split(delimiter)
.map((s) => s.trim())
.filter(Boolean)
)
]
}
function spawnShellAndReadPath(shell: string): Promise<HydrationResult> {
return new Promise((resolve) => {
// Why: printing $PATH between delimiters is resilient to rc-file banners,
// MOTDs, and `echo` invocations that shells like fish print unprompted.
// `-ilc` runs the shell as a login+interactive so both .profile/.zprofile
// and .bashrc/.zshrc are sourced — matches what `which` in Terminal sees.
const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'`
let finished = false
let stdout = ''
const child = spawn(shell, ['-ilc', command], {
// Why: inherit current env so the shell sees the same baseline, then let
// it layer its own rc files on top. Do NOT forward stdio — some shells
// (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup,
// and we don't want that in Orca's console.
env: process.env,
stdio: ['ignore', 'pipe', 'ignore'],
detached: false
})
const timer = setTimeout(() => {
if (finished) {
return
}
finished = true
// Why: slow rc files (corporate env setup, nvm eager init) can exceed
// our budget. Kill the shell and fall back to process.env rather than
// blocking the Agents pane indefinitely.
try {
child.kill('SIGKILL')
} catch {
// ignore
}
resolve({ segments: [], ok: false })
}, SPAWN_TIMEOUT_MS)
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
})
child.on('error', () => {
if (finished) {
return
}
finished = true
clearTimeout(timer)
resolve({ segments: [], ok: false })
})
child.on('close', () => {
if (finished) {
return
}
finished = true
clearTimeout(timer)
const segments = parseCapturedPath(stdout)
resolve({ segments, ok: segments.length > 0 })
})
})
}
type HydrateOptions = {
force?: boolean
/** Override for tests — defaults to running `spawn` against the real shell. */
spawner?: (shell: string) => Promise<HydrationResult>
/** Override for tests — defaults to `pickShell()`. */
shellOverride?: string | null
}
/**
* Spawn the user's login shell once and return the PATH it would export.
* Caches the promise for the lifetime of the process call
* `_resetHydrateShellPathCache()` in tests or `hydrateShellPath({ force: true })`
* when the user asks to re-probe (e.g. after installing a new CLI).
*/
export function hydrateShellPath(options: HydrateOptions = {}): Promise<HydrationResult> {
if (cached && !options.force) {
return cached
}
const shell = options.shellOverride !== undefined ? options.shellOverride : pickShell()
if (!shell) {
// Windows uses cmd/PowerShell rather than a POSIX login shell — the
// `patchPackagedProcessPath` static list is sufficient there.
cached = Promise.resolve({ segments: [], ok: false })
return cached
}
cached = (options.spawner ?? spawnShellAndReadPath)(shell)
return cached
}
/**
* Prepend newly-discovered PATH segments to process.env.PATH, preserving
* existing ordering and avoiding duplicates. Returns the segments that were
* actually added so callers can log/telemetry on nontrivial hydrations.
*/
export function mergePathSegments(segments: string[]): string[] {
if (segments.length === 0) {
return []
}
const current = process.env.PATH ?? ''
const existing = new Set(current.split(delimiter).filter(Boolean))
// Why: Node 22+ Set.prototype.difference preserves insertion order of the
// receiver, so [...incoming.difference(existing)] gives us the new entries
// in the order the shell provided them (first-match-wins on PATH).
const added = [...new Set(segments).difference(existing)]
if (added.length === 0) {
return []
}
// Why: prepend so shell-provided entries win over the hardcoded fallbacks.
// The user's rc files are the source of truth for `which`-style resolution.
process.env.PATH = [...added, ...current.split(delimiter).filter(Boolean)].join(delimiter)
return added
}

View file

@ -159,9 +159,16 @@ export type PreflightStatus = {
gh: { installed: boolean; authenticated: boolean }
}
export type RefreshAgentsResult = {
agents: string[]
addedPathSegments: string[]
shellHydrationOk: boolean
}
export type PreflightApi = {
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
detectAgents: () => Promise<string[]>
refreshAgents: () => Promise<RefreshAgentsResult>
}
export type StatsApi = {

View file

@ -460,7 +460,12 @@ const api = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
}> => ipcRenderer.invoke('preflight:check', args),
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents')
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents'),
refreshAgents: (): Promise<{
agents: string[]
addedPathSegments: string[]
shellHydrationOk: boolean
}> => ipcRenderer.invoke('preflight:refreshAgents')
},
notifications: {

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Terminal } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, Terminal } from 'lucide-react'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { Button } from '../ui/button'
@ -205,6 +205,7 @@ function AgentRow({
export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React.JSX.Element {
const [detectedIds, setDetectedIds] = useState<Set<string> | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
useEffect(() => {
void window.api.preflight.detectAgents().then((ids) => {
@ -212,6 +213,22 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
})
}, [])
const handleRefresh = useCallback(async (): Promise<void> => {
// Why: refresh re-spawns the user's login shell to re-capture PATH
// (preflight:refreshAgents on the main side). This handles the
// "installed a new CLI, Orca doesn't see it yet" case without a restart.
if (isRefreshing) {
return
}
setIsRefreshing(true)
try {
const result = await window.api.preflight.refreshAgents()
setDetectedIds(new Set(result.agents))
} finally {
setIsRefreshing(false)
}
}, [isRefreshing])
const defaultAgent = settings.defaultTuiAgent
const cmdOverrides = settings.agentCmdOverrides ?? {}
@ -296,6 +313,21 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
{detectedAgents.length} detected
</span>
<button
type="button"
onClick={() => void handleRefresh()}
disabled={isRefreshing}
title="Re-read your shell PATH and re-detect installed agents"
className={cn(
'ml-auto flex items-center gap-1.5 rounded-lg px-2 py-1 text-[11px] font-medium transition-colors',
isRefreshing
? 'text-muted-foreground/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
)}
>
<RefreshCw className={cn('size-3', isRefreshing && 'animate-spin')} />
{isRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
</div>
<div className="space-y-2">