mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Fix terminal tab icon detection for foreground agents
- Register with bash-preexec's preexec_functions array to prevent its DEBUG trap re-arming from silencing Orca's command-start signals. - Serve the last-resolved identity past its cache TTL (stale-while- revalidate) while the active foreground process is a wrapper. - Reschedule confirming reads on duplicate OSC 133;D sequences to prevent nested shells from prematurely clearing tab identities. - Trigger self-limiting foreground sampling on visible PTY binding, Enter keystrokes, and pane focus changes.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
# RESOLVED: Codex/agent terminal tab icon not showing
|
||||
|
||||
Branch: `fix-codex-icon-not-showing-up-on-terminal`. All fixes validated live
|
||||
end-to-end on 2026-07-05 in an isolated dev instance (fresh profile + fresh
|
||||
daemon, real clicks via playwright-cli, visible-icon screenshots).
|
||||
|
||||
## Root causes found (three, stacked)
|
||||
|
||||
1. **bash-preexec swallows Orca's OSC 133;C** (the original bug on this
|
||||
machine). The user's bash setup (iTerm2 shell integration) loads
|
||||
bash-preexec, which re-arms its own DEBUG trap at the first prompt,
|
||||
silencing Orca's `trap '__orca_osc133_preexec' DEBUG`. Result: typing
|
||||
`codex`/`grok` emits NO command-start signal, so foreground detection never
|
||||
begins. D (from PROMPT_COMMAND precmd) still fires at prompts, so panes get
|
||||
marked shell-foreground, which also suppresses the title-layer icon.
|
||||
Verified live: `trap -p DEBUG` showed `__bp_preexec_invoke_exec`.
|
||||
**Fix**: `shell-ready.ts` bash wrapper also registers
|
||||
`__orca_osc133_preexec_bp` in bash-preexec's `preexec_functions` array
|
||||
(gated on `__orca_in_command` so C fires exactly once either way).
|
||||
|
||||
2. **Daemon foreground cache TTL vs renderer retry cadence.** For node-wrapped
|
||||
CLIs (grok's `node /opt/homebrew/bin/grok` trampoline), the daemon resolves
|
||||
node→grok asynchronously into a 1000ms-TTL cache, but the renderer's wrapper
|
||||
retries fire at +1200/+3500ms — always after expiry — so every read returned
|
||||
raw `"node"` and detection failed. Verified live: reads alternated
|
||||
node/grok/grok/node at 700ms intervals.
|
||||
**Fix**: `pty-subprocess.ts` serves the last resolved identity while the
|
||||
foreground is still an unidentifiable wrapper (stale-while-revalidate), and
|
||||
clears an expired identity when a refresh proves the wrapper tree has no
|
||||
agent (so `npm` after an agent exit can't inherit the icon).
|
||||
|
||||
3. **Duplicate OSC 133;D defeats the confirming read.** User shell
|
||||
integrations double up Orca's 133 sequences: every D arrives twice ~50ms
|
||||
apart (verified via a PTY data tap: `D;0 … 53ms … D;0`). The second D saw no
|
||||
pending 'command' read (only the first D's pending 'command-finished' read),
|
||||
took the no-RPC fast path, published `{null, shellForeground: true}`, and
|
||||
cancelled the in-flight confirmation.
|
||||
**Fix**: `pane-foreground-agent-tracker.ts` counts a pending/in-flight
|
||||
confirming read as "read pending", so duplicate Ds re-confirm.
|
||||
|
||||
Plus two renderer resilience fixes for "focused pane changes" (user report):
|
||||
|
||||
4. **Enter-triggered sampling** (`pty-connection.ts` onTerminalKeyDown): Enter
|
||||
at a shell-foreground pane triggers the existing self-limiting foreground
|
||||
sample — covers any setup where no 133;C arrives at all. Publishes nothing
|
||||
for idle shells; skipped when an agent identity is already live.
|
||||
5. **Focus-change resampling** (`use-terminal-pane-lifecycle.ts`
|
||||
onActivePaneChange → `sampleForegroundAgentOnFocus`): the tab icon follows
|
||||
the active leaf, so focusing a shell-marked pane whose agent is still
|
||||
running re-samples it (recovers poisoned entries in splits).
|
||||
|
||||
## Validated live (fresh build, new tabs, real clicks)
|
||||
|
||||
- Typed `grok` in an already-focused pane → entry `{agent:'grok'}` + grok icon
|
||||
on the tab within ~6s.
|
||||
- Split right + typed `codex` → both panes' entries correct
|
||||
(`{agent:'grok'}` / `{agent:'codex'}`); tab icon follows the focused pane
|
||||
(grok icon ↔ codex icon on click), screenshots confirmed.
|
||||
- Quit codex → entry cleared, tab shows plain terminal icon for the shell
|
||||
pane; grok icon returns when its pane regains focus.
|
||||
|
||||
## Tests
|
||||
|
||||
- `shell-ready.test.ts` 23/23 (new: bash-preexec re-arm regression test that
|
||||
fails without fix 1).
|
||||
- `pty-subprocess.test.ts` 85/85 (new: stale-serve past TTL + expired-identity
|
||||
clear).
|
||||
- `pane-foreground-agent-tracker.test.ts` 23/23 (new: duplicate-D pair
|
||||
confirm + idle-pane duplicate-D no-RPC path).
|
||||
- `tsgo` web + node configs clean, oxlint clean.
|
||||
- KNOWN: `pty-connection.test.ts` cannot load on this branch (pre-existing
|
||||
vite resolution error for `pane-terminal-foreground-render-settle`; rebase
|
||||
onto main to run its added tests).
|
||||
|
||||
## Notes for review/merge
|
||||
|
||||
- `CODEX_ICON_DEBUG.diff` is stale (pre-dates these fixes); use `git diff`.
|
||||
- The daemon is a long-lived process: users (and dev instances) keep running
|
||||
the OLD daemon code until it restarts. The bash rcfile is rewritten at
|
||||
daemon startup, so fix 1 also needs a daemon restart to take effect.
|
||||
- SSH panes are unaffected: the tracker's reads are gated by isTrackablePtyId
|
||||
(local only); the shell-side C fix benefits every OSC 133 consumer.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
@@ -367,6 +367,81 @@ describe('createPtySubprocess', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the resolved agent identity past the cache TTL while a wrapper holds the foreground', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z'))
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'node'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue('grok')
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('node')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(handle.getForegroundProcess()).toBe('grok')
|
||||
|
||||
// Why: renderer reads poll slower than the 1s cache TTL — an expired
|
||||
// cache must keep answering with the resolved identity, not the wrapper.
|
||||
vi.advanceTimersByTime(1_500)
|
||||
expect(handle.getForegroundProcess()).toBe('grok')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('clears an expired identity when the wrapper tree no longer resolves to an agent', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z'))
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'node'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
resolveAgentForegroundProcessMock.mockResolvedValueOnce('grok').mockResolvedValue('node')
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('node')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(handle.getForegroundProcess()).toBe('grok')
|
||||
// Flush the first refresh's finally so the next read can revalidate.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// An unrelated wrapper (e.g. npm) now owns the pane: the stale-served
|
||||
// identity is revalidated and dropped once the refresh finds no agent.
|
||||
vi.advanceTimersByTime(1_500)
|
||||
expect(handle.getForegroundProcess()).toBe('grok')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(handle.getForegroundProcess()).toBe('node')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('serves daemon Windows wrapper agent foreground from an async cache', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'node.exe'
|
||||
|
||||
@@ -937,6 +937,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
) {
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -994,6 +1004,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
|
||||
// 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.
|
||||
if (
|
||||
cachedAgentForeground &&
|
||||
fallbackProcess !== null &&
|
||||
isAgentForegroundWrapperProcess(fallbackProcess)
|
||||
) {
|
||||
return cachedAgentForeground.processName
|
||||
}
|
||||
const activeStartupAgentForeground = getActiveStartupAgentForeground(now)
|
||||
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
|
||||
return activeStartupAgentForeground.processName
|
||||
|
||||
@@ -556,6 +556,34 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
}
|
||||
)
|
||||
|
||||
itWithBash(
|
||||
'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt',
|
||||
async () => {
|
||||
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
||||
// Minimal bash-preexec imitation (iTerm2/starship setups): re-arms its own
|
||||
// DEBUG trap from PROMPT_COMMAND at the first prompt — silencing Orca's
|
||||
// trap — and dispatches preexec_functions with the command as $1.
|
||||
writeFileSync(
|
||||
join(userDataPath, '.bash_profile'),
|
||||
[
|
||||
'preexec_functions=()',
|
||||
'__bp_preexec_invoke_exec() {',
|
||||
' [[ -n "${__bp_interactive_mode:-}" ]] || return',
|
||||
' __bp_interactive_mode=""',
|
||||
' local f',
|
||||
' for f in "${preexec_functions[@]}"; do "$f" "$BASH_COMMAND"; done',
|
||||
'}',
|
||||
"__bp_arm() { __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }",
|
||||
'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
|
||||
|
||||
expectBashOsc133Lifecycle(output)
|
||||
}
|
||||
)
|
||||
|
||||
itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => {
|
||||
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
||||
writeFileSync(
|
||||
|
||||
@@ -195,6 +195,23 @@ if [[ -n "$__orca_debug_trap_spec" ]]; then
|
||||
fi
|
||||
unset __orca_debug_trap_spec __orca_debug_trap_command
|
||||
unset -f __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command
|
||||
# Why: bash-preexec (pulled in by iTerm2/starship setups) re-arms its own DEBUG
|
||||
# trap at the first prompt, silencing ours. Register with its preexec_functions
|
||||
# array too. Bash holds exactly one DEBUG trap, so only one dispatch path is
|
||||
# ever live and C cannot double-emit; the __orca_in_command gate is a backstop.
|
||||
__orca_osc133_preexec_bp() {
|
||||
[[ -z "\${__orca_in_command:-}" ]] || return
|
||||
[[ -z "\${__orca_in_prompt_command:-}" ]] || return
|
||||
case "\${1:-}" in
|
||||
*__orca_osc133_precmd*|*__orca_osc133_prompt_done*|*__orca_prompt_mark*) return ;;
|
||||
esac
|
||||
printf "\\033]133;C\\007"
|
||||
__orca_in_command=1
|
||||
}
|
||||
case " \${preexec_functions[*]:-} " in
|
||||
*" __orca_osc133_preexec_bp "*) ;;
|
||||
*) preexec_functions+=(__orca_osc133_preexec_bp) ;;
|
||||
esac
|
||||
# Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile
|
||||
# commands as a foreground command and emits a fake C/D before the first prompt.
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
|
||||
@@ -5,12 +5,14 @@ import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracke
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
|
||||
const COMMAND_SETTLE_MS = 350
|
||||
const VISIBLE_PTY_SETTLE_MS = 350
|
||||
const WRAPPER_RESOLVE_RETRY_MS = 1200
|
||||
const SECOND_WRAPPER_RETRY_MS = 3500
|
||||
|
||||
describe('createPaneForegroundAgentTracker', () => {
|
||||
const readForegroundProcess = vi.fn<(ptyId: string) => Promise<string | null>>()
|
||||
const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>()
|
||||
const onConfirmedShellForeground = vi.fn<() => void>()
|
||||
let ptyId: string | null = 'pty-1'
|
||||
|
||||
function makeTracker(): ReturnType<typeof createPaneForegroundAgentTracker> {
|
||||
@@ -18,7 +20,8 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
getPtyId: () => ptyId,
|
||||
isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'),
|
||||
readForegroundProcess,
|
||||
publish
|
||||
publish,
|
||||
onConfirmedShellForeground
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
vi.useFakeTimers()
|
||||
readForegroundProcess.mockReset()
|
||||
publish.mockReset()
|
||||
onConfirmedShellForeground.mockReset()
|
||||
ptyId = 'pty-1'
|
||||
})
|
||||
|
||||
@@ -52,6 +56,72 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false })
|
||||
})
|
||||
|
||||
it('reads the foreground for a visible PTY so restored running Codex panes regain identity', async () => {
|
||||
readForegroundProcess.mockResolvedValue('codex')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onVisiblePtyBound()
|
||||
expect(readForegroundProcess).not.toHaveBeenCalled()
|
||||
|
||||
await flushSettleRead(VISIBLE_PTY_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
})
|
||||
|
||||
it('does not retry or publish visible PTY reads for an idle shell foreground', async () => {
|
||||
readForegroundProcess.mockResolvedValue('zsh')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onVisiblePtyBound()
|
||||
await flushSettleRead(
|
||||
VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + 10_000
|
||||
)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets command-start sampling supersede a pending visible PTY read', async () => {
|
||||
readForegroundProcess.mockResolvedValue('codex')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onVisiblePtyBound()
|
||||
tracker.onCommandStarted()
|
||||
await flushSettleRead(VISIBLE_PTY_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
})
|
||||
|
||||
it('does not let visible PTY sampling downgrade pending command-start sampling', async () => {
|
||||
readForegroundProcess.mockResolvedValueOnce('bash').mockResolvedValueOnce('codex')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandStarted()
|
||||
tracker.onVisiblePtyBound()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
|
||||
await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
})
|
||||
|
||||
it('retries visible PTY reads only while a foreground wrapper may resolve to an agent', async () => {
|
||||
readForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('codex')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onVisiblePtyBound()
|
||||
await flushSettleRead(VISIBLE_PTY_SETTLE_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
|
||||
await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
})
|
||||
|
||||
it('re-reads on a bounded ladder while the read still sees an interpreter wrapper', async () => {
|
||||
// Why: daemon shell/helper→agent ancestry resolution has been observed to
|
||||
// take >1.5s for real node-wrapped CLIs, so the ladder gets two re-reads.
|
||||
@@ -124,16 +194,159 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
expect(readForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a pending read when the command finishes first', async () => {
|
||||
it('confirms a rapid command start->finish instead of trusting the D', async () => {
|
||||
// Why: a leaked nested-shell 133;C->133;D pair (or a fast real command)
|
||||
// cancels the command-start read; confirm the foreground so a still-running
|
||||
// manual agent with no launchAgent/hook keeps its identity.
|
||||
readForegroundProcess.mockResolvedValue('claude')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandStarted()
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS)
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false })
|
||||
expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true })
|
||||
})
|
||||
|
||||
it('confirms duplicate 133;D pairs instead of fast-pathing past the pending confirmation', async () => {
|
||||
// Why: user shell integrations (iTerm/VS Code) double up Orca's OSC 133, so
|
||||
// every D arrives twice ~50ms apart. The second D cancels the first D's
|
||||
// confirming read; it must reschedule the confirmation, not trust the D.
|
||||
readForegroundProcess.mockResolvedValue('grok')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandStarted()
|
||||
tracker.onCommandStarted()
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(50)
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'grok', shellForeground: false })
|
||||
expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true })
|
||||
})
|
||||
|
||||
it('still marks shell without a read for a duplicate D pair on an idle pane', async () => {
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(50)
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
// Why: with no command read in flight the first D takes the no-RPC path,
|
||||
// so its duplicate has nothing to re-confirm and stays RPC-free too.
|
||||
expect(readForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(publish).toHaveBeenCalledTimes(2)
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true })
|
||||
})
|
||||
|
||||
it('marks shell on a rapid command start->finish when the foreground is a shell', async () => {
|
||||
readForegroundProcess.mockResolvedValue('zsh')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandStarted()
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true })
|
||||
})
|
||||
|
||||
it('confirms the foreground before clearing a pane an agent has owned', async () => {
|
||||
readForegroundProcess.mockResolvedValue('codex')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandStarted()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
|
||||
publish.mockClear()
|
||||
readForegroundProcess.mockClear()
|
||||
tracker.onCommandFinished()
|
||||
// Why: a leaked nested-shell 133;D must not clear Codex before the read.
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true })
|
||||
})
|
||||
|
||||
it('marks shell foreground when the confirming read shows the agent is gone', async () => {
|
||||
readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandStarted()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
publish.mockClear()
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenLastCalledWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true })
|
||||
})
|
||||
|
||||
it('signals confirmed shell foreground only when the read proves the agent exited', async () => {
|
||||
// Reads: command-start=codex, first finish=codex (still running), second finish=zsh (exited).
|
||||
readForegroundProcess
|
||||
.mockResolvedValueOnce('codex')
|
||||
.mockResolvedValueOnce('codex')
|
||||
.mockResolvedValueOnce('zsh')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandStarted()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
// A leaked nested-shell 133;D while Codex still owns the foreground: no signal.
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
expect(onConfirmedShellForeground).not.toHaveBeenCalled()
|
||||
|
||||
// Genuine exit -> read sees a shell -> signal fires exactly once.
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
expect(onConfirmedShellForeground).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns to the no-RPC finished path after the agent is confirmed gone', async () => {
|
||||
readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh')
|
||||
const tracker = makeTracker()
|
||||
|
||||
tracker.onCommandStarted()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
tracker.onCommandFinished()
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
publish.mockClear()
|
||||
readForegroundProcess.mockClear()
|
||||
tracker.onCommandFinished()
|
||||
|
||||
expect(readForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true })
|
||||
expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true })
|
||||
})
|
||||
|
||||
it('confirms a command finished for a launch-known agent pane before any read', async () => {
|
||||
readForegroundProcess.mockResolvedValue('codex')
|
||||
const tracker = createPaneForegroundAgentTracker({
|
||||
getPtyId: () => ptyId,
|
||||
isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'),
|
||||
readForegroundProcess,
|
||||
publish,
|
||||
hasKnownAgentIdentity: () => true
|
||||
})
|
||||
|
||||
tracker.onCommandFinished()
|
||||
// Why: launchAgent/hook identity means the 133;D is confirmed, not trusted.
|
||||
expect(publish).not.toHaveBeenCalled()
|
||||
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
})
|
||||
|
||||
it('never reads or publishes for remote or ssh panes', async () => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
recognizeAgentProcess
|
||||
} from '../../../../shared/agent-process-recognition'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
|
||||
// Why: the read must land after the shell has exec'd the command; and when it
|
||||
@@ -6,7 +9,9 @@ import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-ag
|
||||
// asynchronously — observed to take >1.5s for real node-wrapped CLIs — so
|
||||
// give its cache two bounded re-reads, not an open-ended retry loop.
|
||||
const COMMAND_SETTLE_MS = 350
|
||||
const VISIBLE_PTY_SETTLE_MS = 350
|
||||
const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 3500] as const
|
||||
type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished'
|
||||
|
||||
type PaneForegroundAgentTrackerDeps = {
|
||||
getPtyId: () => string | null
|
||||
@@ -15,22 +20,39 @@ type PaneForegroundAgentTrackerDeps = {
|
||||
isTrackablePtyId: (ptyId: string) => boolean
|
||||
readForegroundProcess: (ptyId: string) => Promise<string | null>
|
||||
publish: (entry: PaneForegroundAgentEntry) => void
|
||||
/** True when the pane is otherwise known to run an agent (launchAgent, live
|
||||
* hook status). Lets a restored agent pane confirm — rather than trust — a
|
||||
* 133;D before any command-start read has recorded its own evidence. */
|
||||
hasKnownAgentIdentity?: () => boolean
|
||||
/** Fired when a confirming read proves the foreground genuinely returned to a
|
||||
* shell (agent exited). Lets callers clear a stale agent-named tab title that
|
||||
* the shell never repaints. */
|
||||
onConfirmedShellForeground?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes process-table identity for a pane at OSC 133 command boundaries:
|
||||
* one foreground read when a command starts (that is when the foreground
|
||||
* changes), and a no-RPC shell-foreground mark when it finishes — 133;D is
|
||||
* the ONLY source of shell-foreground proof; reads never produce it.
|
||||
* changes), and a shell-foreground mark when it finishes. A 133;D is normally
|
||||
* the shell-foreground proof; for a pane an agent has owned it is confirmed by a
|
||||
* foreground read first, because a full-screen agent's nested command shells
|
||||
* leak their own 133;D onto the main PTY.
|
||||
*/
|
||||
export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTrackerDeps): {
|
||||
onVisiblePtyBound: () => void
|
||||
onCommandStarted: () => void
|
||||
onCommandFinished: () => void
|
||||
dispose: () => void
|
||||
} {
|
||||
let disposed = false
|
||||
let readTimer: number | null = null
|
||||
let readTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let scheduledReadReason: ForegroundReadReason | null = null
|
||||
let activeReadReason: ForegroundReadReason | null = null
|
||||
let readGeneration = 0
|
||||
// Why: a full-screen agent (Codex, etc.) runs nested command shells whose own
|
||||
// OSC 133;D leaks onto the main PTY. For a pane an agent has owned, that D is
|
||||
// not proof the prompt returned, so confirm the foreground before clearing.
|
||||
let hasForegroundAgentEvidence = false
|
||||
|
||||
const trackablePtyId = (): string | null => {
|
||||
const ptyId = deps.getPtyId()
|
||||
@@ -40,30 +62,53 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
const cancelPendingRead = (): void => {
|
||||
readGeneration += 1
|
||||
if (readTimer !== null) {
|
||||
window.clearTimeout(readTimer)
|
||||
clearTimeout(readTimer)
|
||||
readTimer = null
|
||||
}
|
||||
scheduledReadReason = null
|
||||
activeReadReason = null
|
||||
}
|
||||
|
||||
const scheduleRead = (delayMs: number, retryIndex: number): void => {
|
||||
const scheduleRead = (
|
||||
delayMs: number,
|
||||
retryIndex: number,
|
||||
reason: ForegroundReadReason
|
||||
): void => {
|
||||
const generation = readGeneration
|
||||
readTimer = window.setTimeout(() => {
|
||||
scheduledReadReason = reason
|
||||
readTimer = setTimeout(() => {
|
||||
readTimer = null
|
||||
void readForeground(generation, retryIndex)
|
||||
scheduledReadReason = null
|
||||
activeReadReason = reason
|
||||
void readForeground(generation, retryIndex, reason).finally(() => {
|
||||
if (generation === readGeneration && activeReadReason === reason) {
|
||||
activeReadReason = null
|
||||
}
|
||||
})
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
async function readForeground(generation: number, retryIndex: number): Promise<void> {
|
||||
async function readForeground(
|
||||
generation: number,
|
||||
retryIndex: number,
|
||||
reason: ForegroundReadReason
|
||||
): Promise<void> {
|
||||
const ptyId = trackablePtyId()
|
||||
if (disposed || generation !== readGeneration || !ptyId) {
|
||||
return
|
||||
}
|
||||
const processName = await deps.readForegroundProcess(ptyId).catch(() => null)
|
||||
let processName: string | null = null
|
||||
try {
|
||||
processName = await deps.readForegroundProcess(ptyId)
|
||||
} catch {
|
||||
processName = null
|
||||
}
|
||||
if (disposed || generation !== readGeneration) {
|
||||
return
|
||||
}
|
||||
const recognized = recognizeAgentProcess(processName)
|
||||
if (recognized) {
|
||||
hasForegroundAgentEvidence = true
|
||||
deps.publish({ agent: recognized.agent, shellForeground: false })
|
||||
return
|
||||
}
|
||||
@@ -72,14 +117,50 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
// a nested one (sh/bash without integration); marking shell-foreground
|
||||
// would suppress live title identity. Only 133;D proves the prompt.
|
||||
const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex]
|
||||
if (retryDelay !== undefined && processName) {
|
||||
scheduleRead(retryDelay, retryIndex + 1)
|
||||
const shouldRetry =
|
||||
retryDelay !== undefined &&
|
||||
processName &&
|
||||
(reason === 'command' || isAgentForegroundWrapperProcess(processName))
|
||||
if (shouldRetry) {
|
||||
scheduleRead(retryDelay, retryIndex + 1, reason)
|
||||
return
|
||||
}
|
||||
deps.publish({ agent: null, shellForeground: false })
|
||||
if (reason === 'command') {
|
||||
deps.publish({ agent: null, shellForeground: false })
|
||||
return
|
||||
}
|
||||
if (reason === 'command-finished') {
|
||||
// Why: the 133;D fired AND the foreground shows no agent — together that is
|
||||
// real prompt proof, so the agent truly exited. Reset the evidence so the
|
||||
// pane's ordinary shell commands go back to the no-RPC finished path.
|
||||
hasForegroundAgentEvidence = false
|
||||
deps.publish({ agent: null, shellForeground: true })
|
||||
// Why: confirmed exit — let callers clear a stale agent title the shell
|
||||
// won't repaint (a plain `codex`/`grok` leaves its OSC title behind).
|
||||
deps.onConfirmedShellForeground?.()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onVisiblePtyBound() {
|
||||
// Why: command-start and command-finished reads own the exit decision;
|
||||
// visibility recovery is lower-authority and must never cancel them.
|
||||
if (
|
||||
scheduledReadReason === 'command' ||
|
||||
activeReadReason === 'command' ||
|
||||
scheduledReadReason === 'command-finished' ||
|
||||
activeReadReason === 'command-finished'
|
||||
) {
|
||||
return
|
||||
}
|
||||
cancelPendingRead()
|
||||
if (!trackablePtyId()) {
|
||||
return
|
||||
}
|
||||
// Why: restored/manual agent panes can become visible while Codex is
|
||||
// already foreground, so no OSC 133 command-start event will seed the tab icon.
|
||||
scheduleRead(VISIBLE_PTY_SETTLE_MS, 0, 'visible-pty')
|
||||
},
|
||||
onCommandStarted() {
|
||||
cancelPendingRead()
|
||||
if (!trackablePtyId()) {
|
||||
@@ -88,14 +169,39 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
// Why: the foreground left the prompt the moment C fired — stale
|
||||
// shell-foreground evidence must not clear the command that just started.
|
||||
deps.publish({ agent: null, shellForeground: false })
|
||||
scheduleRead(COMMAND_SETTLE_MS, 0)
|
||||
scheduleRead(COMMAND_SETTLE_MS, 0, 'command')
|
||||
},
|
||||
onCommandFinished() {
|
||||
// Why: a rapid 133;C→133;D pair cancels the command-start read before it
|
||||
// can identify the foreground — that pair is exactly a leaked nested-shell
|
||||
// command under a full-screen agent (or a fast real shell command), so on a
|
||||
// no-identity pane confirm it rather than trusting the D as a prompt return.
|
||||
// A pending confirming read counts too: user shell integrations double up
|
||||
// Orca's OSC 133, and the duplicate D must re-confirm, not fast-path past
|
||||
// the in-flight confirmation it just cancelled.
|
||||
const commandReadWasPending =
|
||||
scheduledReadReason === 'command' ||
|
||||
activeReadReason === 'command' ||
|
||||
scheduledReadReason === 'command-finished' ||
|
||||
activeReadReason === 'command-finished'
|
||||
cancelPendingRead()
|
||||
if (!trackablePtyId()) {
|
||||
return
|
||||
}
|
||||
deps.publish({ agent: null, shellForeground: true })
|
||||
// Why: trust the 133;D and mark shell without an RPC only when nothing hints
|
||||
// at an agent — no prior agent evidence, no launch/hook identity, and no
|
||||
// command-start read racing this finish.
|
||||
if (
|
||||
!hasForegroundAgentEvidence &&
|
||||
deps.hasKnownAgentIdentity?.() !== true &&
|
||||
!commandReadWasPending
|
||||
) {
|
||||
deps.publish({ agent: null, shellForeground: true })
|
||||
return
|
||||
}
|
||||
// Why: confirm the foreground before clearing — if the agent still owns it,
|
||||
// the read republishes its identity; only a genuine shell result clears it.
|
||||
scheduleRead(COMMAND_SETTLE_MS, 0, 'command-finished')
|
||||
},
|
||||
dispose() {
|
||||
disposed = true
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
beginAgentStartupDeliveryAttempt,
|
||||
resetAgentStartupDelayedDeliveryForTests
|
||||
} from '@/lib/agent-startup-delayed-delivery'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
|
||||
// Repro command:
|
||||
// pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw"
|
||||
@@ -169,6 +170,7 @@ type StoreState = {
|
||||
consumePendingSnapshot: ReturnType<typeof vi.fn>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
agentStatusByPaneKey: Record<string, unknown>
|
||||
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
|
||||
sleepingAgentSessionsByPaneKey: Record<string, unknown>
|
||||
agentLaunchConfigByPaneKey: Record<string, { launchConfig: unknown }>
|
||||
getAgentLaunchConfigForStatusEntry: ReturnType<typeof vi.fn>
|
||||
@@ -737,6 +739,7 @@ describe('connectPanePty', () => {
|
||||
consumePendingSnapshot: vi.fn(() => null),
|
||||
runtimePaneTitlesByTabId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
paneForegroundAgentByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
agentLaunchConfigByPaneKey: {},
|
||||
getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => {
|
||||
@@ -771,8 +774,12 @@ describe('connectPanePty', () => {
|
||||
),
|
||||
removeAgentStatus: vi.fn(),
|
||||
dropAgentStatus: vi.fn(),
|
||||
setPaneForegroundAgent: vi.fn(),
|
||||
clearPaneForegroundAgent: vi.fn(),
|
||||
setPaneForegroundAgent: vi.fn((paneKey: string, entry: PaneForegroundAgentEntry) => {
|
||||
mockStoreState.paneForegroundAgentByPaneKey[paneKey] = entry
|
||||
}),
|
||||
clearPaneForegroundAgent: vi.fn((paneKey: string) => {
|
||||
delete mockStoreState.paneForegroundAgentByPaneKey[paneKey]
|
||||
}),
|
||||
markTerminalTabUnread: vi.fn(),
|
||||
markTerminalPaneUnread: vi.fn(),
|
||||
markAgentCompletionPaneUnread: vi.fn()
|
||||
@@ -13949,6 +13956,238 @@ describe('connectPanePty', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('visible foreground agent sampling (perf)', () => {
|
||||
const VISIBLE_PTY_SETTLE_MS = 350
|
||||
|
||||
// Why: every connectPanePty binding in this file shares the tab-1/LEAF_1 pane
|
||||
// key, and an undisposed reattach binding elsewhere can resolve a foreground
|
||||
// read into this test's store slice. Give each sampling case its own tabId so
|
||||
// no other test's publish can pollute the pane identity it asserts on.
|
||||
async function connectRestoredPaneForForegroundSampling(
|
||||
args: {
|
||||
ptyId?: string
|
||||
tabId?: string
|
||||
isVisibleRef?: { current: boolean }
|
||||
} = {}
|
||||
): Promise<{
|
||||
binding: { noteVisibilityResume: () => void }
|
||||
deps: ReturnType<typeof createDeps>
|
||||
transport: MockTransport
|
||||
cacheKey: string
|
||||
}> {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const ptyId = args.ptyId ?? 'tab-pty'
|
||||
const tabId = args.tabId ?? `tab-${ptyId}`
|
||||
const transport = createMockTransport(ptyId)
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
||||
return sessionId ? { id: sessionId } : null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const deps = createDeps({
|
||||
tabId,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: ptyId },
|
||||
...(args.isVisibleRef ? { isVisibleRef: args.isVisibleRef } : {})
|
||||
})
|
||||
const binding = connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
deps as never
|
||||
) as unknown as { noteVisibilityResume: () => void }
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await flushAsyncTicks(20)
|
||||
return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) }
|
||||
}
|
||||
|
||||
async function advanceVisibleForegroundRead(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS)
|
||||
await flushAsyncTicks()
|
||||
}
|
||||
|
||||
function foregroundReadCallsFor(ptyId: string): unknown[][] {
|
||||
return vi
|
||||
.mocked(window.api.pty.getForegroundProcess)
|
||||
.mock.calls.filter(([calledPtyId]) => calledPtyId === ptyId)
|
||||
}
|
||||
|
||||
it('does not inspect foreground process for a fresh visible spawn', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const ptyId = 'pty-fresh-visible-no-sample'
|
||||
transportFactoryQueue.push(createMockTransport(ptyId))
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {}
|
||||
}
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await flushAsyncTicks(20)
|
||||
const spawnHandler = createdTransportOptions[0]?.onPtySpawn as
|
||||
| ((ptyId: string) => void)
|
||||
| undefined
|
||||
spawnHandler?.(ptyId)
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('samples exactly one visible restored PTY with no stronger identity signal', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const ptyId = 'pty-restored-visible-sample'
|
||||
|
||||
const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId })
|
||||
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
||||
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]])
|
||||
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
|
||||
agent: 'codex',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not sample hidden restored PTYs', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const ptyId = 'pty-hidden-restored-no-sample'
|
||||
|
||||
await connectRestoredPaneForForegroundSampling({
|
||||
ptyId,
|
||||
isVisibleRef: { current: false }
|
||||
})
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('samples once when an identityless hidden pane resumes visible', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const isVisibleRef = { current: false }
|
||||
const ptyId = 'pty-hidden-then-visible-sample'
|
||||
const { binding } = await connectRestoredPaneForForegroundSampling({ ptyId, isVisibleRef })
|
||||
await advanceVisibleForegroundRead()
|
||||
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
||||
|
||||
isVisibleRef.current = true
|
||||
binding.noteVisibilityResume()
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]])
|
||||
})
|
||||
|
||||
it('does not sample when launch metadata already supplies tab identity', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const ptyId = 'pty-launch-identity-no-sample'
|
||||
const tabId = `tab-${ptyId}`
|
||||
mockStoreState.tabsByWorktree = {
|
||||
'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }]
|
||||
}
|
||||
|
||||
await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not sample when a live hook row already supplies pane identity', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const ptyId = 'pty-hook-identity-no-sample'
|
||||
const tabId = `tab-${ptyId}`
|
||||
mockStoreState.agentStatusByPaneKey[makePaneKey(tabId, LEAF_1)] = {
|
||||
state: 'working',
|
||||
agentType: 'codex'
|
||||
}
|
||||
|
||||
await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not sample when process identity is already known', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const ptyId = 'pty-process-identity-no-sample'
|
||||
const tabId = `tab-${ptyId}`
|
||||
mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = {
|
||||
agent: 'codex',
|
||||
shellForeground: false
|
||||
}
|
||||
|
||||
await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not re-sample once 133;D proved the pane is at a shell prompt', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const ptyId = 'pty-shell-foreground-no-sample'
|
||||
const tabId = `tab-${ptyId}`
|
||||
mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = {
|
||||
agent: null,
|
||||
shellForeground: true
|
||||
}
|
||||
|
||||
await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-samples a shell-marked pane a launch agent still owns', async () => {
|
||||
// Why: a reattach or a full-screen agent's leaked nested-shell 133;D leaves
|
||||
// shellForeground on a launchAgent pane, suppressing its icon. The launch
|
||||
// metadata means an agent is expected, so re-read to recover its identity.
|
||||
vi.useFakeTimers()
|
||||
const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
||||
getForegroundProcess.mockResolvedValue('codex')
|
||||
const ptyId = 'pty-shell-foreground-launch-agent-sample'
|
||||
const tabId = `tab-${ptyId}`
|
||||
mockStoreState.tabsByWorktree = {
|
||||
'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }]
|
||||
}
|
||||
mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = {
|
||||
agent: null,
|
||||
shellForeground: true
|
||||
}
|
||||
|
||||
const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
||||
await advanceVisibleForegroundRead()
|
||||
|
||||
expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]])
|
||||
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
|
||||
agent: 'codex',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal input liveness IPC gating (perf)', () => {
|
||||
// Why (perf regression guard): listSessions() is a renderer→main→daemon
|
||||
// round-trip over every live session. Terminal input must never start that
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IBuffer, IDisposable } from '@xterm/xterm'
|
||||
import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor'
|
||||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
agentTypeToIconAgent,
|
||||
isGeminiTerminalTitle,
|
||||
isClaudeAgent
|
||||
} from '@/lib/agent-status'
|
||||
@@ -164,6 +165,7 @@ import {
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
|
||||
import {
|
||||
isExpectedAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
@@ -666,6 +668,10 @@ let inactiveForegroundImmediateBudgetWindowStart = 0
|
||||
type PanePtyBinding = IDisposable & {
|
||||
syncProcessTracking: () => void
|
||||
noteVisibilityResume: () => void
|
||||
/** Re-sample process identity when the pane gains intra-tab focus: the tab
|
||||
* icon follows the active leaf, and a shell-marked entry on a still-running
|
||||
* agent pane has no OSC boundary left to correct it. */
|
||||
sampleForegroundAgentOnFocus: () => void
|
||||
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
|
||||
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
|
||||
}
|
||||
@@ -1751,12 +1757,81 @@ export function connectPanePty(
|
||||
}
|
||||
return pendingWrite.then(() => interruptInference.flushPending())
|
||||
}
|
||||
// Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a
|
||||
// full-screen agent's leaked nested-shell 133;D can't clear its tab identity,
|
||||
// even on a restore where no command-start read has recorded evidence yet.
|
||||
const paneHasKnownAgentIdentity = (): boolean => {
|
||||
const state = useAppStore.getState()
|
||||
const entry = state.agentStatusByPaneKey[cacheKey]
|
||||
if (entry?.state !== 'done' && agentTypeToIconAgent(entry?.agentType)) {
|
||||
return true
|
||||
}
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === deps.tabId
|
||||
)
|
||||
return Boolean(
|
||||
tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent
|
||||
)
|
||||
}
|
||||
// Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints
|
||||
// it on exit, so a confirmed return-to-shell must clear a title that still
|
||||
// names an agent — otherwise the tab reads "grok" over a bare prompt. Only
|
||||
// reset an agent-named title; user/shell-set titles are left untouched.
|
||||
const clearStaleAgentTabTitleOnConfirmedShell = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === deps.tabId
|
||||
)
|
||||
const title = currentTitle ?? tab?.title
|
||||
if (!title || resolveExplicitTerminalTitleAgentType(title) === null) {
|
||||
return
|
||||
}
|
||||
const neutralTitle = neutralTerminalTitle()
|
||||
deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle)
|
||||
if (manager.getActivePane()?.id === pane.id) {
|
||||
deps.updateTabTitle(deps.tabId, neutralTitle)
|
||||
}
|
||||
}
|
||||
const paneForegroundAgentTracker = createPaneForegroundAgentTracker({
|
||||
getPtyId: () => transport.getPtyId(),
|
||||
isTrackablePtyId: (id) => !isRemoteRuntimePtyId(id) && parseAppSshPtyId(id) === null,
|
||||
readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id),
|
||||
publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry)
|
||||
publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry),
|
||||
hasKnownAgentIdentity: paneHasKnownAgentIdentity,
|
||||
onConfirmedShellForeground: clearStaleAgentTabTitleOnConfirmedShell
|
||||
})
|
||||
const sampleVisiblePaneForegroundAgent = (): void => {
|
||||
if (!deps.isVisibleRef.current) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const foreground = state.paneForegroundAgentByPaneKey[cacheKey]
|
||||
// Why: live process identity already paints the icon — nothing to read.
|
||||
if (foreground?.agent) {
|
||||
return
|
||||
}
|
||||
const entry = state.agentStatusByPaneKey[cacheKey]
|
||||
if (entry?.state !== 'done' && agentTypeToIconAgent(entry?.agentType)) {
|
||||
return
|
||||
}
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === deps.tabId
|
||||
)
|
||||
const expectsAgent = Boolean(
|
||||
tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent
|
||||
)
|
||||
// Why: with no shell mark yet, launchAgent bootstrap already paints the icon,
|
||||
// so a read is pointless. Otherwise probe the foreground: a shell mark (from a
|
||||
// reattach or a full-screen agent's leaked nested-shell 133;D) can hide a
|
||||
// still-running agent — including a manually started one with no launchAgent or
|
||||
// hook, which no later 133;C will reseed. A genuinely idle shell just returns a
|
||||
// shell name and publishes nothing, so the probe is self-limiting.
|
||||
if (!foreground?.shellForeground && expectsAgent) {
|
||||
return
|
||||
}
|
||||
paneForegroundAgentTracker.onVisiblePtyBound()
|
||||
}
|
||||
const commandLifecycle = createTerminalCommandLifecycle({
|
||||
onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(),
|
||||
onCommandFinished: () => {
|
||||
@@ -1829,6 +1904,14 @@ export function connectPanePty(
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: user shell frameworks (bash-preexec/iTerm2) can replace Orca's
|
||||
// OSC 133;C hook, so a manually launched agent produces no command-start
|
||||
// signal at all. Enter at a shell-foreground prompt is the user-side
|
||||
// equivalent; the sample is gated to panes with no live agent identity
|
||||
// and publishes nothing for an idle shell.
|
||||
if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
}
|
||||
deps.clearTerminalTabUnread(deps.tabId)
|
||||
deps.clearTerminalPaneUnread(cacheKey)
|
||||
deps.clearWorktreeUnread(deps.worktreeId)
|
||||
@@ -2225,7 +2308,11 @@ export function connectPanePty(
|
||||
}
|
||||
const bindActivePanePty = (
|
||||
ptyId: string,
|
||||
options: { seedInitialAgentStatus?: boolean; updateTabPtyId?: 'always' | 'if-missing' } = {}
|
||||
options: {
|
||||
seedInitialAgentStatus?: boolean
|
||||
updateTabPtyId?: 'always' | 'if-missing'
|
||||
sampleVisibleForegroundAgent?: boolean
|
||||
} = {}
|
||||
): void => {
|
||||
if (activePanePtyBinding && activePanePtyBinding !== ptyId) {
|
||||
reportPanePtyVisibility(activePanePtyBinding, false)
|
||||
@@ -2248,6 +2335,11 @@ export function connectPanePty(
|
||||
// frame-level sync often runs before that async result arrives.
|
||||
scheduleRuntimeGraphSync()
|
||||
agentCompletionCoordinator.startProcessTracking()
|
||||
// Why: fresh spawns receive future OSC command-start events; only adopted or
|
||||
// restored PTYs may already be inside Codex with no new foreground signal.
|
||||
if (options.sampleVisibleForegroundAgent === true) {
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
}
|
||||
}
|
||||
|
||||
const onPtySpawn = (ptyId: string): void => {
|
||||
@@ -3657,7 +3749,10 @@ export function connectPanePty(
|
||||
) {
|
||||
// Why: daemon createOrAttach can turn an apparent fresh spawn into
|
||||
// a reattach; the transport skips onPtySpawn there to preserve recency.
|
||||
bindActivePanePty(resolvedPtyId, { updateTabPtyId: 'if-missing' })
|
||||
bindActivePanePty(resolvedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
}
|
||||
if (resolvedPtyId) {
|
||||
reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows)
|
||||
@@ -5223,6 +5318,7 @@ export function connectPanePty(
|
||||
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
|
||||
deps.updateTabPtyId(deps.tabId, ptyId)
|
||||
agentCompletionCoordinator.startProcessTracking()
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
|
||||
// Why: mobile terminal streaming needs the exact screen state from
|
||||
// xterm.js. The shared helper installs both the SerializeAddon-backed
|
||||
@@ -5784,7 +5880,10 @@ export function connectPanePty(
|
||||
onError: reportError
|
||||
}
|
||||
})
|
||||
bindActivePanePty(attachPtyId, { updateTabPtyId: 'if-missing' })
|
||||
bindActivePanePty(attachPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (attachPtyId === eagerLivePtyId) {
|
||||
registerPaneSerializerFor(attachPtyId)
|
||||
}
|
||||
@@ -5838,7 +5937,10 @@ export function connectPanePty(
|
||||
})
|
||||
// Why: this path reuses a PTY spawned by an earlier mount, so no
|
||||
// later spawn event will bind this remounted pane's DOM/container.
|
||||
bindActivePanePty(spawnedPtyId, { updateTabPtyId: 'if-missing' })
|
||||
bindActivePanePty(spawnedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
reportError(err instanceof Error ? err.message : String(err))
|
||||
@@ -5948,6 +6050,10 @@ export function connectPanePty(
|
||||
noteVisibilityResume() {
|
||||
ptySizeReassertion.request({ fit: false })
|
||||
consumeHibernatedAgentWake()
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
},
|
||||
sampleForegroundAgentOnFocus() {
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
},
|
||||
reconcileIfSessionDead,
|
||||
reconcileIfSessionMissing,
|
||||
|
||||
@@ -1281,6 +1281,13 @@ export function useTerminalPaneLifecycle({
|
||||
persistLayoutSnapshot()
|
||||
}
|
||||
reportActiveRendererPtyForPane(paneTransportsRef.current, pane.id)
|
||||
// Why: the tab icon resolves from the active leaf's process identity;
|
||||
// focusing a shell-marked pane whose agent is still running must
|
||||
// re-sample it, since no further OSC boundary will.
|
||||
const focusedBinding = panePtyBindings.get(pane.id) as
|
||||
| (IDisposable & { sampleForegroundAgentOnFocus?: () => void })
|
||||
| undefined
|
||||
focusedBinding?.sampleForegroundAgentOnFocus?.()
|
||||
// Why: when the user switches focus between split panes, update the
|
||||
// tab title to the newly active pane's last-known title so the tab
|
||||
// label reflects the focused agent — not a stale title from the
|
||||
|
||||
Reference in New Issue
Block a user