Fix terminal tab icon detection for foreground agents (#7476)
* 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. * Fix terminal shell integration and DEBUG trap chaining in bash Chain external DEBUG traps (e.g., starship, bash-preexec) dynamically in a prompt epilogue rather than using static hooks. This ensures our own DEBUG trap survives re-arming by third-party frameworks and reliably emits OSC 133 sequences. Additionally: - Print the shell-ready marker inside the precmd hook to avoid modifying and displacing hooks at the end of PROMPT_COMMAND. - Skip redundant foreground state checks in the PTY connection when a shell prompt is already active and no background agent is expected. * Fix terminal shell integration and DEBUG trap chaining in bash Chain external DEBUG traps (e.g., starship, bash-preexec) dynamically in a prompt epilogue rather than using static hooks. This ensures our own DEBUG trap survives re-arming by third-party frameworks and reliably emits OSC 133 sequences. Additionally: - Print the shell-ready marker inside the precmd hook to avoid modifying and displacing hooks at the end of PROMPT_COMMAND. - Skip redundant foreground state checks in the PTY connection when a shell prompt is already active and no background agent is expected. * Remove debug artifacts from codex icon investigation Drop temporary diff, handoff notes, and screenshot evidence that were accidentally committed during debugging. * Mirror upstream bash-preexec so preexec dispatch test reflects real command The naive $BASH_COMMAND imitation captured Orca's chained __orca_osc133_epilogue instead of the user command, failing on CI. Read the command from history like real bash-preexec does. Co-authored-by: Orca <help@stably.ai> * Fix promisify overload typecheck error in windowsHide wrapper test The wrapper's static type (...args: unknown[]) => unknown makes util.promisify resolve to its zero-arg callback overload, so calling the promisified function with args tripped TS2554 under typecheck. Type the promisified result to the variadic custom-symbol impl the wrapper copies at runtime. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
dacb84bbb5
commit
b47474b036
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -941,6 +941,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
|
||||
}
|
||||
|
|
@ -998,6 +1008,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
|
||||
|
|
|
|||
|
|
@ -515,11 +515,16 @@ describePosix('daemon shell-ready launch config', () => {
|
|||
|
||||
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
|
||||
expect(bashRc).toContain('printf "\\033]133;C\\007"')
|
||||
// precmd is prepended (captures $? first) and the epilogue is appended last,
|
||||
// so a framework that must be last in PROMPT_COMMAND stays between them.
|
||||
expect(bashRc).toContain(
|
||||
'PROMPT_COMMAND="__orca_osc133_precmd${PROMPT_COMMAND:+;${PROMPT_COMMAND}}"'
|
||||
'PROMPT_COMMAND="__orca_osc133_precmd${PROMPT_COMMAND:+;${PROMPT_COMMAND}};__orca_osc133_epilogue"'
|
||||
)
|
||||
expect(bashRc.indexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan(
|
||||
bashRc.indexOf('if [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then')
|
||||
// The final DEBUG arming runs after PROMPT_COMMAND setup so the rcfile's own
|
||||
// commands are not mistaken for a foreground command (lastIndexOf skips the
|
||||
// identical re-arm inside __orca_osc133_epilogue).
|
||||
expect(bashRc.lastIndexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan(
|
||||
bashRc.indexOf('PROMPT_COMMAND="__orca_osc133_precmd')
|
||||
)
|
||||
expect(zshrc).toContain('printf "\\033]133;D;%s\\007"')
|
||||
expect(zshrc).toContain('printf "\\033]133;C\\007"')
|
||||
|
|
@ -556,6 +561,82 @@ 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(
|
||||
'dispatches a non-empty preexec_functions against the real command, not Orca hooks',
|
||||
async () => {
|
||||
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
||||
// Why: Orca's epilogue captures bash-preexec's re-armed DEBUG trap and
|
||||
// chains it. A real preexec callback must fire against the user's command —
|
||||
// not __orca_osc133_epilogue. Mirror upstream bash-preexec faithfully: it
|
||||
// enables `functrace` (so Orca's `trap -p DEBUG` capture sees its trap),
|
||||
// defers that install to the first prompt via PROMPT_COMMAND, and reads the
|
||||
// command from `history` (so DEBUG fires on prompt hooks never dispatch a
|
||||
// phantom). The naive `$BASH_COMMAND` imitation does none of these.
|
||||
writeFileSync(
|
||||
join(userDataPath, '.bash_profile'),
|
||||
[
|
||||
'preexec_functions=(__user_preexec)',
|
||||
'__user_preexec() { printf \'USER_PREEXEC:%s\\n\' "$1"; }',
|
||||
'__bp_inside=0',
|
||||
'__bp_last_hist=""',
|
||||
'__bp_preexec_invoke_exec() {',
|
||||
' (( __bp_inside > 0 )) && return',
|
||||
' [[ -n "${__bp_interactive_mode:-}" ]] || return',
|
||||
' local __bp_inside=1',
|
||||
' local this_command',
|
||||
' this_command="$(builtin history 1)"',
|
||||
' this_command="${this_command#"${this_command%%[![:space:]]*}"}"',
|
||||
' this_command="${this_command#* }"',
|
||||
' this_command="${this_command#"${this_command%%[![:space:]]*}"}"',
|
||||
' [[ -n "$this_command" && "$this_command" != "$__bp_last_hist" ]] || return',
|
||||
' __bp_last_hist="$this_command"',
|
||||
' __bp_interactive_mode=""',
|
||||
' local f',
|
||||
' for f in "${preexec_functions[@]}"; do "$f" "$this_command"; done',
|
||||
'}',
|
||||
"__bp_arm() { set -o functrace; __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)
|
||||
expect(output).toContain('USER_PREEXEC:true')
|
||||
expect(output).toContain('USER_PREEXEC:false')
|
||||
expect(output).not.toContain('USER_PREEXEC:__orca_osc133')
|
||||
expect(output).not.toContain('USER_PREEXEC:__bp_')
|
||||
}
|
||||
)
|
||||
|
||||
itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => {
|
||||
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
||||
writeFileSync(
|
||||
|
|
|
|||
|
|
@ -135,9 +135,10 @@ __orca_osc133_precmd() {
|
|||
unset __orca_in_command
|
||||
fi
|
||||
printf "\\033]133;A\\007"
|
||||
}
|
||||
__orca_osc133_prompt_done() {
|
||||
unset __orca_in_prompt_command
|
||||
# Why: emit the shell-ready marker here (not a trailing PROMPT_COMMAND entry)
|
||||
# so a framework that must be last in PROMPT_COMMAND — bash-preexec — is not
|
||||
# displaced by one of Orca's own hooks.
|
||||
[[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]] && printf "${SHELL_READY_MARKER}"
|
||||
}
|
||||
__orca_run_user_debug_trap() {
|
||||
if [[ -n "\${__orca_user_debug_trap:-}" ]]; then
|
||||
|
|
@ -146,17 +147,43 @@ __orca_run_user_debug_trap() {
|
|||
}
|
||||
__orca_osc133_preexec() {
|
||||
__orca_run_user_debug_trap
|
||||
# Why: a framework (bash-preexec/starship) may replace our DEBUG trap at the
|
||||
# first prompt; __orca_osc133_epilogue re-takes it each prompt and stores the
|
||||
# framework's trap here, so the framework's own preexec still runs while our
|
||||
# command-start C survives its re-arm.
|
||||
if [[ -n "\${__orca_chained_debug_trap:-}" ]]; then
|
||||
eval "$__orca_chained_debug_trap" || true
|
||||
fi
|
||||
[[ -z "\${__orca_in_prompt_command:-}" ]] || return
|
||||
# Why: bash DEBUG fires for every simple command, including PROMPT_COMMAND
|
||||
# bodies. Skip our own prompt-time helpers so they don't mark the shell as
|
||||
# "in command" before the prompt has even drawn.
|
||||
# Why: a chained trap can invoke us more than once for a single command, so
|
||||
# emit C only on the first fire (the __orca_in_command gate), and never for a
|
||||
# prompt-time hook — ours or bash-preexec's __bp_* helpers.
|
||||
[[ -z "\${__orca_in_command:-}" ]] || return
|
||||
case "$BASH_COMMAND" in
|
||||
*__orca_osc133_precmd*|*__orca_osc133_prompt_done*|*__orca_prompt_mark*) return ;;
|
||||
*__orca_osc133_*|*__bp_*) return ;;
|
||||
esac
|
||||
printf "\\033]133;C\\007"
|
||||
__orca_in_command=1
|
||||
}
|
||||
# Why: prepend so we capture $? before the user's PROMPT_COMMAND chain mutates it.
|
||||
# Why: runs LAST every prompt — closes the prompt window (so command starts emit
|
||||
# C) and re-arms our single DEBUG trap. A framework that replaced DEBUG at the
|
||||
# first prompt is captured and chained rather than discarded, so it keeps working
|
||||
# while its re-arm can no longer silence Orca's command-start signal.
|
||||
__orca_osc133_epilogue() {
|
||||
unset __orca_in_prompt_command
|
||||
local __orca_spec="$(trap -p DEBUG)"
|
||||
case "$__orca_spec" in
|
||||
"" | *__orca_osc133_preexec* ) __orca_chained_debug_trap="" ;;
|
||||
* )
|
||||
__orca_spec="\${__orca_spec#trap -- }"
|
||||
__orca_spec="\${__orca_spec% DEBUG}"
|
||||
eval "__orca_chained_debug_trap=$__orca_spec"
|
||||
;;
|
||||
esac
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
}
|
||||
# Why: normalize an array PROMPT_COMMAND (bash 5.1+) to a string so prepend/append
|
||||
# below is uniform, and capture $? in precmd before the user's chain mutates it.
|
||||
__orca_normalize_prompt_command() {
|
||||
local __orca_joined="" __orca_prompt_part
|
||||
if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
|
||||
|
|
@ -171,27 +198,8 @@ __orca_normalize_prompt_command() {
|
|||
PROMPT_COMMAND="$__orca_joined"
|
||||
fi
|
||||
}
|
||||
__orca_prepend_prompt_command() {
|
||||
__orca_normalize_prompt_command
|
||||
PROMPT_COMMAND="__orca_osc133_precmd\${PROMPT_COMMAND:+;\${PROMPT_COMMAND}}"
|
||||
}
|
||||
__orca_append_prompt_command() {
|
||||
local command="$1"
|
||||
__orca_normalize_prompt_command
|
||||
if [[ -n "\${PROMPT_COMMAND:-}" ]]; then
|
||||
PROMPT_COMMAND="\${PROMPT_COMMAND};$command"
|
||||
else
|
||||
PROMPT_COMMAND="$command"
|
||||
fi
|
||||
}
|
||||
__orca_prepend_prompt_command
|
||||
if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then
|
||||
__orca_prompt_mark() {
|
||||
printf "${SHELL_READY_MARKER}"
|
||||
}
|
||||
__orca_append_prompt_command "__orca_prompt_mark"
|
||||
fi
|
||||
__orca_append_prompt_command "__orca_osc133_prompt_done"
|
||||
__orca_normalize_prompt_command
|
||||
PROMPT_COMMAND="__orca_osc133_precmd\${PROMPT_COMMAND:+;\${PROMPT_COMMAND}};__orca_osc133_epilogue"
|
||||
__orca_debug_trap_spec="$(trap -p DEBUG)"
|
||||
if [[ -n "$__orca_debug_trap_spec" ]]; then
|
||||
__orca_debug_trap_command="\${__orca_debug_trap_spec#trap -- }"
|
||||
|
|
@ -199,7 +207,7 @@ if [[ -n "$__orca_debug_trap_spec" ]]; then
|
|||
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
|
||||
fi
|
||||
unset __orca_debug_trap_spec __orca_debug_trap_command
|
||||
unset -f __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command
|
||||
unset -f __orca_normalize_prompt_command
|
||||
# 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>
|
||||
|
|
@ -738,6 +740,7 @@ describe('connectPanePty', () => {
|
|||
consumePendingSnapshot: vi.fn(() => null),
|
||||
runtimePaneTitlesByTabId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
paneForegroundAgentByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
agentLaunchConfigByPaneKey: {},
|
||||
getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => {
|
||||
|
|
@ -772,8 +775,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()
|
||||
|
|
@ -14262,6 +14269,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'
|
||||
|
|
@ -165,6 +166,7 @@ import {
|
|||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
|
||||
import {
|
||||
isExpectedAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
|
|
@ -669,6 +671,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
|
||||
}
|
||||
|
|
@ -1762,12 +1768,86 @@ export function connectPanePty(
|
|||
}
|
||||
return pendingWrite.then(() => interruptInference.flushPending())
|
||||
}
|
||||
// Why: the 133;D confirmation guard and the visible-pane resampler both key off
|
||||
// "does this pane expect an agent"; derive each signal once so the two callers
|
||||
// can't drift and silently reintroduce the icon bug this fix closes.
|
||||
const paneHasLiveHookAgentIcon = (state: ReturnType<typeof useAppStore.getState>): boolean => {
|
||||
const entry = state.agentStatusByPaneKey[cacheKey]
|
||||
return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType))
|
||||
}
|
||||
const paneExpectsLaunchAgent = (state: ReturnType<typeof useAppStore.getState>): boolean => {
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === deps.tabId
|
||||
)
|
||||
return Boolean(
|
||||
tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent
|
||||
)
|
||||
}
|
||||
// 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()
|
||||
return paneHasLiveHookAgentIcon(state) || paneExpectsLaunchAgent(state)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
if (paneHasLiveHookAgentIcon(state)) {
|
||||
return
|
||||
}
|
||||
const expectsAgent = paneExpectsLaunchAgent(state)
|
||||
// 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 the launch/hook identity says is expected, 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
|
||||
}
|
||||
// Why: a 133;D already proved this pane is back at a shell prompt; with no
|
||||
// agent expectation there is nothing to recover, so trust it and don't re-read.
|
||||
if (foreground?.shellForeground && !expectsAgent) {
|
||||
return
|
||||
}
|
||||
paneForegroundAgentTracker.onVisiblePtyBound()
|
||||
}
|
||||
const commandLifecycle = createTerminalCommandLifecycle({
|
||||
onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(),
|
||||
onCommandFinished: () => {
|
||||
|
|
@ -1840,6 +1920,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)
|
||||
|
|
@ -2236,7 +2324,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)
|
||||
|
|
@ -2259,6 +2351,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 => {
|
||||
|
|
@ -3768,7 +3865,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)
|
||||
|
|
@ -5334,6 +5434,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
|
||||
|
|
@ -5895,7 +5996,10 @@ export function connectPanePty(
|
|||
onError: reportError
|
||||
}
|
||||
})
|
||||
bindActivePanePty(attachPtyId, { updateTabPtyId: 'if-missing' })
|
||||
bindActivePanePty(attachPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (attachPtyId === eagerLivePtyId) {
|
||||
registerPaneSerializerFor(attachPtyId)
|
||||
}
|
||||
|
|
@ -5949,7 +6053,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))
|
||||
|
|
@ -6059,6 +6166,10 @@ export function connectPanePty(
|
|||
noteVisibilityResume() {
|
||||
ptySizeReassertion.request({ fit: false })
|
||||
consumeHibernatedAgentWake()
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
},
|
||||
sampleForegroundAgentOnFocus() {
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
},
|
||||
reconcileIfSessionDead,
|
||||
reconcileIfSessionMissing,
|
||||
|
|
|
|||
|
|
@ -1282,6 +1282,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
|
||||
|
|
|
|||
Loading…
Reference in New Issue