From 764cadafc7daa82f68aca2aac71b70f349be4203 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:20:54 -0700 Subject: [PATCH 01/22] fix(terminal): confirm the agent from ConPTY console presence to avoid false exits (#9258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): confirm the agent from ConPTY console presence to avoid false exits On Windows the foreground scan is a whole-process-table PowerShell fork that, under load, exceeds its timeout or returns an incomplete snapshot — the completion coordinator then reads the shell as the foreground and fires a false "agent done" while the agent is still working. While a recognized agent is still active, confirm it with a cheap ConPTY console-membership read instead: a child process still attached to the console means the agent is working, so keep it without the whole-table scan. Fall through to the authoritative scan only when the console is shell-only (the agent likely exited). No-op off Windows, and never worse than the existing degraded-scan fallback. * fix(terminal): apply the ConPTY console-presence check on the daemon foreground path Windows PTYs are hosted by the terminal daemon, whose foreground-identity refresh (not the local provider) is what runs by default — it retired the cached agent on a timed-out or incomplete CIM scan, so foreground reads fell back to the shell and fired a false "agent done" while the agent was working. Mirror the local-provider fix on the daemon path: a degraded scan (available:false) no longer retires the identity; an authoritative scan that resolves no agent is confirmed against a ConPTY console-membership read before retiring (an incomplete snapshot with a child still attached keeps the agent); and the sync foreground read serves the cached agent across a shell fallback on Windows (an unreliable exit signal under load) until the background refresh authoritatively retires it. The membership read stays off the sync path. * fix(terminal): exclude the console-list helper's own process from ConPTY membership The console-membership helper attaches to the console to read it, so GetConsoleProcessList counts the helper's own forked process. A bare shell therefore read as [helper, shell] and looked like it still had a child, so a genuine shell-only console (an exited agent) was never detected — the foreground refresh held the exited agent's identity indefinitely. Drop the helper's own pid before judging membership; a remaining set of only the shell (or the AttachConsole-failure fallback) is not child proof. Fixes real-exit detection on both the daemon and local foreground paths. * fix(terminal): require conclusive ConPTY exit evidence * fix(terminal): absorb delayed ConPTY helper errors --- ...ubprocess-foreground-degraded-scan.test.ts | 167 ++++++++++++++++++ src/main/daemon/pty-subprocess.test.ts | 8 + src/main/daemon/pty-subprocess.ts | 73 +++++--- src/main/providers/local-pty-provider.test.ts | 70 ++++++++ src/main/providers/local-pty-provider.ts | 40 ++++- .../windows-conpty-process-membership.test.ts | 52 +++++- .../windows-conpty-process-membership.ts | 24 ++- .../windows-console-foreground.test.ts | 26 +++ .../providers/windows-console-foreground.ts | 15 ++ 9 files changed, 433 insertions(+), 42 deletions(-) create mode 100644 src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts create mode 100644 src/main/providers/windows-console-foreground.test.ts create mode 100644 src/main/providers/windows-console-foreground.ts diff --git a/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts new file mode 100644 index 00000000000..31b7f5562ca --- /dev/null +++ b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts @@ -0,0 +1,167 @@ +// Guards the daemon foreground identity against Windows scan degradation: a +// timed-out CIM scan (available:false) or an incomplete snapshot (available:true +// with the agent row missing) must not retire a still-working agent and make +// the coordinator read the shell as a false "agent done". Console presence is +// the arbiter of a real exit. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock, readConptyMock } = + vi.hoisted(() => ({ + spawnMock: vi.fn(), + isPwshAvailableMock: vi.fn(), + resolveAgentForegroundProcessMock: vi.fn(), + readConptyMock: vi.fn() + })) + +vi.mock('node-pty', () => ({ spawn: spawnMock })) +vi.mock('../pwsh', () => ({ isPwshAvailable: isPwshAvailableMock })) + +const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' +const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' +const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' +vi.mock('../providers/windows-powershell-executable', () => ({ + resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => + family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, + resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => + family === 'pwsh.exe' + ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] + : [WINDOWS_POWERSHELL_ABS, CMD_ABS], + getWindowsCmdPath: () => CMD_ABS +})) + +vi.mock('../providers/agent-foreground-process', () => ({ + resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) => + resolveAgentForegroundProcessMock(...args) +})) + +vi.mock('../providers/windows-conpty-process-membership', () => ({ + readWindowsConptyProcessIds: (...args: unknown[]) => readConptyMock(...args) +})) + +import { createPtySubprocess } from './pty-subprocess' + +const BASE_TIME_MS = 1_000_000 + +function mockPtyProcess(processName: string, pid = 12345) { + return { + pid, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + process: processName, + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })) + } +} + +async function flushAsyncTicks(count = 12): Promise { + for (let i = 0; i < count; i++) { + await Promise.resolve() + } +} + +async function readForegroundAt( + handle: { getForegroundProcess: () => string | null }, + atMs: number +): Promise { + vi.setSystemTime(BASE_TIME_MS + atMs) + const foreground = handle.getForegroundProcess() + await flushAsyncTicks() + return foreground +} + +describe('daemon pty foreground degraded-scan handling', () => { + let platform: PropertyDescriptor | undefined + let previousUserDataPath: string | undefined + let userDataPath: string + + beforeEach(() => { + spawnMock.mockReset() + isPwshAvailableMock.mockReset() + isPwshAvailableMock.mockReturnValue(false) + resolveAgentForegroundProcessMock.mockReset() + readConptyMock.mockReset() + readConptyMock.mockResolvedValue(null) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-degraded-scan-test-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + platform = Object.getOwnPropertyDescriptor(process, 'platform') + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(BASE_TIME_MS) + }) + + afterEach(() => { + vi.useRealTimers() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function spawnWindowsShell() { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const proc = mockPtyProcess('powershell.exe') + spawnMock.mockReturnValue(proc) + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + return { proc, handle } + } + + it('keeps a cached agent across a degraded (timed-out) scan', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: false, processName: null }) + const { handle } = spawnWindowsShell() + + await readForegroundAt(handle, 0) // establishes 'claude' + expect(await readForegroundAt(handle, 1_000)).toBe('claude') // refresh returns degraded → keep + // Past the 1s TTL with a shell fallback: pre-fix this returned the shell. + expect(await readForegroundAt(handle, 2_500)).toBe('claude') + expect(readConptyMock).not.toHaveBeenCalled() + }) + + it('keeps a cached agent when a scan finds no agent but the console still has a child', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockResolvedValue(new Set([12345, 999])) // child still attached + const { handle } = spawnWindowsShell() + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 1_000)).toBe('claude') + expect(await readForegroundAt(handle, 2_500)).toBe('claude') + }) + + it('retires a cached agent when a scan finds no agent and the console is shell-only', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockResolvedValue(new Set([12345])) + const { handle } = spawnWindowsShell() + + await readForegroundAt(handle, 0) + await readForegroundAt(handle, 1_000) // refresh clears the cache + expect(await readForegroundAt(handle, 1_100)).toBe('powershell.exe') + expect(readConptyMock).toHaveBeenCalledTimes(1) + }) + + it('keeps a cached agent when the console-membership probe is unavailable', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockResolvedValue(null) + const { handle } = spawnWindowsShell() + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 1_000)).toBe('claude') + expect(await readForegroundAt(handle, 2_500)).toBe('claude') + expect(readConptyMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 62011ccd183..30f45915a82 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -67,6 +67,14 @@ vi.mock('../providers/agent-foreground-process', () => ({ } })) +// Console-membership reads run a real node-pty fork that never settles under +// fake timers; default to "shell-only" so the degraded-scan guard falls through +// to its existing retirement logic (the degraded-scan behavior itself is +// covered in pty-subprocess-foreground-degraded-scan.test.ts). +vi.mock('../providers/windows-conpty-process-membership', () => ({ + readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +})) + import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess' import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard' diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 8e1f7024891..2ab88945689 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -983,38 +983,59 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // Why: daemon foreground reads are sync and run on the IPC hot path. // Refresh derived identities (shell/wrapper/helper -> codex/claude/etc.) // in the background and serve them from a short cache on later reads. + // Why: Windows may need an async membership check before this shared + // shell/wrapper retirement policy is safe to apply. + const retireStaleForegroundIdentity = (): void => { + const currentFallbackProcess = getFallbackForegroundProcess() + if ( + fallbackIsShell && + !getActiveStartupAgentForeground() && + currentFallbackProcess !== null && + isShellProcess(currentFallbackProcess) + ) { + cachedAgentForeground = null + startupAgentForeground = null + } else if ( + cachedAgentForeground !== null && + Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && + currentFallbackProcess !== null && + isAgentForegroundWrapperProcess(currentFallbackProcess) + ) { + // Why: the wrapper's tree no longer resolves to an agent — an expired + // identity must not transfer to an unrelated wrapper (e.g. npm right + // after an agent exit). Fresh identities survive one-off scan hiccups. + cachedAgentForeground = null + } + } void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { contextPaths: agentForegroundContextPaths }) - .then(({ processName }) => { + .then(({ processName, available }) => { if (dead) { return } + // Why: a degraded scan is not exit evidence; retiring here fires false + // completion while an agent is still working under CIM load. + if (!available) { + return + } if (!processName || !recognizeAgentProcess(processName)) { - const currentFallbackProcess = getFallbackForegroundProcess() - if ( - fallbackIsShell && - !getActiveStartupAgentForeground() && - currentFallbackProcess !== null && - isShellProcess(currentFallbackProcess) - ) { - cachedAgentForeground = null - startupAgentForeground = null - } else if ( - cachedAgentForeground !== null && - Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && - currentFallbackProcess !== null && - isAgentForegroundWrapperProcess(currentFallbackProcess) - ) { - // Why: the wrapper's tree no longer resolves to an agent — an expired - // identity must not transfer to an unrelated wrapper (e.g. npm right - // after an agent exit). Fresh identities survive one-off scan hiccups. - cachedAgentForeground = null + // Why: a Windows snapshot can omit a live agent; only verified + // shell-only membership may retire its cached identity. + if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { + return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => { + if (dead || consoleProcessIds === null || consoleProcessIds.size > 1) { + return + } + retireStaleForegroundIdentity() + }) } + retireStaleForegroundIdentity() return } cachedAgentForeground = { processName, refreshedAt: Date.now() } startupAgentForeground = null + return processName }) .catch(() => { // Best-effort only: foreground enrichment must never affect PTY health. @@ -1069,14 +1090,18 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return cachedAgentForeground.processName } // Why: a wrapper foreground (node/python) can never identify itself, and - // readers poll slower than the cache TTL — returning the raw wrapper here + // readers poll slower than the cache TTL — returning the raw fallback here // would hide the resolved identity forever. Serve the last resolved agent - // while the scheduled refresh revalidates; exit truth is safe because an - // exited agent's foreground falls back to the shell, not a wrapper. + // while the scheduled refresh revalidates. On Windows a shell fallback is + // also an unreliable exit signal (ConPTY lag under load surfaces the shell + // while the agent is alive), so trust the cache there too; the background + // refresh retires the identity only after a console-presence read confirms + // the agent left the console. if ( cachedAgentForeground && fallbackProcess !== null && - isAgentForegroundWrapperProcess(fallbackProcess) + (isAgentForegroundWrapperProcess(fallbackProcess) || + (process.platform === 'win32' && isShellProcess(fallbackProcess))) ) { return cachedAgentForeground.processName } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 0e4a828817e..4e2b189deaa 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -12,6 +12,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, + readWindowsConptyProcessIdsMock, captureDescendantSnapshotMock, terminateDescendantSnapshotMock } = vi.hoisted(() => ({ @@ -23,6 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), + readWindowsConptyProcessIdsMock: vi.fn(), captureDescendantSnapshotMock: vi.fn(), terminateDescendantSnapshotMock: vi.fn() })) @@ -79,6 +81,10 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) +vi.mock('./windows-conpty-process-membership', () => ({ + readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +})) + vi.mock('../wsl', () => ({ parseWslPath: (path: string) => { const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) @@ -156,6 +162,8 @@ describe('LocalPtyProvider', () => { processName: fallbackProcess }) ) + readWindowsConptyProcessIdsMock.mockReset() + readWindowsConptyProcessIdsMock.mockResolvedValue(null) exitCb = undefined mockProc = { @@ -1427,6 +1435,68 @@ describe('LocalPtyProvider', () => { await expect(foreground).resolves.toBeNull() }) + + it('confirms a still-active agent from ConPTY console presence without a whole-table scan', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockProc.process = 'powershell.exe' + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: true, + processName: 'claude' + }) + // A child beyond the shell is still attached to this console. + readWindowsConptyProcessIdsMock.mockResolvedValue(new Set([12345, 999])) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + // First call establishes the agent identity via the scan. + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + // node-pty still only names the shell, but console presence confirms the + // agent — no second whole-table scan. + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1) + }) + + it('falls through to the scan when the ConPTY console shows only the shell', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockProc.process = 'powershell.exe' + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: true, + processName: 'claude' + }) + readWindowsConptyProcessIdsMock.mockResolvedValue(new Set([12345])) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) + }) + + it('keeps the cached agent when both the console probe and process snapshot are inconclusive', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockProc.process = 'powershell.exe' + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readWindowsConptyProcessIdsMock.mockResolvedValue(null) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) + }) + + it('retires the cached agent after verified shell-only membership and a no-agent scan', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockProc.process = 'powershell.exe' + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readWindowsConptyProcessIdsMock.mockResolvedValue(new Set([12345])) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + await expect(provider.getForegroundProcess(id)).resolves.toBeNull() + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) + }) }) describe('confirmForegroundProcess', () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 292999c027e..fef99e7d4f8 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -61,6 +61,7 @@ import { terminateDescendantSnapshot } from '../pty-descendant-termination' import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' +import { canConfirmAgentFromConsolePresence } from './windows-console-foreground' import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' @@ -1234,10 +1235,35 @@ export class LocalPtyProvider implements IPtyProvider { ptyLastRecognizedForeground.delete(id) return null } + const fallbackProcess = resolveForegroundFallbackProcess( + proc.process || null, + ptyShellName.get(id) + ) + const cachedAgent = ptyLastRecognizedForeground.get(id) ?? null + let consoleMembershipUnavailable = false + // Why: exact console membership can preserve a live cached agent without + // trusting the whole-table scan that becomes incomplete under Windows load. + if ( + process.platform === 'win32' && + canConfirmAgentFromConsolePresence(cachedAgent, fallbackProcess) + ) { + try { + const consoleProcessIds = await readWindowsConptyProcessIds(proc.pid) + if (ptyProcesses.get(id) !== proc) { + return null + } + if (consoleProcessIds !== null && consoleProcessIds.size > 1 && cachedAgent !== null) { + return cachedAgent + } + consoleMembershipUnavailable = consoleProcessIds === null + } catch { + consoleMembershipUnavailable = true + } + } try { const resolution = await resolveAgentForegroundProcessWithAvailability( proc.pid, - resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)), + fallbackProcess, { contextPaths: ptyAgentForegroundContextPaths.get(id) } @@ -1251,9 +1277,17 @@ export class LocalPtyProvider implements IPtyProvider { // foreground — the completion coordinator reads that as an exit and fires // a false "agent done" while the agent is still working. Prefer the last // recognized agent across a transient failure (e.g. a Windows CIM timeout). + const lastRecognizedAgent = ptyLastRecognizedForeground.get(id) ?? null + const resolvedAgent = resolution.processName + ? recognizeAgentProcessFromCommandLine(resolution.processName) + : null + // Why: an incomplete global snapshot plus an unavailable console probe is + // not exit proof; only verified shell-only membership may clear the cache. const stable = resolveStableForegroundProcess( - resolution, - ptyLastRecognizedForeground.get(id) ?? null + consoleMembershipUnavailable && resolvedAgent === null + ? { ...resolution, available: false } + : resolution, + lastRecognizedAgent ) if (stable.lastRecognizedAgent) { ptyLastRecognizedForeground.set(id, stable.lastRecognizedAgent) diff --git a/src/main/providers/windows-conpty-process-membership.test.ts b/src/main/providers/windows-conpty-process-membership.test.ts index e9e2ef09fdd..9ae23b6f379 100644 --- a/src/main/providers/windows-conpty-process-membership.test.ts +++ b/src/main/providers/windows-conpty-process-membership.test.ts @@ -2,9 +2,15 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' -function forkWith(event: 'message' | 'error' | 'none', value?: unknown) { - const child = new EventEmitter() as EventEmitter & { kill: ReturnType } +function forkWith(event: 'message' | 'error' | 'none', value?: unknown, pid?: number) { + const child = new EventEmitter() as EventEmitter & { + kill: ReturnType + pid?: number + } child.kill = vi.fn() + if (pid !== undefined) { + child.pid = pid + } const forkProcess = vi.fn(() => { queueMicrotask(() => { if (event === 'message') { @@ -20,7 +26,7 @@ function forkWith(event: 'message' | 'error' | 'none', value?: unknown) { describe('readWindowsConptyProcessIds', () => { it('returns exact console membership from the fixed node-pty helper', async () => { - const { forkProcess } = forkWith('message', [101, 202, 303]) + const { forkProcess } = forkWith('message', [999, 101, 202, 303], 999) await expect( readWindowsConptyProcessIds(101, { @@ -36,14 +42,28 @@ describe('readWindowsConptyProcessIds', () => { }) it.each([ - ['root-only failure fallback', [101]], - ['malformed response', [101, '202']], - ['missing PTY root', [202, 303]] - ])('fails closed for %s', async (_label, processIds) => { - const { forkProcess } = forkWith('message', processIds) + ['root-only failure fallback', [101], 999], + ['malformed response', [999, 101, '202'], 999], + ['missing PTY root', [999, 202, 303], 999], + ['missing helper pid', [101, 202], 999], + ['unavailable helper pid', [101, 202], undefined] + ])('fails closed for %s', async (_label, processIds, helperPid) => { + const { forkProcess } = forkWith('message', processIds, helperPid) await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull() }) + it('returns root-only membership when only the helper and shell are attached', async () => { + const { forkProcess } = forkWith('message', [999, 101], 999) + await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toEqual(new Set([101])) + }) + + it('reports membership excluding the helper when a real child is attached', async () => { + const { forkProcess } = forkWith('message', [999, 101, 202], 999) + await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toEqual( + new Set([101, 202]) + ) + }) + it('handles helper spawn errors without an unhandled child error', async () => { const { forkProcess } = forkWith('error') await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull() @@ -61,4 +81,20 @@ describe('readWindowsConptyProcessIds', () => { vi.useRealTimers() } }) + + it('absorbs an asynchronous kill error after timeout settlement', async () => { + vi.useFakeTimers() + try { + const { child, forkProcess } = forkWith('none') + child.kill.mockImplementation(() => { + queueMicrotask(() => child.emit('error', new Error('kill failed'))) + }) + const result = readWindowsConptyProcessIds(101, { forkProcess, timeoutMs: 10 }) + await vi.advanceTimersByTimeAsync(10) + await expect(result).resolves.toBeNull() + expect(child.listenerCount('error')).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/providers/windows-conpty-process-membership.ts b/src/main/providers/windows-conpty-process-membership.ts index 759a234c63f..28e29ebbd97 100644 --- a/src/main/providers/windows-conpty-process-membership.ts +++ b/src/main/providers/windows-conpty-process-membership.ts @@ -15,8 +15,8 @@ function resolveNodePtyConsoleListAgent(): string { } /** - * Runs node-pty's fixed native console-list helper with bounded error/exit - * handling. A root-only result is its failure fallback, not shell proof. + * Returns normalized console membership, or null when the probe is unavailable. + * A root-only set proves the shell is alone because successful raw results include the helper. */ export function readWindowsConptyProcessIds( rootPid: number, @@ -45,23 +45,33 @@ export function readWindowsConptyProcessIds( settled = true clearTimeout(timeout) child.removeListener('message', onMessage) - child.removeListener('error', onFailure) - child.removeListener('exit', onFailure) + // Why: kill failures can emit asynchronously after timeout settlement; + // teardown listeners stay until exit so they cannot crash the daemon. resolve(value) } const onFailure = (): void => finish(null) + const onExit = (): void => { + child.removeListener('error', onFailure) + finish(null) + } const onMessage = (message: ProcessListMessage): void => { const value = message?.consoleProcessList + const helperPid = child.pid if ( !Array.isArray(value) || - value.length <= 1 || + helperPid === undefined || !value.includes(rootPid) || + !value.includes(helperPid) || value.some((pid) => !Number.isSafeInteger(pid) || pid <= 0) ) { finish(null) return } - finish(new Set(value)) + // Why: GetConsoleProcessList includes this helper; removing it makes a + // root-only set authoritative shell-only evidence instead of a false child. + const consoleProcessIds = new Set(value) + consoleProcessIds.delete(helperPid) + finish(consoleProcessIds) } const timeout = setTimeout(() => { child.kill() @@ -69,6 +79,6 @@ export function readWindowsConptyProcessIds( }, deps.timeoutMs ?? CONPTY_PROCESS_LIST_TIMEOUT_MS) child.once('message', onMessage) child.once('error', onFailure) - child.once('exit', onFailure) + child.once('exit', onExit) }) } diff --git a/src/main/providers/windows-console-foreground.test.ts b/src/main/providers/windows-console-foreground.test.ts new file mode 100644 index 00000000000..f13ef2ae556 --- /dev/null +++ b/src/main/providers/windows-console-foreground.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { canConfirmAgentFromConsolePresence } from './windows-console-foreground' + +describe('canConfirmAgentFromConsolePresence', () => { + it('is true for a cached agent when node-pty only names the shell (a scan would run)', () => { + expect(canConfirmAgentFromConsolePresence('claude', 'powershell.exe')).toBe(true) + expect(canConfirmAgentFromConsolePresence('codex', 'cmd.exe')).toBe(true) + }) + + it('is false when node-pty already names a recognized agent (no scan needed)', () => { + // Nothing to save here — the fast no-scan path already returns the agent. + expect(canConfirmAgentFromConsolePresence('claude', 'claude')).toBe(false) + }) + + it('is false for a generic wrapper that may outlive the cached agent', () => { + expect(canConfirmAgentFromConsolePresence('claude', 'node.exe')).toBe(false) + }) + + it('is false when no agent has been recognized yet (identity must be established first)', () => { + expect(canConfirmAgentFromConsolePresence(null, 'powershell.exe')).toBe(false) + }) + + it('is false when there is no fallback process name', () => { + expect(canConfirmAgentFromConsolePresence('claude', null)).toBe(false) + }) +}) diff --git a/src/main/providers/windows-console-foreground.ts b/src/main/providers/windows-console-foreground.ts new file mode 100644 index 00000000000..77b5bfe4f6d --- /dev/null +++ b/src/main/providers/windows-console-foreground.ts @@ -0,0 +1,15 @@ +import { isShellProcess } from '../../shared/shell-process-detection' + +/** Whether ConPTY membership can revalidate a cached agent without a process scan. */ +export function canConfirmAgentFromConsolePresence( + cachedAgentName: string | null, + fallbackProcess: string | null +): boolean { + return ( + cachedAgentName !== null && + fallbackProcess !== null && + // Why: a generic wrapper may outlive the agent; only the shell fallback is + // the known unreliable Windows exit signal this cache is allowed to bridge. + isShellProcess(fallbackProcess) + ) +} From 624c8d4120cd355a336a02048ae5ce6fbe0b2d16 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:21:06 -0700 Subject: [PATCH 02/22] Make plain-text file:// links clickable in the terminal (#9467) * Make plain-text file:// links clickable in the terminal Printed file:// URIs (e.g. a report path echoed by a tool or agent) were neither http links nor bare filesystem paths, so the terminal's URL and local-path detectors both skipped them and the link was dead. Orca already resolves and opens file:// URIs for OSC 8 hyperlinks. Reuse that exact resolver for plain-text URIs so a printed file:// behaves the same whether or not the emitter wrapped it in an escape sequence: - Promote the (dependency-pure) file-url target resolver into src/shared so the OSC path and the new plain-text path share one implementation. - Add a file:// detector that decodes the URI to a filesystem path and routes it through the existing file-link pipeline (existence probe + openDetectedFilePath), so line/col anchors, %20, Windows drive paths, html-in-browser, editor reveal, and SSH/runtime resolution all just work. Lines without file:// are unchanged: the pass short-circuits to the prior result, so only file://-bearing lines gain a link. - Add unit + integration coverage for detection, decoding, and no-double-link. * Harden plain-text file URI detection * Split terminal file link detection modules --- config/max-lines-baseline.txt | 1 - .../terminal-osc-link-routing.ts | 2 +- .../lib/terminal-bare-file-link-detection.ts | 68 +++++ .../terminal-file-link-detection-ranges.ts | 126 ++++++++ .../src/lib/terminal-file-uri-link.test.ts | 67 +++++ .../src/lib/terminal-file-uri-link.ts | 95 ++++++ src/renderer/src/lib/terminal-links.test.ts | 28 ++ src/renderer/src/lib/terminal-links.ts | 277 ++++-------------- .../terminal-file-url-target.test.ts | 0 .../terminal-file-url-target.ts | 2 +- 10 files changed, 438 insertions(+), 228 deletions(-) create mode 100644 src/renderer/src/lib/terminal-bare-file-link-detection.ts create mode 100644 src/renderer/src/lib/terminal-file-link-detection-ranges.ts create mode 100644 src/renderer/src/lib/terminal-file-uri-link.test.ts create mode 100644 src/renderer/src/lib/terminal-file-uri-link.ts rename src/{renderer/src/components/terminal-pane => shared}/terminal-file-url-target.test.ts (100%) rename src/{renderer/src/components/terminal-pane => shared}/terminal-file-url-target.ts (95%) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 543ff2e6a60..be7d1cd9080 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -280,7 +280,6 @@ inline src/renderer/src/lib/pane-manager/pane-manager.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts -inline src/renderer/src/lib/terminal-links.ts inline src/renderer/src/lib/worktree-activation.test.ts inline src/renderer/src/lib/worktree-activation.ts inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 2b043d648cf..cdea515c945 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -1,7 +1,7 @@ import { resolveTerminalFileLinkText } from '@/lib/terminal-links' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { LinkHandlerDeps } from './terminal-link-handlers' -import { resolveTerminalFileUrlTarget } from './terminal-file-url-target' +import { resolveTerminalFileUrlTarget } from '../../../../shared/terminal-file-url-target' import { openDetectedFilePath } from './terminal-file-open-routing' import { isTerminalLinkActivation } from './terminal-link-activation' import { diff --git a/src/renderer/src/lib/terminal-bare-file-link-detection.ts b/src/renderer/src/lib/terminal-bare-file-link-detection.ts new file mode 100644 index 00000000000..364ebaffd6c --- /dev/null +++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts @@ -0,0 +1,68 @@ +import { + detectTerminalFileLinkRanges, + terminalFileLinkRangesOverlap, + toParsedTerminalFileLink +} from './terminal-file-link-detection-ranges' +import type { ParsedTerminalFileLink } from './terminal-links' + +// Mirrors VSCode's terminal word separators, with `:` handled by the existing +// line/column suffix parser instead of acting as a raw separator. +const WORD_TOKEN_REGEX = /[^\s()[\]{}'",;<>|`]+/g + +const EXTENSIONLESS_FILENAMES = new Set([ + 'Makefile', + 'Dockerfile', + 'Rakefile', + 'Gemfile', + 'Procfile', + 'LICENSE', + 'README', + 'CHANGELOG', + 'AUTHORS', + 'NOTICE', + 'CONTRIBUTING' +]) + +const BARE_FILENAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/ +const MAX_BARE_FILENAME_TOKEN_LENGTH = 120 + +function looksLikeFilename(token: string): boolean { + if (token.length < 2 || token.length > 100) { + return false + } + if (!BARE_FILENAME_PATTERN.test(token)) { + return false + } + if (/^\d+$/.test(token)) { + return false + } + if (token.includes('.')) { + return !/^\.+$/.test(token) + } + return EXTENSIONLESS_FILENAMES.has(token) +} + +// Bare words are filesystem-validated by the provider, so reject obvious prose +// before paying for a stat while retaining common extensionless project files. +export function detectBareFilenameLinks( + lineText: string, + claimedRanges: readonly [number, number][] +): ParsedTerminalFileLink[] { + const links: ParsedTerminalFileLink[] = [] + for (const range of detectTerminalFileLinkRanges(lineText, WORD_TOKEN_REGEX)) { + if (terminalFileLinkRangesOverlap(range, claimedRanges)) { + continue + } + // Why: huge terminal blobs can be one unbroken token; parse only bounded + // bare-filename candidates so hover link detection stays interactive. + if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) { + continue + } + const link = toParsedTerminalFileLink(range) + if (!link || !looksLikeFilename(link.pathText)) { + continue + } + links.push(link) + } + return links +} diff --git a/src/renderer/src/lib/terminal-file-link-detection-ranges.ts b/src/renderer/src/lib/terminal-file-link-detection-ranges.ts new file mode 100644 index 00000000000..304a76482bc --- /dev/null +++ b/src/renderer/src/lib/terminal-file-link-detection-ranges.ts @@ -0,0 +1,126 @@ +import { parseExplicitFileLinkTarget } from './explicit-file-link-target' +import type { ParsedTerminalFileLink } from './terminal-links' + +const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"]) +const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.']) + +export type DetectedTerminalFileLinkRange = { + startIndex: number + endIndex: number + text: string +} + +function trimBoundaryPunctuation( + value: string, + startIndex: number +): DetectedTerminalFileLinkRange | null { + let start = 0 + let end = value.length + + while (start < end && LEADING_TRIM_CHARS.has(value[start])) { + start += 1 + } + while (end > start && TRAILING_TRIM_CHARS.has(value[end - 1])) { + end -= 1 + } + + if (start >= end) { + return null + } + + return { + text: value.slice(start, end), + startIndex: startIndex + start, + endIndex: startIndex + end + } +} + +export function* detectTerminalFileLinkRanges( + lineText: string, + regex: RegExp +): Generator { + for (const match of lineText.matchAll(regex)) { + const rawStart = match.index ?? 0 + const trimmed = trimBoundaryPunctuation(match[0], rawStart) + if (trimmed) { + yield trimmed + } + } +} + +export function mergeTerminalFileLinkRanges(ranges: [number, number][]): [number, number][] { + if (ranges.length <= 1) { + return ranges + } + const sorted = ranges.slice().sort((left, right) => left[0] - right[0] || left[1] - right[1]) + const merged: [number, number][] = [] + for (const range of sorted) { + const last = merged.at(-1) + if (!last || range[0] > last[1]) { + merged.push([range[0], range[1]]) + continue + } + last[1] = Math.max(last[1], range[1]) + } + return merged +} + +export function terminalFileLinkRangesOverlap( + range: DetectedTerminalFileLinkRange, + claimedRanges: readonly [number, number][] +): boolean { + // Why: generated terminal lines can contain thousands of file-looking tokens; + // overlap checks must stay logarithmic instead of scanning every prior range. + let low = 0 + let high = claimedRanges.length + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (claimedRanges[mid][0] < range.endIndex) { + low = mid + 1 + } else { + high = mid + } + } + const previous = claimedRanges[low - 1] + return previous !== undefined && previous[1] > range.startIndex +} + +export function insertTerminalFileLinkClaimedRange( + claimedRanges: [number, number][], + range: [number, number] +): void { + const last = claimedRanges.at(-1) + if (!last || last[0] <= range[0]) { + claimedRanges.push(range) + return + } + + let low = 0 + let high = claimedRanges.length + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (claimedRanges[mid][0] <= range[0]) { + low = mid + 1 + } else { + high = mid + } + } + claimedRanges.splice(low, 0, range) +} + +export function toParsedTerminalFileLink( + range: DetectedTerminalFileLinkRange +): ParsedTerminalFileLink | null { + const parsed = parseExplicitFileLinkTarget(range.text) + if (!parsed) { + return null + } + return { + pathText: parsed.pathText, + line: parsed.line, + column: parsed.column, + startIndex: range.startIndex, + endIndex: range.endIndex, + displayText: range.text + } +} diff --git a/src/renderer/src/lib/terminal-file-uri-link.test.ts b/src/renderer/src/lib/terminal-file-uri-link.test.ts new file mode 100644 index 00000000000..1e8504f69d3 --- /dev/null +++ b/src/renderer/src/lib/terminal-file-uri-link.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { detectTerminalFileUriLinks } from './terminal-file-uri-link' + +describe('detectTerminalFileUriLinks', () => { + it('decodes a localhost-less file URI to an absolute path', () => { + const line = 'Report: file:///Users/dev/orca/report.html' + const [link] = detectTerminalFileUriLinks(line) + expect(link).toMatchObject({ + pathText: '/Users/dev/orca/report.html', + line: null, + column: null + }) + expect(line.slice(link.startIndex, link.endIndex)).toBe('file:///Users/dev/orca/report.html') + expect(link.displayText).toBe('file:///Users/dev/orca/report.html') + }) + + it('percent-decodes spaces in the path', () => { + const [link] = detectTerminalFileUriLinks('open file:///Users/dev/My%20Reports/out.html now') + expect(link.pathText).toBe('/Users/dev/My Reports/out.html') + }) + + it('keeps standard unescaped parentheses and apostrophes inside the path', () => { + const uri = "file:///tmp/Brennan's%20Report%20(final)" + const [link] = detectTerminalFileUriLinks(`open (${uri})`) + expect(link.pathText).toBe("/tmp/Brennan's Report (final)") + expect(link.displayText).toBe(uri) + }) + + it('carries a :line:col suffix', () => { + const [link] = detectTerminalFileUriLinks('file:///Users/dev/app.ts:12:3') + expect(link).toMatchObject({ pathText: '/Users/dev/app.ts', line: 12, column: 3 }) + }) + + it('carries an #Lline anchor', () => { + const [link] = detectTerminalFileUriLinks('file:///Users/dev/app.ts#L42') + expect(link).toMatchObject({ pathText: '/Users/dev/app.ts', line: 42, column: null }) + }) + + it('strips the WHATWG leading slash before a Windows drive path', () => { + const [link] = detectTerminalFileUriLinks('file:///C:/Users/dev/report.html') + expect(link.pathText).toBe('C:/Users/dev/report.html') + }) + + it('trims trailing sentence punctuation but keeps the extension', () => { + const [link] = detectTerminalFileUriLinks('see file:///tmp/out.html.') + expect(link.displayText).toBe('file:///tmp/out.html') + expect(link.pathText).toBe('/tmp/out.html') + }) + + it('rejects remote hosts (existence probing handles the path otherwise)', () => { + expect(detectTerminalFileUriLinks('file://build-server/var/log/out.txt')).toEqual([]) + }) + + it('ignores non-file schemes and malformed escapes', () => { + expect(detectTerminalFileUriLinks('https://example.com/x.html')).toEqual([]) + expect(detectTerminalFileUriLinks('file:///tmp/%E0%A4%A.txt')).toEqual([]) + }) + + it('finds multiple file URIs on one line', () => { + const links = detectTerminalFileUriLinks('a file:///tmp/a.txt b file:///tmp/b.txt') + expect(links.map((link) => link.pathText)).toEqual(['/tmp/a.txt', '/tmp/b.txt']) + }) + + it('does not expose oversized terminal tokens to filesystem probing', () => { + expect(detectTerminalFileUriLinks(`file:///tmp/${'a'.repeat(10_000)}`)).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/terminal-file-uri-link.ts b/src/renderer/src/lib/terminal-file-uri-link.ts new file mode 100644 index 00000000000..d9751ba24cf --- /dev/null +++ b/src/renderer/src/lib/terminal-file-uri-link.ts @@ -0,0 +1,95 @@ +import { resolveTerminalFileUrlTarget } from '../../../shared/terminal-file-url-target' +import type { ParsedTerminalFileLink } from './terminal-links' + +// Why: plain-text file URIs bypass both the HTTP and local-path detectors; use +// the OSC 8 resolver so both terminal representations open identically. + +const MAX_FILE_URI_LENGTH = 2048 +// Why: extraction runs on hover; cap before URL parsing and filesystem probes +// so a file:// prefix in a huge dumped token cannot block the renderer. +const FILE_URI_REGEX = /\bfile:\/\/[^\s"`<>|]{1,2049}/gi + +const TRAILING_PROSE_CHARS = new Set(['.', ',', ';', ':', '!', '?', '>', '"', "'", '`']) + +function trimTrailingProse(uriText: string): string { + let parentheses = 0 + let brackets = 0 + let braces = 0 + for (const char of uriText) { + parentheses += char === ')' ? 1 : char === '(' ? -1 : 0 + brackets += char === ']' ? 1 : char === '[' ? -1 : 0 + braces += char === '}' ? 1 : char === '{' ? -1 : 0 + } + + let end = uriText.length + while (end > 0) { + const char = uriText[end - 1] + if (TRAILING_PROSE_CHARS.has(char)) { + end -= 1 + continue + } + // Why: standard file URIs leave parentheses unescaped; trim only closing + // delimiters supplied by surrounding prose, not balanced filename text. + if (char === ')' && parentheses > 0) { + parentheses -= 1 + end -= 1 + continue + } + if (char === ']' && brackets > 0) { + brackets -= 1 + end -= 1 + continue + } + if (char === '}' && braces > 0) { + braces -= 1 + end -= 1 + continue + } + break + } + return uriText.slice(0, end) +} + +// Remote hosts are rejected: on a local pane a hostname'd URI would resolve to a +// path that does not exist, and the provider's existence probe would drop it +// anyway. Windows UNC support stays with the OSC path, which has the platform +// context this pure pass deliberately avoids. +function toFileUriLink(uriText: string, startIndex: number): ParsedTerminalFileLink | null { + let url: URL + try { + url = new URL(uriText) + } catch { + return null + } + const target = resolveTerminalFileUrlTarget(url) + if (!target) { + return null + } + return { + pathText: target.filePath, + line: target.line, + column: target.column, + startIndex, + endIndex: startIndex + uriText.length, + displayText: uriText + } +} + +export function detectTerminalFileUriLinks(lineText: string): ParsedTerminalFileLink[] { + const links: ParsedTerminalFileLink[] = [] + for (const match of lineText.matchAll(FILE_URI_REGEX)) { + const startIndex = match.index ?? 0 + if (match[0].length > MAX_FILE_URI_LENGTH) { + continue + } + const trimmed = trimTrailingProse(match[0]) + if (!trimmed) { + continue + } + const link = toFileUriLink(trimmed, startIndex) + if (link) { + links.push(link) + } + } + return links +} diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index f6aa3501a0a..66ff8d870a8 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -5,6 +5,7 @@ import { columnForTerminalFileLinkTap } from '../../../shared/terminal-file-link-conformance' import { + extractTerminalFileLinkCandidates, extractTerminalFileLinks, isPathInsideWorktree, resolveTerminalFileLink, @@ -316,4 +317,31 @@ describe('terminal path helpers', () => { it('does not resolve partial text as an OSC hyperlink target', () => { expect(resolveTerminalFileLinkText('open docs/README.md', '/repo')).toBeNull() }) + + describe('plain-text file:// URIs', () => { + it('extracts a printed file:// URI as a file link resolving to its path', () => { + const line = 'Report: file:///Users/dev/orca/report.html' + const link = extractTerminalFileLinks(line).find( + (candidate) => candidate.displayText === 'file:///Users/dev/orca/report.html' + ) + expect(link).toMatchObject({ pathText: '/Users/dev/orca/report.html' }) + expect(resolveTerminalFileLink(link!, '/Users/dev/orca')).toEqual({ + absolutePath: '/Users/dev/orca/report.html', + line: null, + column: null + }) + }) + + it('does not also emit a bare-path link for the URI body', () => { + const links = extractTerminalFileLinks('file:///Users/dev/orca/report.html') + expect(links.map((link) => link.displayText)).toEqual(['file:///Users/dev/orca/report.html']) + }) + + it('exposes file:// URIs to the hover candidate pass as well', () => { + const candidates = extractTerminalFileLinkCandidates('file:///tmp/out.txt:9') + expect(candidates.some((link) => link.pathText === '/tmp/out.txt' && link.line === 9)).toBe( + true + ) + }) + }) }) diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index 605360e4245..729f2d839e7 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,9 +1,15 @@ -/* eslint-disable max-lines -- Why: terminal link parsing depends on ordered passes sharing range state. */ import { normalizeAbsolutePath } from './terminal-path-normalization' +import { resolveExplicitFileLinkTarget } from './explicit-file-link-target' +import { detectBareFilenameLinks } from './terminal-bare-file-link-detection' import { - parseExplicitFileLinkTarget, - resolveExplicitFileLinkTarget -} from './explicit-file-link-target' + detectTerminalFileLinkRanges, + insertTerminalFileLinkClaimedRange, + mergeTerminalFileLinkRanges, + terminalFileLinkRangesOverlap, + toParsedTerminalFileLink, + type DetectedTerminalFileLinkRange +} from './terminal-file-link-detection-ranges' +import { detectTerminalFileUriLinks } from './terminal-file-uri-link' export type ParsedTerminalFileLink = { pathText: string @@ -52,63 +58,7 @@ const SPACED_LOCAL_PATH_REGEXES = [ LINE_ENDING_SPACED_PATH_REGEX ] -// Word separators used by the bare-filename pass. Mirrors the default set in -// VSCode's `terminal.integrated.wordSeparators` with the exception that we -// include `:` indirectly via the line:col suffix parser rather than as a -// raw separator. A word is any maximal run of non-separator characters. -// \s matches NBSP in modern JS; xterm powerline glyphs are in the PUA and -// never appear in filenames, so we don't list them explicitly. -const WORD_TOKEN_REGEX = /[^\s()[\]{}'",;<>|`]+/g - -const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"]) -const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.']) - -function trimBoundaryPunctuation( - value: string, - startIndex: number -): { text: string; startIndex: number; endIndex: number } | null { - let start = 0 - let end = value.length - - while (start < end && LEADING_TRIM_CHARS.has(value[start])) { - start += 1 - } - while (end > start && TRAILING_TRIM_CHARS.has(value[end - 1])) { - end -= 1 - } - - if (start >= end) { - return null - } - - return { - text: value.slice(start, end), - startIndex: startIndex + start, - endIndex: startIndex + end - } -} - -// Project files that look like filenames despite having no extension. The -// word detector otherwise requires a `.` in the token to keep noise down — -// without this list, `ls` output containing `Makefile` or `LICENSE` would -// not be clickable. -const EXTENSIONLESS_FILENAMES = new Set([ - 'Makefile', - 'Dockerfile', - 'Rakefile', - 'Gemfile', - 'Procfile', - 'LICENSE', - 'README', - 'CHANGELOG', - 'AUTHORS', - 'NOTICE', - 'CONTRIBUTING' -]) - -const BARE_FILENAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/ const URI_PREFIX_CHAR_PATTERN = /^[A-Za-z0-9+./:-]$/ -const MAX_BARE_FILENAME_TOKEN_LENGTH = 120 function hasPathSeparator(text: string): boolean { return text.includes('/') || text.includes('\\') @@ -147,41 +97,6 @@ function hasSpacedPathExtension(text: string): boolean { return /\s/.test(trimmedText) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmedText) } -// Bare words are validated against the filesystem by the provider, so this -// filter's job is to reject tokens that are obviously not filenames before -// we pay for a stat. Plain words like `src` or `my-cli` are usually -// directories or binaries and produce more noise than value — users who -// really want to open them can prefix with `./`. -function looksLikeFilename(token: string): boolean { - if (token.length < 2 || token.length > 100) { - return false - } - if (!BARE_FILENAME_PATTERN.test(token)) { - return false - } - if (/^\d+$/.test(token)) { - return false - } - if (token.includes('.')) { - return !/^\.+$/.test(token) - } - return EXTENSIONLESS_FILENAMES.has(token) -} - -type DetectedRange = { startIndex: number; endIndex: number; text: string } -// Shared tokenization: run a regex over the line, trim boundary punctuation, -// hand each surviving range to the caller. Collapses the three near-copies -// of this loop the module had grown. -function* detectRanges(lineText: string, regex: RegExp): Generator { - for (const match of lineText.matchAll(regex)) { - const rawStart = match.index ?? 0 - const trimmed = trimBoundaryPunctuation(match[0], rawStart) - if (trimmed) { - yield trimmed - } - } -} - function getImmediateUriPrefix(lineText: string, endIndex: number): string { let start = endIndex while (start > 0 && URI_PREFIX_CHAR_PATTERN.test(lineText[start - 1])) { @@ -190,7 +105,7 @@ function getImmediateUriPrefix(lineText: string, endIndex: number): string { return lineText.slice(start, endIndex) } -function isInsideUriScheme(lineText: string, range: DetectedRange): boolean { +function isInsideUriScheme(lineText: string, range: DetectedTerminalFileLinkRange): boolean { const prefix = getImmediateUriPrefix(lineText, range.startIndex) // Why: local-path matching can start at the `//host/path` portion of a URL. return ( @@ -200,61 +115,9 @@ function isInsideUriScheme(lineText: string, range: DetectedRange): boolean { ) } -function mergeRanges(ranges: [number, number][]): [number, number][] { - if (ranges.length <= 1) { - return ranges - } - const sorted = ranges.slice().sort((left, right) => left[0] - right[0] || left[1] - right[1]) - const merged: [number, number][] = [] - for (const range of sorted) { - const last = merged.at(-1) - if (!last || range[0] > last[1]) { - merged.push([range[0], range[1]]) - continue - } - last[1] = Math.max(last[1], range[1]) - } - return merged -} - -function rangesOverlap(range: DetectedRange, claimedRanges: readonly [number, number][]): boolean { - // Why: generated terminal lines can contain thousands of file-looking tokens; - // overlap checks must stay logarithmic instead of scanning every prior range. - let low = 0 - let high = claimedRanges.length - while (low < high) { - const mid = Math.floor((low + high) / 2) - if (claimedRanges[mid][0] < range.endIndex) { - low = mid + 1 - } else { - high = mid - } - } - const previous = claimedRanges[low - 1] - return previous !== undefined && previous[1] > range.startIndex -} - -function insertClaimedRange(claimedRanges: [number, number][], range: [number, number]): void { - const last = claimedRanges.at(-1) - if (!last || last[0] <= range[0]) { - claimedRanges.push(range) - return - } - - let low = 0 - let high = claimedRanges.length - while (low < high) { - const mid = Math.floor((low + high) / 2) - if (claimedRanges[mid][0] <= range[0]) { - low = mid + 1 - } else { - high = mid - } - } - claimedRanges.splice(low, 0, range) -} - -function trimSpacedPathTrailingProse(range: DetectedRange): DetectedRange { +function trimSpacedPathTrailingProse( + range: DetectedTerminalFileLinkRange +): DetectedTerminalFileLinkRange { // Why: keep one extension-terminated path, but drop trailing prose or a // second unrelated path that the broad spaced-path scan also captured. A // line-end extension token only extends the span when the added segment is @@ -296,7 +159,9 @@ function countPathStarts(text: string): number { return count } -function trimTrailingWhitespace(range: DetectedRange): DetectedRange { +function trimTrailingWhitespace( + range: DetectedTerminalFileLinkRange +): DetectedTerminalFileLinkRange { const text = range.text.trimEnd() return { text, @@ -305,8 +170,10 @@ function trimTrailingWhitespace(range: DetectedRange): DetectedRange { } } -function buildLineEndingSpacedPathPrefixRanges(range: DetectedRange): DetectedRange[] { - const ranges: DetectedRange[] = [] +function buildLineEndingSpacedPathPrefixRanges( + range: DetectedTerminalFileLinkRange +): DetectedTerminalFileLinkRange[] { + const ranges: DetectedTerminalFileLinkRange[] = [] for (const match of range.text.matchAll(/\s+/g)) { const endIndex = match.index ?? 0 const text = range.text.slice(0, endIndex).trimEnd() @@ -321,25 +188,6 @@ function buildLineEndingSpacedPathPrefixRanges(range: DetectedRange): DetectedRa return ranges.toReversed() } -function toParsedLink(range: DetectedRange): ParsedTerminalFileLink | null { - const parsed = parseExplicitFileLinkTarget(range.text) - if (!parsed) { - return null - } - return { - pathText: parsed.pathText, - line: parsed.line, - column: parsed.column, - startIndex: range.startIndex, - endIndex: range.endIndex, - displayText: range.text - } -} - -function sortLinksByPosition(links: ParsedTerminalFileLink[]): ParsedTerminalFileLink[] { - return links.sort((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex) -} - // Ported from VSCode's TerminalLocalLinkDetector. Extracts anything that // contains a path separator, optionally with a `:line:col` suffix — covers // `./src/foo.ts`, `/abs/bar`, `src/foo.ts:12:3`, etc. @@ -353,14 +201,14 @@ function detectLocalPathLinks( const links: ParsedTerminalFileLink[] = [] const spacedLinks = detectSpacedLocalPathLinks(lineText, includeLineEndingPrefixCandidates) - const spacedRanges = mergeRanges( + const spacedRanges = mergeTerminalFileLinkRanges( spacedLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex]) ) for (const link of spacedLinks) { links.push(link) } - for (const range of detectRanges(lineText, LOCAL_PATH_REGEX)) { - if (rangesOverlap(range, spacedRanges)) { + for (const range of detectTerminalFileLinkRanges(lineText, LOCAL_PATH_REGEX)) { + if (terminalFileLinkRangesOverlap(range, spacedRanges)) { continue } if (isInsideUriScheme(lineText, range)) { @@ -369,12 +217,12 @@ function detectLocalPathLinks( if (!/[\\/]/.test(range.text)) { continue } - const link = toParsedLink(range) + const link = toParsedTerminalFileLink(range) if (link) { links.push(link) } } - return sortLinksByPosition(links) + return links.sort((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex) } function detectSpacedLocalPathLinks( @@ -384,7 +232,7 @@ function detectSpacedLocalPathLinks( const links: ParsedTerminalFileLink[] = [] const claimedRanges: [number, number][] = [] for (const regex of SPACED_LOCAL_PATH_REGEXES) { - for (const range of detectRanges(lineText, regex)) { + for (const range of detectTerminalFileLinkRanges(lineText, regex)) { if (regex === SPACED_PATH_WITH_SEPARATOR_REGEX && !hasSeparatorAfterWhitespace(range.text)) { continue } @@ -398,7 +246,10 @@ function detectSpacedLocalPathLinks( ) { continue } - if (rangesOverlap(range, claimedRanges) || isInsideUriScheme(lineText, range)) { + if ( + terminalFileLinkRangesOverlap(range, claimedRanges) || + isInsideUriScheme(lineText, range) + ) { continue } const candidateRanges = @@ -407,7 +258,9 @@ function detectSpacedLocalPathLinks( : [range] const candidateLinks = candidateRanges .map((candidateRange) => - toParsedLink(trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))) + toParsedTerminalFileLink( + trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange)) + ) ) .filter((link): link is ParsedTerminalFileLink => link !== null) const link = candidateLinks[0] @@ -415,65 +268,39 @@ function detectSpacedLocalPathLinks( for (const candidateLink of candidateLinks) { links.push(candidateLink) } - insertClaimedRange(claimedRanges, [link.startIndex, link.endIndex]) + insertTerminalFileLinkClaimedRange(claimedRanges, [link.startIndex, link.endIndex]) } } } return links } -// Ported from VSCode's TerminalWordLinkDetector. Tokenizes the line on -// separators and emits filename-ish words so `ls` output becomes clickable. -// Skips ranges already claimed by the local-path pass to avoid double links -// when a bare filename happens to be a substring of a longer path. -function detectBareFilenameLinks( +// Runs the file-uri, local-path, and bare-filename passes in that precedence. +// `file://` and separator paths claim their ranges first so the bare-filename +// pass never re-links a token already covered by a longer explicit link. +function assembleFileLinks( lineText: string, - claimedRanges: readonly [number, number][] + includeLineEndingPrefixCandidates: boolean ): ParsedTerminalFileLink[] { - const links: ParsedTerminalFileLink[] = [] - for (const range of detectRanges(lineText, WORD_TOKEN_REGEX)) { - if (rangesOverlap(range, claimedRanges)) { - continue - } - // Why: huge terminal blobs can be one unbroken token; parse only bounded - // bare-filename candidates so hover link detection stays interactive. - if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) { - continue - } - const link = toParsedLink(range) - if (!link) { - continue - } - if (!looksLikeFilename(link.pathText)) { - continue - } - links.push(link) + const uriLinks = detectTerminalFileUriLinks(lineText) + const pathLinks = detectLocalPathLinks(lineText, includeLineEndingPrefixCandidates) + const explicitLinks = uriLinks.length > 0 ? [...uriLinks, ...pathLinks] : pathLinks + const claimed = mergeTerminalFileLinkRanges( + explicitLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex]) + ) + const wordLinks = detectBareFilenameLinks(lineText, claimed) + for (const link of wordLinks) { + explicitLinks.push(link) } - return links + return explicitLinks } export function extractTerminalFileLinks(lineText: string): ParsedTerminalFileLink[] { - const pathLinks = detectLocalPathLinks(lineText) - const claimed = mergeRanges( - pathLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex]) - ) - const wordLinks = detectBareFilenameLinks(lineText, claimed) - for (const link of wordLinks) { - pathLinks.push(link) - } - return pathLinks + return assembleFileLinks(lineText, false) } export function extractTerminalFileLinkCandidates(lineText: string): ParsedTerminalFileLink[] { - const pathLinks = detectLocalPathLinks(lineText, true) - const claimed = mergeRanges( - pathLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex]) - ) - const wordLinks = detectBareFilenameLinks(lineText, claimed) - for (const link of wordLinks) { - pathLinks.push(link) - } - return pathLinks + return assembleFileLinks(lineText, true) } export function resolveTerminalFileLink( diff --git a/src/renderer/src/components/terminal-pane/terminal-file-url-target.test.ts b/src/shared/terminal-file-url-target.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/terminal-file-url-target.test.ts rename to src/shared/terminal-file-url-target.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-file-url-target.ts b/src/shared/terminal-file-url-target.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/terminal-file-url-target.ts rename to src/shared/terminal-file-url-target.ts index f21b31ed7c4..941023181ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-url-target.ts +++ b/src/shared/terminal-file-url-target.ts @@ -1,4 +1,4 @@ -import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path' +import { fileUriToFilesystemPath } from './file-uri-path' export type TerminalFileUrlTarget = { filePath: string From 08f446190abadafa9ae9fc224ded171640822858 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:59:36 -0700 Subject: [PATCH 03/22] Standardize per-repo Source Control AI save UX (#9427) * Standardize per-repo Source Control AI save UX with global recipe patter Extract draft/save logic into `useRepositorySourceControlAiGlobalUx` hook and serial persist queue, matching how global action recipes save: selects persist immediately, CLI args and command template draft until per-action Save. * Support draft/commit for per-repo custom-command input - Drafts custom commands locally on keystroke without backend writes - Commits to persistence only on blur or mode selection change - Adds forceRepoMode local state to fix mode-select snapping with empty fields - Mirrors existing pattern used for action recipe text editing - Improves persist queue with repo-switch safety (returns boolean, pins repoId at schedule time) - Adds comprehensive test coverage for settings hook and persist queue * Fix forceRepoMode logic to preserve REPO intent mid-edit Always set forceRepoMode based on field content instead of only when a repo command is present. This preserves REPO intent when the field is empty during editing, only exiting when a value is explicitly entered. Add test for draft discard and revert behavior. --- .../RepositorySourceControlAiActionRows.tsx | 24 +- ...RepositorySourceControlAiCustomCommand.tsx | 56 ++- .../RepositorySourceControlAiSection.test.ts | 80 +--- .../RepositorySourceControlAiSection.tsx | 399 +++--------------- ...repository-source-control-ai-draft.test.ts | 56 --- .../repository-source-control-ai-draft.ts | 308 ++++++++++---- ...sitory-source-control-ai-global-ux.test.ts | 236 +++++++++++ .../repository-source-control-ai-global-ux.ts | 316 ++++++++++++++ ...ry-source-control-ai-persist-queue.test.ts | 149 +++++++ ...ository-source-control-ai-persist-queue.ts | 73 ++++ 10 files changed, 1116 insertions(+), 581 deletions(-) delete mode 100644 src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-persist-queue.ts diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx index 58a735e04dc..9fcfe68c077 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx @@ -51,7 +51,8 @@ type RepositorySourceControlAiActionRowsProps = { onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void onAppendVariable: (actionId: SourceControlActionId, variable: string) => void - isSaving: boolean + /** Per-action saving state for CLI args + command template (matches global recipes). */ + savingActionIds: Partial> actionDirtyById: Record onActionDiscard: (actionId: SourceControlActionId) => void onActionSave: (actionId: SourceControlActionId) => void @@ -67,7 +68,7 @@ export function RepositorySourceControlAiActionRows({ onActionTemplateChange, onActionAgentArgsChange, onAppendVariable, - isSaving, + savingActionIds, actionDirtyById, onActionDiscard, onActionSave @@ -103,6 +104,7 @@ export function RepositorySourceControlAiActionRows({ const agentWarningText = getSourceControlActionAgentWarningText(actionId, effectiveAgent) const agentSupportText = getSourceControlActionAgentSupportText(actionId) const actionDirty = actionDirtyById[actionId] + const isSavingAction = savingActionIds[actionId] === true return (
{hasOverride ? ( -
+

{actionDirty ? translate( - 'auto.components.settings.RepositorySourceControlAiSection.e57dde9d93', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e', 'Unsaved changes' ) : translate( - 'auto.components.settings.RepositorySourceControlAiSection.ccb07dd027', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8', 'Saved' )}

@@ -268,10 +270,10 @@ export function RepositorySourceControlAiActionRows({ variant="ghost" size="xs" onClick={() => onActionDiscard(actionId)} - disabled={isSaving} + disabled={isSavingAction} > {translate( - 'auto.components.settings.RepositorySourceControlAiSection.67b3ff5467', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc', 'Discard' )} @@ -281,15 +283,15 @@ export function RepositorySourceControlAiActionRows({ variant="secondary" size="xs" onClick={() => onActionSave(actionId)} - disabled={!actionDirty || isSaving} + disabled={!actionDirty || isSavingAction} > - {isSaving + {isSavingAction ? translate( - 'auto.components.settings.RepositorySourceControlAiSection.57e6e9d4b1', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8', 'Saving...' ) : translate( - 'auto.components.settings.RepositorySourceControlAiSection.152268c295', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12', 'Save' )} diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx index 6a22472b2ee..e3e6e32cf6b 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx @@ -1,4 +1,5 @@ import type React from 'react' +import { useState } from 'react' import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' import { Input } from '../ui/input' import { Label } from '../ui/label' @@ -12,19 +13,27 @@ import { translate } from '@/i18n/i18n' type RepositorySourceControlAiCustomCommandProps = { value: string | undefined source: SourceControlAiSettings + // onChange drafts the value locally (per keystroke); onCommit persists it (on blur / mode change). onChange: (value: string | undefined) => void + onCommit: (value: string | undefined) => void +} + +function isRepoCommand(value: string | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0 } export function RepositorySourceControlAiCustomCommand({ value, source, - onChange + onChange, + onCommit }: RepositorySourceControlAiCustomCommandProps): React.JSX.Element { - // Why: value only counts as a repo command when it is a non-empty trimmed string; - // empty/nullish values make hasRepoCommand select CUSTOM_COMMAND_MODE_INHERIT - // instead of CUSTOM_COMMAND_MODE_REPO, so clearing the input switches mode. - const hasRepoCommand = typeof value === 'string' && value.trim().length > 0 - const mode = hasRepoCommand ? CUSTOM_COMMAND_MODE_REPO : CUSTOM_COMMAND_MODE_INHERIT + // Why: selecting "Repository command" with an empty global/repo value would otherwise snap the + // Select back to inherit (empty is not a repo command). Keep a local intent so the user can type. + const [forceRepoMode, setForceRepoMode] = useState(false) + const hasRepoCommand = isRepoCommand(value) + const mode = + hasRepoCommand || forceRepoMode ? CUSTOM_COMMAND_MODE_REPO : CUSTOM_COMMAND_MODE_INHERIT return (
@@ -45,13 +54,23 @@ export function RepositorySourceControlAiCustomCommand({