mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
fix(sidebar): an agent's row stays while it runs, whatever its tab title (#23948)
* fix(sidebar): keep hook-less agent rows while the agent runs, whatever its title Codex retitles its pane to the project name, so the sidebar's title-derived row (which required the title to name an agent) vanished while Codex kept running (#23767). Rows now take identity from the canonical pane resolver over the pane's foreground-process read and launch record, then the title; the title only decides idle/working/needs-input. The row still goes away when the PTY exits, the process tracker proves the shell is back, or the title is a shell or default title. * test(dashboard): justify the partial store fixture's type assertion * fix(sidebar): only a live process read keeps a plain-title agent row Review of the previous commit found ghost rows: the tab launch record is a latch nothing clears on WSL, after an SSH exit, or for a launch that never started, and a parked pane's process read went stale because only the mounted tracker re-derives it. - The launch record returns to main's role: a fallback only for titles that show activity, ranked below a title naming another agent (pane reuse), matching the tab icon's order. - A parked pane's command boundary retires its unconfirmable process read, like the mounted ladder's unavailable path; reveal re-reads it. * fix(sidebar): confirm before a parked marker retires an agent; read Git Bash prompt titles as the shell - A parked pane's end-of-command marker can be a nested shell's leak under a still-running full-screen agent, so confirm the foreground first (as the mounted ladder does) and retire the process read only on a shell or no answer. SSH/remote parked panes hold no incarnation to fence a host read with, so they still retire. - Git Bash emits no command marks; its `$MSYSTEM:$PWD` prompt title (MINGW64:/c/repo) is now shell evidence, so a stale Codex read there no longer keeps a ghost row after Codex exits. * fix(sidebar): trust only process-read agents for plain-title rows; per-worktree foreground selector groups by tab A daemon reattach seeds the pane's foreground entry with its launch agent, which can outlive the process while Orca is closed. The entry now records where its agent came from (agentEvidence), and the sidebar/dashboard title-derived rows only keep a plain-title row on an actual process read. Routing and the tab icon are unchanged. selectPaneForegroundAgentsForWorktree grouped every pane key per worktree; it now groups by tab once per map identity and skips worktrees with no tabs. * fix(sidebar): a parked pane's reattach keeps its own process read of the same agent The reattach seed marked a returning parked Codex pane as launch-record evidence, over the process read this session already took, so its row blinked out on reveal and stayed hidden if the user left the tab before the visible read landed. Keep the read when it names the same agent; the seed still drops byte-routing trust. * test(terminal): foreground confirmation publishes process-read evidence * fix(sidebar): a cleared pane title retires the agent's process read Codex clears its title when it exits, and the tab then shows its default title. A pane without shell command marks never re-reads its foreground process, so the retained read kept a "Codex · Idle" row after /quit (permanently for a hand-typed Codex; about 15 s while the marked-pane confirm ladder ran). Treat a blank title like the default title it shows. * fix(sidebar): the pane's process monitor retires an exited agent's process read A hook-less pane keeps its sidebar row from the tracker's foreground-process read, but nothing re-derived that read in a pane without OSC 133 command marks. After Codex exited there, a "Codex · Idle" row stayed: permanently when the shell titles its prompt, or when a killed Codex leaves its last title. The pane's agent-completion process monitor already confirms an agent's exit (no agent and no child processes, held past its settle window). It now reports that exit to the tracker, which retires its own process read and runs the confirmed-shell path the visible-pty read uses. A tracker read that names an agent seeds the monitor, so hidden panes and panes the monitor had not polled yet are watched too. A command read in flight still decides the pane, and launch records or other agents' reads are left alone. * fix(sidebar): a monitor-confirmed exit leaves the next agent in an unmarked pane identifiable The process-exit retire published shellForeground:true and left the one-shot visible sample settled; a pane without command marks has no command start to lift either, so a Codex typed again after quitting was never read and lost its row on retitle. Publish shellForeground:false and reopen the sample. * test(terminal): justify the pane binding cast in the process-exit relaunch test
This commit is contained in:
@@ -115,6 +115,30 @@ describe('buildDashboardSnapshot rows cache', () => {
|
||||
expect(cached).toEqual(buildDashboardSnapshot(titleWrite, NOW + 500))
|
||||
})
|
||||
|
||||
it('recomputes only the worktree whose pane foreground process changed', () => {
|
||||
const cache = createWorktreeAgentRowsCache()
|
||||
const state: DashboardSnapshotState = {
|
||||
...baseState(),
|
||||
agentStatusByPaneKey: { [PANE_1]: entry(PANE_1, 'tab1', 'w1') },
|
||||
runtimePaneTitlesByTabId: { tab1: { 0: 'shell' }, tab2: { 0: 'demo-repo' } }
|
||||
}
|
||||
buildDashboardSnapshot(state, NOW, { rowsCache: cache, rowsGeneration: 1 })
|
||||
|
||||
const codexStarted: DashboardSnapshotState = {
|
||||
...state,
|
||||
paneForegroundAgentByPaneKey: {
|
||||
[PANE_2]: { agent: 'codex', agentEvidence: 'process-read', shellForeground: false }
|
||||
}
|
||||
}
|
||||
const cached = buildDashboardSnapshot(codexStarted, NOW + 500, {
|
||||
rowsCache: cache,
|
||||
rowsGeneration: 1
|
||||
})
|
||||
expect(cache.lastComputedWorktreeIds).toEqual(['w2'])
|
||||
expect(cached.cards.find((card) => card.paneKey === PANE_2)?.agentType).toBe('codex')
|
||||
expect(cached).toEqual(buildDashboardSnapshot(codexStarted, NOW + 500))
|
||||
})
|
||||
|
||||
it('keeps card-level fields fresh (acks, workspace statuses) without recomputing rows', () => {
|
||||
const cache = createWorktreeAgentRowsCache()
|
||||
const state = baseState()
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/store', () => ({ useAppStore: () => undefined }))
|
||||
const STORE_WRITES = 2_000
|
||||
|
||||
function storeState(): AppState {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only the slices listed here; the rest of AppState is never touched in this test.
|
||||
return {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
@@ -23,6 +24,7 @@ function storeState(): AppState {
|
||||
terminalLayoutsByTabId: {},
|
||||
ptyIdsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
paneForegroundAgentByPaneKey: {},
|
||||
folderWorkspaces: [],
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
@@ -32,7 +34,7 @@ function storeState(): AppState {
|
||||
}
|
||||
|
||||
// What `useShallow` did before the gate, unwrapped from the hook so it can be
|
||||
// driven directly: allocate the 14-key object, then `shallow()` it against the
|
||||
// driven directly: allocate the 15-key object, then `shallow()` it against the
|
||||
// previous one. Kept here as the comparison baseline.
|
||||
let previousShallow: Record<string, unknown> | null = null
|
||||
const shallowInputs = (s: AppState): Record<string, unknown> => ({
|
||||
@@ -47,6 +49,7 @@ const shallowInputs = (s: AppState): Record<string, unknown> => ({
|
||||
terminalLayoutsByTabId: s.terminalLayoutsByTabId,
|
||||
ptyIdsByTabId: s.ptyIdsByTabId,
|
||||
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
|
||||
paneForegroundAgentByPaneKey: s.paneForegroundAgentByPaneKey,
|
||||
folderWorkspaces: s.folderWorkspaces,
|
||||
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
|
||||
agentStatusEpoch: s.agentStatusEpoch
|
||||
@@ -92,7 +95,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('agent bucket count input gate', () => {
|
||||
it('allocates nothing on a store write that leaves all fourteen slices alone', () => {
|
||||
it('allocates nothing on a store write that leaves all fifteen slices alone', () => {
|
||||
const state = storeState()
|
||||
// Prime the gate, then replay the writes an unrelated slice would trigger.
|
||||
selectAgentBucketCountState(state)
|
||||
@@ -149,6 +152,7 @@ describe('agent bucket count input gate', () => {
|
||||
'terminalLayoutsByTabId',
|
||||
'ptyIdsByTabId',
|
||||
'runtimePaneTitlesByTabId',
|
||||
'paneForegroundAgentByPaneKey',
|
||||
'folderWorkspaces',
|
||||
'acknowledgedAgentsByPaneKey',
|
||||
'agentStatusEpoch'
|
||||
|
||||
@@ -23,6 +23,7 @@ export type AgentBucketCountState = Pick<
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'runtimePaneTitlesByTabId'
|
||||
| 'paneForegroundAgentByPaneKey'
|
||||
| 'folderWorkspaces'
|
||||
| 'acknowledgedAgentsByPaneKey'
|
||||
| 'agentStatusEpoch'
|
||||
@@ -34,8 +35,8 @@ export type AgentBucketCountState = Pick<
|
||||
|
||||
// Why module scope rather than useShallow: zustand runs this selector on every
|
||||
// store write, and shallow() on a plain object takes the compareEntries path —
|
||||
// two Object.entries arrays, 28 tuples and two Maps allocated per write just to
|
||||
// conclude nothing moved. Fourteen `===` against the previous slices allocates
|
||||
// two Object.entries arrays, 30 tuples and two Maps allocated per write just to
|
||||
// conclude nothing moved. Fifteen `===` against the previous slices allocates
|
||||
// nothing on the unchanged path, and the result is a pure function of the state
|
||||
// so one gate can serve every mounted consumer.
|
||||
let previousState: AgentBucketCountState | null = null
|
||||
@@ -60,6 +61,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState
|
||||
previous.terminalLayoutsByTabId === s.terminalLayoutsByTabId &&
|
||||
previous.ptyIdsByTabId === s.ptyIdsByTabId &&
|
||||
previous.runtimePaneTitlesByTabId === s.runtimePaneTitlesByTabId &&
|
||||
previous.paneForegroundAgentByPaneKey === s.paneForegroundAgentByPaneKey &&
|
||||
previous.folderWorkspaces === s.folderWorkspaces &&
|
||||
previous.acknowledgedAgentsByPaneKey === s.acknowledgedAgentsByPaneKey &&
|
||||
previous.agentStatusEpoch === s.agentStatusEpoch
|
||||
@@ -78,6 +80,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState
|
||||
terminalLayoutsByTabId: s.terminalLayoutsByTabId,
|
||||
ptyIdsByTabId: s.ptyIdsByTabId,
|
||||
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
|
||||
paneForegroundAgentByPaneKey: s.paneForegroundAgentByPaneKey,
|
||||
folderWorkspaces: s.folderWorkspaces,
|
||||
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
|
||||
agentStatusEpoch: s.agentStatusEpoch,
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
} from '../sidebar/worktree-agent-row-selectors'
|
||||
import {
|
||||
selectLivePtyIdsForWorktree,
|
||||
selectPaneForegroundAgentsForWorktree,
|
||||
selectRuntimePaneTitlesForWorktree
|
||||
} from '../sidebar/worktree-card-status-inputs'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
|
||||
type WorktreeAgentRowsCacheEntry = {
|
||||
/** Caller-provided invalidation token for time-based freshness (agentStatusEpoch). */
|
||||
@@ -31,6 +33,7 @@ type WorktreeAgentRowsCacheEntry = {
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
paneTitlesByTabId: Record<string, Record<number, string>>
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
|
||||
rows: DashboardAgentRowWithLineage[]
|
||||
}
|
||||
|
||||
@@ -88,7 +91,7 @@ export type WorktreeAgentRowsState = Pick<
|
||||
| 'ptyIdsByTabId'
|
||||
| 'runtimePaneTitlesByTabId'
|
||||
> &
|
||||
Partial<Pick<AppState, 'unifiedTabsByWorktree'>>
|
||||
Partial<Pick<AppState, 'unifiedTabsByWorktree' | 'paneForegroundAgentByPaneKey'>>
|
||||
|
||||
// Why not identity: the per-worktree layout/title/ptyId selectors build a fresh top-level
|
||||
// record per call while preserving per-tab value references, so shallow equality is the
|
||||
@@ -122,6 +125,7 @@ export function selectWorktreeAgentRowsCached(args: {
|
||||
const terminalLayoutsByTabId = selectTerminalLayoutsForWorktree(state, worktreeId)
|
||||
const paneTitlesByTabId = selectRuntimePaneTitlesForWorktree(state, worktreeId)
|
||||
const ptyIdsByTabId = selectLivePtyIdsForWorktree(state, worktreeId)
|
||||
const paneForegroundAgentByPaneKey = selectPaneForegroundAgentsForWorktree(state, worktreeId)
|
||||
|
||||
const cached = cache?.byWorktree.get(worktreeId)
|
||||
if (
|
||||
@@ -134,7 +138,8 @@ export function selectWorktreeAgentRowsCached(args: {
|
||||
cached.orchestration === orchestration &&
|
||||
shallowRecordEqual(cached.terminalLayoutsByTabId, terminalLayoutsByTabId) &&
|
||||
shallowRecordEqual(cached.paneTitlesByTabId, paneTitlesByTabId) &&
|
||||
shallowRecordEqual(cached.ptyIdsByTabId, ptyIdsByTabId)
|
||||
shallowRecordEqual(cached.ptyIdsByTabId, ptyIdsByTabId) &&
|
||||
shallowRecordEqual(cached.paneForegroundAgentByPaneKey, paneForegroundAgentByPaneKey)
|
||||
) {
|
||||
return cached.rows
|
||||
}
|
||||
@@ -158,6 +163,7 @@ export function selectWorktreeAgentRowsCached(args: {
|
||||
ptyIdsByTabId,
|
||||
terminalLayoutsByTabId,
|
||||
runtimeAgentOrchestrationByPaneKey: orchestration,
|
||||
paneForegroundAgentByPaneKey,
|
||||
now
|
||||
})
|
||||
)
|
||||
@@ -174,6 +180,7 @@ export function selectWorktreeAgentRowsCached(args: {
|
||||
terminalLayoutsByTabId,
|
||||
paneTitlesByTabId,
|
||||
ptyIdsByTabId,
|
||||
paneForegroundAgentByPaneKey,
|
||||
rows
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { titleShowsNoAgent } from '../../../../shared/agent-detection'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
|
||||
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
|
||||
const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
|
||||
'Claude Code': 'claude',
|
||||
OpenClaude: 'openclaude',
|
||||
Codex: 'codex',
|
||||
'Gemini CLI': 'gemini',
|
||||
'GitHub Copilot': 'copilot',
|
||||
Grok: 'grok',
|
||||
Devin: 'devin',
|
||||
Antigravity: 'antigravity',
|
||||
OpenCode: 'opencode',
|
||||
Aider: 'aider',
|
||||
Cursor: 'cursor',
|
||||
Droid: 'droid',
|
||||
Hermes: 'hermes',
|
||||
Pi: 'pi',
|
||||
OMP: 'omp'
|
||||
}
|
||||
|
||||
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
|
||||
|
||||
export function resolveTitleDerivedAgentType(
|
||||
title: string,
|
||||
label: string,
|
||||
ownerAgentType?: AgentType | null
|
||||
): AgentType | null {
|
||||
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
|
||||
if (agentType !== 'claude') {
|
||||
return agentType
|
||||
}
|
||||
// Why: Claude's task-title spinner heuristic has no provider identity. In
|
||||
// split panes it can match arbitrary terminal spinners, so sidebar rows only
|
||||
// accept Claude when the title itself names Claude.
|
||||
if (!CLAUDE_AGENT_TOKEN_RE.test(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: a "claude" word inside another agent's task text is a mention, not identity.
|
||||
// Only a title that PRESENTS Claude may take a pane away from its known owner (#8940).
|
||||
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
|
||||
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
|
||||
return null
|
||||
}
|
||||
return agentType
|
||||
}
|
||||
|
||||
/** The pane's foreground-process read as the tracker publishes it; routing fields are omitted. */
|
||||
export type TitleDerivedPaneForeground = Pick<
|
||||
PaneForegroundAgentEntry,
|
||||
'agent' | 'agentEvidence' | 'shellForeground'
|
||||
>
|
||||
|
||||
// Why: Codex clears its title on exit (OSC 0 with no text) and the tab then shows its default title.
|
||||
function titleRetiresProcessRead(args: { title: string; defaultTitle?: string }): boolean {
|
||||
return args.title.trim().length === 0 || titleShowsNoAgent(args.title, args.defaultTitle)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which agent a hook-less pane runs: a live process read (unless the title is a shell's), then the
|
||||
* title, then the agent Orca launched while the title shows activity. Sidebar-only; the tab icon
|
||||
* orders its signals differently. Null means the pane shows no agent row.
|
||||
*
|
||||
* Only the process read may keep a row whose title shows no agent activity (Codex retitles itself
|
||||
* to the project name, #23767): the mounted pane's tracker re-derives it at command boundaries,
|
||||
* which a pane without shell command marks never emits, so exit titles must still retire it. The
|
||||
* launch record is a tab-scoped latch with no run id, so it stays a fallback for titles that show
|
||||
* activity, and ranks below a title that names a different agent (pane reuse).
|
||||
*/
|
||||
export function resolveTitleDerivedPaneAgent(args: {
|
||||
title: string
|
||||
defaultTitle?: string
|
||||
titleShowsActivity: boolean
|
||||
titleAgentType: AgentType | null
|
||||
launchAgentType: AgentType | null
|
||||
foreground: TitleDerivedPaneForeground | undefined
|
||||
}): AgentType | null {
|
||||
// Why: a shell/default title is exit evidence the process read may not have caught up with; a
|
||||
// reattach's launch record is not a read at all and can name an agent that already exited.
|
||||
const processAgent =
|
||||
args.foreground?.agentEvidence !== 'process-read' || titleRetiresProcessRead(args)
|
||||
? null
|
||||
: args.foreground.agent
|
||||
// Why: OMP's nested pi process must not take an OMP-launched pane from its owner.
|
||||
const ownedProcessAgent = processAgent
|
||||
? (resolveCompatibleAgentTypeForOwner(processAgent, args.launchAgentType) ?? processAgent)
|
||||
: null
|
||||
return (
|
||||
ownedProcessAgent ??
|
||||
args.titleAgentType ??
|
||||
(args.titleShowsActivity ? args.launchAgentType : null)
|
||||
)
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
EMPTY_LIVE_PTY_IDS,
|
||||
EMPTY_PANE_FOREGROUND_AGENTS,
|
||||
EMPTY_RUNTIME_PANE_TITLES,
|
||||
selectLivePtyIdsForWorktree,
|
||||
selectPaneForegroundAgentsForWorktree,
|
||||
selectRuntimePaneTitlesForWorktree
|
||||
} from './worktree-card-status-inputs'
|
||||
import { buildWorktreeAgentRows } from './worktree-agent-rows'
|
||||
@@ -101,6 +103,11 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
|
||||
: EMPTY_WORKTREE_AGENT_ORCHESTRATION
|
||||
)
|
||||
)
|
||||
const paneForegroundAgentByPaneKey = useAppStore(
|
||||
useShallow((s) =>
|
||||
active ? selectPaneForegroundAgentsForWorktree(s, worktreeId) : EMPTY_PANE_FOREGROUND_AGENTS
|
||||
)
|
||||
)
|
||||
const agentFreshnessSignature = useAppStore((s) =>
|
||||
active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
|
||||
)
|
||||
@@ -131,6 +138,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
|
||||
ptyIdsByTabId,
|
||||
terminalLayoutsByTabId,
|
||||
runtimeAgentOrchestrationByPaneKey,
|
||||
paneForegroundAgentByPaneKey,
|
||||
now
|
||||
})
|
||||
)
|
||||
@@ -145,6 +153,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
|
||||
ptyIdsByTabId,
|
||||
terminalLayoutsByTabId,
|
||||
runtimeAgentOrchestrationByPaneKey,
|
||||
paneForegroundAgentByPaneKey,
|
||||
agentFreshnessSignature
|
||||
])
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
|
||||
import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows'
|
||||
import type { TitleDerivedPaneForeground } from './title-derived-pane-agent-identity'
|
||||
import { buildSubagentChildRows } from './worktree-subagent-child-rows'
|
||||
import { compareWorktreeAgentRows } from './worktree-agent-row-order'
|
||||
import {
|
||||
@@ -146,6 +147,7 @@ export function buildWorktreeAgentRows(args: {
|
||||
ptyIdsByTabId?: Record<string, string[]>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
|
||||
paneForegroundAgentByPaneKey?: Record<string, TitleDerivedPaneForeground>
|
||||
now: number
|
||||
}): DashboardAgentRow[] {
|
||||
const rows: DashboardAgentRow[] = []
|
||||
|
||||
@@ -7,9 +7,11 @@ import type {
|
||||
} from '../../../../shared/terminal-tab-types'
|
||||
import {
|
||||
EMPTY_LIVE_PTY_IDS,
|
||||
EMPTY_PANE_FOREGROUND_AGENTS,
|
||||
EMPTY_RUNTIME_PANE_TITLES,
|
||||
EMPTY_TERMINAL_LAYOUT_ROOTS,
|
||||
selectLivePtyIdsForWorktree,
|
||||
selectPaneForegroundAgentsForWorktree,
|
||||
selectTerminalLayoutRootsForWorktree,
|
||||
selectTerminalLayoutRootsForWorktrees,
|
||||
selectRuntimePaneTitlesForWorktree
|
||||
@@ -219,4 +221,68 @@ describe('worktree card status input selectors', () => {
|
||||
expect(Object.isFrozen(EMPTY_LIVE_PTY_IDS)).toBe(true)
|
||||
expect(Object.isFrozen(EMPTY_TERMINAL_LAYOUT_ROOTS)).toBe(true)
|
||||
})
|
||||
|
||||
it("selects only this worktree's pane foreground reads, keeping identity across other writes", () => {
|
||||
const worktreeId = 'repo1::/path/wt1'
|
||||
const ownPaneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
const ownEntry = { agent: 'codex' as const, shellForeground: false }
|
||||
const state: Parameters<typeof selectPaneForegroundAgentsForWorktree>[0] = {
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [makeTab('tab-1', worktreeId)],
|
||||
other: [makeTab('tab-2', 'other')]
|
||||
},
|
||||
paneForegroundAgentByPaneKey: {
|
||||
[ownPaneKey]: ownEntry,
|
||||
'tab-2:22222222-2222-4222-8222-222222222222': { agent: 'claude', shellForeground: false }
|
||||
}
|
||||
}
|
||||
const selected = selectPaneForegroundAgentsForWorktree(state, worktreeId)
|
||||
expect(selected).toEqual({ [ownPaneKey]: ownEntry })
|
||||
|
||||
const otherWrite = {
|
||||
...state,
|
||||
paneForegroundAgentByPaneKey: {
|
||||
...state.paneForegroundAgentByPaneKey,
|
||||
'tab-2:22222222-2222-4222-8222-222222222222': { agent: null, shellForeground: true }
|
||||
}
|
||||
}
|
||||
expect(selectPaneForegroundAgentsForWorktree(otherWrite, worktreeId)).toBe(selected)
|
||||
expect(selectPaneForegroundAgentsForWorktree(otherWrite, 'missing')).toBe(
|
||||
EMPTY_PANE_FOREGROUND_AGENTS
|
||||
)
|
||||
})
|
||||
|
||||
it('groups foreground reads by tab, including split panes, and skips worktrees with no tabs', () => {
|
||||
const splitLeft = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
const splitRight = 'tab-1:33333333-3333-4333-8333-333333333333'
|
||||
const ownSecondTab = 'tab-3:44444444-4444-4444-8444-444444444444'
|
||||
const state: Parameters<typeof selectPaneForegroundAgentsForWorktree>[0] = {
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTab('tab-1', 'wt1'), makeTab('tab-3', 'wt1')],
|
||||
wt2: [makeTab('tab-2', 'wt2')],
|
||||
empty: []
|
||||
},
|
||||
paneForegroundAgentByPaneKey: {
|
||||
[splitLeft]: { agent: 'codex', shellForeground: false },
|
||||
'tab-2:22222222-2222-4222-8222-222222222222': { agent: 'claude', shellForeground: false },
|
||||
[splitRight]: { agent: null, shellForeground: true },
|
||||
[ownSecondTab]: { agent: 'gemini', shellForeground: false },
|
||||
'not-a-pane-key': { agent: 'grok', shellForeground: false }
|
||||
}
|
||||
}
|
||||
|
||||
const selected = selectPaneForegroundAgentsForWorktree(state, 'wt1')
|
||||
expect(selected).toEqual({
|
||||
[splitLeft]: state.paneForegroundAgentByPaneKey?.[splitLeft],
|
||||
[splitRight]: state.paneForegroundAgentByPaneKey?.[splitRight],
|
||||
[ownSecondTab]: state.paneForegroundAgentByPaneKey?.[ownSecondTab]
|
||||
})
|
||||
expect(Object.keys(selectPaneForegroundAgentsForWorktree(state, 'wt2'))).toEqual([
|
||||
'tab-2:22222222-2222-4222-8222-222222222222'
|
||||
])
|
||||
expect(selectPaneForegroundAgentsForWorktree(state, 'empty')).toBe(EMPTY_PANE_FOREGROUND_AGENTS)
|
||||
expect(
|
||||
selectPaneForegroundAgentsForWorktree({ ...state, paneForegroundAgentByPaneKey: {} }, 'wt1')
|
||||
).toBe(EMPTY_PANE_FOREGROUND_AGENTS)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types'
|
||||
import { createWorktreeRecordSelector } from '@/store/worktree-record-selector-cache'
|
||||
|
||||
@@ -9,6 +11,9 @@ import { createWorktreeRecordSelector } from '@/store/worktree-record-selector-c
|
||||
// across worktrees instead of failing locally.
|
||||
export const EMPTY_RUNTIME_PANE_TITLES: Record<string, Record<number, string>> = Object.freeze({})
|
||||
export const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = Object.freeze({})
|
||||
export const EMPTY_PANE_FOREGROUND_AGENTS: Record<string, PaneForegroundAgentEntry> = Object.freeze(
|
||||
{}
|
||||
)
|
||||
export const EMPTY_TERMINAL_LAYOUT_ROOTS: Record<
|
||||
string,
|
||||
TerminalPaneLayoutNode | null | undefined
|
||||
@@ -18,6 +23,10 @@ type WorktreeCardStatusInputState = Pick<AppState, 'runtimePaneTitlesByTabId' |
|
||||
tabsByWorktree: Record<string, readonly { id: string }[]>
|
||||
}
|
||||
|
||||
type WorktreeCardForegroundInputState = Partial<Pick<AppState, 'paneForegroundAgentByPaneKey'>> & {
|
||||
tabsByWorktree: Record<string, readonly { id: string }[]>
|
||||
}
|
||||
|
||||
type WorktreeCardLayoutRootInputState = Pick<AppState, 'terminalLayoutsByTabId'> & {
|
||||
tabsByWorktree: Record<string, readonly { id: string }[]>
|
||||
}
|
||||
@@ -58,6 +67,64 @@ export const selectLivePtyIdsForWorktree = createWorktreeRecordSelector<
|
||||
}
|
||||
})
|
||||
|
||||
type PaneForegroundAgentsByTabId = ReadonlyMap<
|
||||
string,
|
||||
readonly [string, PaneForegroundAgentEntry][]
|
||||
>
|
||||
|
||||
// Why: grouped once per map identity so each card walks only its own tabs, not every pane key.
|
||||
const paneForegroundAgentsByTabIdCache = new WeakMap<
|
||||
Record<string, PaneForegroundAgentEntry>,
|
||||
PaneForegroundAgentsByTabId
|
||||
>()
|
||||
|
||||
function getPaneForegroundAgentsByTabId(
|
||||
entries: Record<string, PaneForegroundAgentEntry>
|
||||
): PaneForegroundAgentsByTabId {
|
||||
const cached = paneForegroundAgentsByTabIdCache.get(entries)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const byTabId = new Map<string, [string, PaneForegroundAgentEntry][]>()
|
||||
for (const [paneKey, entry] of Object.entries(entries)) {
|
||||
const tabId = parsePaneKey(paneKey)?.tabId
|
||||
if (!tabId) {
|
||||
continue
|
||||
}
|
||||
const group = byTabId.get(tabId)
|
||||
if (group) {
|
||||
group.push([paneKey, entry])
|
||||
} else {
|
||||
byTabId.set(tabId, [[paneKey, entry]])
|
||||
}
|
||||
}
|
||||
paneForegroundAgentsByTabIdCache.set(entries, byTabId)
|
||||
return byTabId
|
||||
}
|
||||
|
||||
/** This worktree's pane foreground-process reads, keyed by pane key. */
|
||||
export const selectPaneForegroundAgentsForWorktree = createWorktreeRecordSelector<
|
||||
WorktreeCardForegroundInputState,
|
||||
Record<string, PaneForegroundAgentEntry>
|
||||
>({
|
||||
readSources: (state) => [state.tabsByWorktree, state.paneForegroundAgentByPaneKey],
|
||||
empty: EMPTY_PANE_FOREGROUND_AGENTS,
|
||||
build: (state, worktreeId) => {
|
||||
const tabs = state.tabsByWorktree[worktreeId]
|
||||
if (!tabs?.length || !state.paneForegroundAgentByPaneKey) {
|
||||
return EMPTY_PANE_FOREGROUND_AGENTS
|
||||
}
|
||||
const byTabId = getPaneForegroundAgentsByTabId(state.paneForegroundAgentByPaneKey)
|
||||
const out: Record<string, PaneForegroundAgentEntry> = {}
|
||||
for (const tab of tabs) {
|
||||
for (const [paneKey, entry] of byTabId.get(tab.id) ?? []) {
|
||||
out[paneKey] = entry
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
})
|
||||
|
||||
export const selectTerminalLayoutRootsForWorktree = createWorktreeRecordSelector<
|
||||
WorktreeCardLayoutRootInputState,
|
||||
Record<string, TerminalPaneLayoutNode | null | undefined>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { buildWorktreeAgentRows } from './worktree-agent-rows'
|
||||
import type { TitleDerivedPaneForeground } from './title-derived-pane-agent-identity'
|
||||
|
||||
const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777'
|
||||
const LEAF_ID_2 = '88888888-8888-4888-8888-888888888888'
|
||||
@@ -43,6 +44,10 @@ function makeSingleLayout(leafId: string): TerminalLayoutSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
function processRead(agent: TuiAgent): TitleDerivedPaneForeground {
|
||||
return { agent, agentEvidence: 'process-read', shellForeground: false }
|
||||
}
|
||||
|
||||
describe('buildTitleDerivedAgentRows', () => {
|
||||
it('adds title-derived rows for live agent panes that have no hook status yet', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
@@ -369,7 +374,11 @@ describe('buildTitleDerivedAgentRows', () => {
|
||||
})
|
||||
|
||||
it('still resolves Claude from a title that presents Claude, owner or not', () => {
|
||||
const rowsFor = (title: string, launchAgent?: TuiAgent) =>
|
||||
const rowsFor = (
|
||||
title: string,
|
||||
launchAgent?: TuiAgent,
|
||||
foreground?: TitleDerivedPaneForeground
|
||||
) =>
|
||||
buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', launchAgent ? { launchAgent } : {})],
|
||||
entries: [],
|
||||
@@ -377,12 +386,19 @@ describe('buildTitleDerivedAgentRows', () => {
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-agent'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
...(foreground
|
||||
? { paneForegroundAgentByPaneKey: { [makePaneKey('tab-1', LEAF_ID_1)]: foreground } }
|
||||
: {}),
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rowsFor('⠋ Claude Code').map((row) => row.agentType)).toEqual(['claude'])
|
||||
// Pane reuse: the user exited OpenCode and ran claude in the same pane.
|
||||
// Pane reuse: the user exited OpenCode and ran claude in the same pane. The launch record is
|
||||
// a latch with no run id, so the title outranks it even before any process read.
|
||||
expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude'])
|
||||
expect(
|
||||
rowsFor('✳ Claude Code', 'opencode', processRead('claude')).map((row) => row.agentType)
|
||||
).toEqual(['claude'])
|
||||
// No owner to defend the pane: naming Claude stays the only available identity.
|
||||
expect(rowsFor('⠋ use Claude Sonnet').map((row) => row.agentType)).toEqual(['claude'])
|
||||
expect(rowsFor('zsh', 'opencode')).toHaveLength(0)
|
||||
@@ -403,6 +419,175 @@ describe('buildTitleDerivedAgentRows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// #23767: Codex retitles its pane to the project name, so a title-gated row vanished while
|
||||
// Codex kept running. A live process read now identifies the pane; the title sets activity.
|
||||
describe('hook-less agent rows identified by the foreground process', () => {
|
||||
const PANE_KEY = makePaneKey('tab-1', LEAF_ID_1)
|
||||
|
||||
function rowsFor(args: {
|
||||
title: string
|
||||
launchAgent?: TuiAgent
|
||||
foreground?: TitleDerivedPaneForeground
|
||||
ptyIds?: string[]
|
||||
layout?: TerminalLayoutSnapshot
|
||||
}) {
|
||||
return buildWorktreeAgentRows({
|
||||
tabs: [
|
||||
makeTab('tab-1', {
|
||||
defaultTitle: 'Terminal 1',
|
||||
...(args.launchAgent ? { launchAgent: args.launchAgent } : {})
|
||||
})
|
||||
],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: args.title } },
|
||||
ptyIdsByTabId: { 'tab-1': args.ptyIds ?? ['pty-agent'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': args.layout ?? makeSingleLayout(LEAF_ID_1) },
|
||||
...(args.foreground ? { paneForegroundAgentByPaneKey: { [PANE_KEY]: args.foreground } } : {}),
|
||||
now: 2000
|
||||
})
|
||||
}
|
||||
|
||||
const summarize = (rows: ReturnType<typeof rowsFor>) =>
|
||||
rows.map((row) => [row.agentType, row.state, row.entry.prompt, row.entry.lastAssistantMessage])
|
||||
|
||||
it('keeps a launched Codex row when Codex retitles the pane to the project name', () => {
|
||||
const foreground = processRead('codex')
|
||||
expect(summarize(rowsFor({ title: 'Codex', launchAgent: 'codex', foreground }))).toEqual([
|
||||
['codex', 'idle', 'Codex', 'Idle']
|
||||
])
|
||||
expect(summarize(rowsFor({ title: 'demo-repo', launchAgent: 'codex', foreground }))).toEqual([
|
||||
['codex', 'idle', 'Codex', 'Idle']
|
||||
])
|
||||
})
|
||||
|
||||
it('never keeps a plain-title row on the launch record alone', () => {
|
||||
// No process read (WSL, a launch that never started), or a read that found no agent (after an
|
||||
// SSH exit, or a parked pane's boundary retiring its unconfirmable read): no row, as before.
|
||||
expect(rowsFor({ title: 'demo-repo', launchAgent: 'codex' })).toHaveLength(0)
|
||||
expect(
|
||||
rowsFor({
|
||||
title: 'demo-repo',
|
||||
launchAgent: 'codex',
|
||||
foreground: { agent: null, shellForeground: false }
|
||||
})
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("does not trust a reattach's launch record as a process read", () => {
|
||||
// Reattach seeds the daemon's launch agent, which can outlive the process while Orca is
|
||||
// closed; a background pane is not re-read until it is shown.
|
||||
const launchSeed: TitleDerivedPaneForeground = {
|
||||
agent: 'codex',
|
||||
agentEvidence: 'launch-record',
|
||||
shellForeground: false
|
||||
}
|
||||
expect(rowsFor({ title: 'demo-repo', launchAgent: 'codex', foreground: launchSeed })).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(rowsFor({ title: 'demo-repo', foreground: launchSeed })).toEqual([])
|
||||
expect(
|
||||
rowsFor({ title: 'demo-repo', foreground: { ...launchSeed, agentEvidence: undefined } })
|
||||
).toEqual([])
|
||||
// Once a real read confirms the same agent, the row comes back.
|
||||
expect(
|
||||
summarize(
|
||||
rowsFor({ title: 'demo-repo', launchAgent: 'codex', foreground: processRead('codex') })
|
||||
)
|
||||
).toEqual([['codex', 'idle', 'Codex', 'Idle']])
|
||||
})
|
||||
|
||||
it('rows a hand-typed agent from its foreground process, whatever its title says', () => {
|
||||
for (const agent of ['codex', 'claude', 'gemini', 'opencode', 'grok'] as const) {
|
||||
const rows = rowsFor({ title: 'demo-repo', foreground: processRead(agent) })
|
||||
expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([
|
||||
[PANE_KEY, agent, 'idle']
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes process evidence to its own pane inside a split', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'claude' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: 'demo-repo', 2: 'demo-repo' } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() },
|
||||
paneForegroundAgentByPaneKey: {
|
||||
[makePaneKey('tab-1', LEAF_ID_2)]: processRead('codex')
|
||||
},
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.paneKey, row.agentType])).toEqual([
|
||||
[makePaneKey('tab-1', LEAF_ID_2), 'codex']
|
||||
])
|
||||
})
|
||||
|
||||
it('lets the title drive activity without deciding who the agent is', () => {
|
||||
const foreground = processRead('codex')
|
||||
expect(summarize(rowsFor({ title: '⠋ demo-repo', foreground }))).toEqual([
|
||||
['codex', 'working', 'Codex', 'Running']
|
||||
])
|
||||
expect(summarize(rowsFor({ title: 'demo-repo', foreground }))).toEqual([
|
||||
['codex', 'idle', 'Codex', 'Idle']
|
||||
])
|
||||
// A title naming another agent does not outrank the process that is actually running.
|
||||
expect(summarize(rowsFor({ title: '⠋ Gemini CLI', foreground }))).toEqual([
|
||||
['codex', 'working', 'Codex', 'Running']
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the row once the agent is really gone', () => {
|
||||
// The process tracker proved the shell is back.
|
||||
expect(
|
||||
rowsFor({
|
||||
title: 'demo-repo',
|
||||
launchAgent: 'codex',
|
||||
foreground: { agent: null, shellForeground: true }
|
||||
})
|
||||
).toHaveLength(0)
|
||||
// A shell or default title outranks a process read that has not caught up yet.
|
||||
expect(
|
||||
rowsFor({
|
||||
title: 'zsh',
|
||||
launchAgent: 'codex',
|
||||
foreground: processRead('codex')
|
||||
})
|
||||
).toHaveLength(0)
|
||||
expect(rowsFor({ title: 'Terminal 1', launchAgent: 'codex' })).toHaveLength(0)
|
||||
// Git Bash has no command marks, so its prompt title is what retires a stale process read.
|
||||
expect(
|
||||
rowsFor({
|
||||
title: 'MINGW64:/c/Users/dev/demo-repo',
|
||||
foreground: processRead('codex')
|
||||
})
|
||||
).toHaveLength(0)
|
||||
// Codex clears its title on exit; a pane without command marks never re-reads the process.
|
||||
expect(rowsFor({ title: '', foreground: processRead('codex') })).toHaveLength(0)
|
||||
expect(
|
||||
rowsFor({ title: ' ', launchAgent: 'codex', foreground: processRead('codex') })
|
||||
).toHaveLength(0)
|
||||
// The PTY exited.
|
||||
expect(
|
||||
rowsFor({
|
||||
title: 'demo-repo',
|
||||
launchAgent: 'codex',
|
||||
foreground: processRead('codex'),
|
||||
ptyIds: []
|
||||
})
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('makes no row from a plain title when nothing identifies an agent', () => {
|
||||
expect(rowsFor({ title: 'demo-repo' })).toHaveLength(0)
|
||||
expect(
|
||||
rowsFor({ title: 'demo-repo', foreground: { agent: null, shellForeground: false } })
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Why: `runtimePaneTitlesByTabId` mixes two disjoint id spaces — live PaneManager
|
||||
// ids (>= 1) and the `-(leafIndex + 1)` slots a parked tab mints — so attributing a
|
||||
// title by its position in the numerically sorted slot list puts one split pane's
|
||||
|
||||
@@ -23,9 +23,13 @@ import {
|
||||
type CompatibleAgentOwnerOptions
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
|
||||
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
|
||||
import {
|
||||
resolveTitleDerivedAgentType,
|
||||
resolveTitleDerivedPaneAgent,
|
||||
type TitleDerivedPaneForeground
|
||||
} from './title-derived-pane-agent-identity'
|
||||
|
||||
/** Fixed, not per-process: title rows are a pure projection of the current title, so they are
|
||||
/** Fixed, not per-process: title rows are a pure projection of current pane facts, so they are
|
||||
* comparable across restarts in a way a sequenced authority's rows are not. Ordering against
|
||||
* any other authority's rows is undefined — see agent-status-observation.ts. */
|
||||
export const TITLE_DERIVED_AGENT_ROW_AUTHORITY_ID = 'renderer-title-projection'
|
||||
@@ -33,26 +37,7 @@ export const TITLE_DERIVED_AGENT_ROW_AUTHORITY_ID = 'renderer-title-projection'
|
||||
const EMPTY_RUNTIME_TITLES: Record<string, Record<number, string>> = {}
|
||||
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
|
||||
const EMPTY_TERMINAL_LAYOUTS: Record<string, TerminalLayoutSnapshot | undefined> = {}
|
||||
|
||||
const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
|
||||
'Claude Code': 'claude',
|
||||
OpenClaude: 'openclaude',
|
||||
Codex: 'codex',
|
||||
'Gemini CLI': 'gemini',
|
||||
'GitHub Copilot': 'copilot',
|
||||
Grok: 'grok',
|
||||
Devin: 'devin',
|
||||
Antigravity: 'antigravity',
|
||||
OpenCode: 'opencode',
|
||||
Aider: 'aider',
|
||||
Cursor: 'cursor',
|
||||
Droid: 'droid',
|
||||
Hermes: 'hermes',
|
||||
Pi: 'pi',
|
||||
OMP: 'omp'
|
||||
}
|
||||
|
||||
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
|
||||
const EMPTY_PANE_FOREGROUND: Record<string, TitleDerivedPaneForeground> = {}
|
||||
|
||||
export function buildTitleDerivedAgentRows(args: {
|
||||
tabs: TerminalTab[]
|
||||
@@ -60,6 +45,7 @@ export function buildTitleDerivedAgentRows(args: {
|
||||
ptyIdsByTabId?: Record<string, string[]>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
|
||||
paneForegroundAgentByPaneKey?: Record<string, TitleDerivedPaneForeground>
|
||||
seenPaneKeys: Set<string>
|
||||
now: number
|
||||
}): DashboardAgentRow[] {
|
||||
@@ -67,6 +53,7 @@ export function buildTitleDerivedAgentRows(args: {
|
||||
const runtimePaneTitlesByTabId = args.runtimePaneTitlesByTabId ?? EMPTY_RUNTIME_TITLES
|
||||
const ptyIdsByTabId = args.ptyIdsByTabId ?? EMPTY_LIVE_PTY_IDS
|
||||
const terminalLayoutsByTabId = args.terminalLayoutsByTabId ?? EMPTY_TERMINAL_LAYOUTS
|
||||
const paneForegroundAgentByPaneKey = args.paneForegroundAgentByPaneKey ?? EMPTY_PANE_FOREGROUND
|
||||
|
||||
for (const tab of args.tabs) {
|
||||
if (!tabHasLivePty(ptyIdsByTabId, tab.id)) {
|
||||
@@ -113,6 +100,7 @@ export function buildTitleDerivedAgentRows(args: {
|
||||
leafId,
|
||||
title,
|
||||
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
|
||||
paneForegroundAgentByPaneKey,
|
||||
now: args.now,
|
||||
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
|
||||
})
|
||||
@@ -134,6 +122,7 @@ export function buildTitleDerivedAgentRows(args: {
|
||||
leafId,
|
||||
title: tab.title,
|
||||
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
|
||||
paneForegroundAgentByPaneKey,
|
||||
now: args.now,
|
||||
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
|
||||
})
|
||||
@@ -156,6 +145,7 @@ function buildTitleDerivedAgentRow(args: {
|
||||
leafId: string
|
||||
title: string
|
||||
ownerAgentType: AgentType | null
|
||||
paneForegroundAgentByPaneKey: Record<string, TitleDerivedPaneForeground>
|
||||
now: number
|
||||
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
|
||||
}): DashboardAgentRow | null {
|
||||
@@ -172,31 +162,37 @@ function buildTitleDerivedAgentRow(args: {
|
||||
// Why (cursor): the native `cursor agent` literal is deliberately status-less so a
|
||||
// redraw cannot stomp hook state — but it still identifies a live pane, so the row
|
||||
// reads idle instead of vanishing (#10258).
|
||||
const status = isClaudeAgentsTitle
|
||||
const titleStatus = isClaudeAgentsTitle
|
||||
? 'idle'
|
||||
: (classifyTitleActivity(title) ?? (isCursorAgentTitle(title) ? 'idle' : null))
|
||||
const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title)
|
||||
if (!status || !label) {
|
||||
return null
|
||||
}
|
||||
if (!isTerminalLeafId(args.leafId)) {
|
||||
return null
|
||||
}
|
||||
const paneKey = makePaneKey(args.tab.id, args.leafId)
|
||||
const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
|
||||
const titleAgentType = isClaudeAgentsTitle
|
||||
? 'claude'
|
||||
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType)
|
||||
// Why: a status frame proves activity, not identity, so the resolver drops it.
|
||||
// Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940)
|
||||
// surface only decorated task titles; fall back to the pane's known owner instead
|
||||
// of hiding the pane. Safe because the `!status || !label` gate above already
|
||||
// rejects plain shell titles — this path must never manufacture a row from one.
|
||||
const agentType = titleAgentType ?? args.ownerAgentType
|
||||
// Why: a status frame proves activity, not identity (Codex over SSH, #8711; OpenCode's
|
||||
// '. '/'* ' frames, #8940), so a title names an agent only when it carries both.
|
||||
const titleAgentType =
|
||||
!titleStatus || !label
|
||||
? null
|
||||
: isClaudeAgentsTitle
|
||||
? 'claude'
|
||||
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType)
|
||||
const agentType = resolveTitleDerivedPaneAgent({
|
||||
title,
|
||||
defaultTitle: args.tab.defaultTitle,
|
||||
titleShowsActivity: Boolean(titleStatus && label),
|
||||
titleAgentType,
|
||||
launchAgentType: args.ownerAgentType,
|
||||
foreground: args.paneForegroundAgentByPaneKey[paneKey]
|
||||
})
|
||||
if (!agentType) {
|
||||
return null
|
||||
}
|
||||
const rowLabel = titleAgentType ? label : formatAgentTypeLabel(agentType)
|
||||
// Why: the title sets activity only; a plain title on a process-identified pane is idle.
|
||||
const status = titleStatus ?? 'idle'
|
||||
const rowLabel = agentType === titleAgentType && label ? label : formatAgentTypeLabel(agentType)
|
||||
const rowState = titleStatusToRowState(status)
|
||||
const secondary =
|
||||
status === 'permission' ? 'Needs input' : status === 'working' ? 'Running' : 'Idle'
|
||||
@@ -212,7 +208,7 @@ function buildTitleDerivedAgentRow(args: {
|
||||
terminalTitle: title,
|
||||
lastAssistantMessage: secondary,
|
||||
...(orchestration ? { orchestration } : {}),
|
||||
// Why not the renderer sequencer: this row is RE-DERIVED from the pane's title on every
|
||||
// Why not the renderer sequencer: this row is RE-DERIVED from the pane's facts on every
|
||||
// render, not observed once, so a counter would churn a new revision per frame and break
|
||||
// memoization. Deriving revision from `now` keeps the stamp deterministic in the same clock
|
||||
// the row already publishes as updatedAt, and monotonic for the pane.
|
||||
@@ -245,30 +241,6 @@ function buildTitleDerivedAgentRow(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTitleDerivedAgentType(
|
||||
title: string,
|
||||
label: string,
|
||||
ownerAgentType?: AgentType | null
|
||||
): AgentType | null {
|
||||
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
|
||||
if (agentType !== 'claude') {
|
||||
return agentType
|
||||
}
|
||||
// Why: Claude's task-title spinner heuristic has no provider identity. In
|
||||
// split panes it can match arbitrary terminal spinners, so sidebar rows only
|
||||
// accept Claude when the title itself names Claude.
|
||||
if (!CLAUDE_AGENT_TOKEN_RE.test(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: a "claude" word inside another agent's task text is a mention, not identity.
|
||||
// Only a title that PRESENTS Claude may take a pane away from its known owner (#8940).
|
||||
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
|
||||
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
|
||||
return null
|
||||
}
|
||||
return agentType
|
||||
}
|
||||
|
||||
function resolveTitleDerivedPaneOwner(
|
||||
tab: TerminalTab,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
|
||||
@@ -42,6 +42,8 @@ export type AgentCompletionCoordinatorOptions = {
|
||||
replacement: RecognizedAgentProcess
|
||||
) => boolean
|
||||
shouldSuppressConfirmedProcessExitCompletion?: (exited: RecognizedAgentProcess) => boolean
|
||||
/** Fired once per confirmed exit (settled local absence, or the host's `exited` verdict). */
|
||||
onForegroundAgentExited?: (exited: RecognizedAgentProcess) => void
|
||||
isLive: () => boolean
|
||||
shouldPollProcessCadence?: () => boolean
|
||||
// Why: a host that publishes foreground evidence with its inventory lets a
|
||||
@@ -65,6 +67,8 @@ export type AgentCompletionCoordinator = {
|
||||
observeHookStatus: (payload: AgentCompletionStatusSnapshot) => void
|
||||
seedHookStatus: (payload: AgentCompletionStatusSnapshot) => void
|
||||
startProcessTracking: () => void
|
||||
/** Another reader saw this agent in the foreground; lets the monitor confirm its exit. */
|
||||
observeForegroundAgentProcess: (process: RecognizedAgentProcess) => void
|
||||
hasPendingHookDoneCompletion: () => boolean
|
||||
resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void
|
||||
dispose: () => void
|
||||
|
||||
@@ -316,6 +316,7 @@ export function createAgentCompletionCoordinator(
|
||||
observeHookStatus: hookObserver.observeHookStatus,
|
||||
seedHookStatus: hookObserver.seedHookStatus,
|
||||
startProcessTracking: () => processMonitor.start(),
|
||||
observeForegroundAgentProcess: processMonitor.observeRecognizedProcess,
|
||||
hasPendingHookDoneCompletion: lifecycle.hasPendingHookDoneCompletion,
|
||||
resetCompletionState: lifecycle.resetCompletionState,
|
||||
dispose: lifecycle.dispose
|
||||
|
||||
@@ -113,6 +113,9 @@ export function handleAgentCompletionInspectionResult(args: {
|
||||
}
|
||||
state.lastForegroundAgent = null
|
||||
clearAgentRunEvidence()
|
||||
if (exited) {
|
||||
options.onForegroundAgentExited?.(exited)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (admitted.verdict !== 'live') {
|
||||
@@ -188,6 +191,7 @@ export function handleAgentCompletionInspectionResult(args: {
|
||||
}
|
||||
state.lastForegroundAgent = null
|
||||
clearAgentRunEvidence()
|
||||
options.onForegroundAgentExited?.(exited)
|
||||
} else {
|
||||
state.lastForegroundAgent = null
|
||||
clearAgentRunEvidence()
|
||||
|
||||
@@ -184,6 +184,11 @@ export function createAgentCompletionProcessMonitor({
|
||||
requestInspection,
|
||||
scheduleNextPoll,
|
||||
clearPollTimer,
|
||||
observeRecognizedProcess: (process: RecognizedAgentProcess) => {
|
||||
if (!state.disposed) {
|
||||
handleRecognizedProcess(process)
|
||||
}
|
||||
},
|
||||
start: () => {
|
||||
state.pollTrackingStarted = true
|
||||
scheduleNextPoll()
|
||||
|
||||
@@ -195,7 +195,11 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false })
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'claude',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a delayed foreground result after the pane rebinds to another PTY', async () => {
|
||||
@@ -215,7 +219,11 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(publish).not.toHaveBeenCalledWith({ agent: 'droid', shellForeground: false })
|
||||
expect(publish).not.toHaveBeenCalledWith({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('uses typed-agent text only to await process confirmation', async () => {
|
||||
@@ -230,6 +238,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS)
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -282,7 +291,11 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
await flushSettleRead(VISIBLE_PTY_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retry or publish visible PTY reads for an idle shell foreground', async () => {
|
||||
@@ -307,7 +320,11 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
await flushSettleRead(VISIBLE_PTY_SETTLE_MS)
|
||||
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let visible PTY sampling downgrade pending command-start sampling', async () => {
|
||||
@@ -321,7 +338,11 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
|
||||
await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('retries visible PTY reads only while a foreground wrapper may resolve to an agent', async () => {
|
||||
@@ -335,7 +356,11 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
|
||||
await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('re-reads on a bounded ladder while the read still sees an interpreter wrapper', async () => {
|
||||
@@ -357,7 +382,11 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
|
||||
await flushSettleRead(SECOND_WRAPPER_RETRY_MS)
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(3)
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false })
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'claude',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('stops after the ladder and publishes no identity for a persistent unknown process', async () => {
|
||||
@@ -398,7 +427,11 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS)
|
||||
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'gemini', shellForeground: false })
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'gemini',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('marks shell foreground on command finished without any foreground read', () => {
|
||||
@@ -472,6 +505,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
|
||||
@@ -485,6 +519,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -567,6 +602,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -619,6 +655,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(publish).toHaveBeenCalledExactlyOnceWith({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -637,6 +674,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(publish).toHaveBeenCalledExactlyOnceWith({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -676,6 +714,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
expect(readForegroundProcess).toHaveBeenCalledTimes(3)
|
||||
expect(publish).toHaveBeenCalledExactlyOnceWith({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -749,8 +788,16 @@ describe('createPaneForegroundAgentTracker', () => {
|
||||
resolveFirstRead('claude')
|
||||
await flushSettleRead(COMMAND_SETTLE_MS)
|
||||
|
||||
expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
||||
expect(publish).not.toHaveBeenCalledWith({ agent: 'claude', shellForeground: false })
|
||||
expect(publish).toHaveBeenLastCalledWith({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
expect(publish).not.toHaveBeenCalledWith({
|
||||
agent: 'claude',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('stops publishing after dispose', async () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
recognizeAgentProcess
|
||||
recognizeAgentProcess,
|
||||
type RecognizedAgentProcess
|
||||
} from '../../../../shared/agent-process-recognition'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
|
||||
import { isShellProcess } from '../../../../shared/shell-process-detection'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
@@ -33,6 +35,10 @@ type PaneForegroundAgentTrackerDeps = {
|
||||
isRemotePtyId?: (ptyId: string) => boolean
|
||||
getExpectedIncarnationId?: () => string | null
|
||||
publish: (entry: PaneForegroundAgentEntry) => void
|
||||
/** The entry currently published for this pane, whoever wrote it. */
|
||||
getPublishedEntry?: () => PaneForegroundAgentEntry | undefined
|
||||
/** A local read recognized an agent; lets the pane's process monitor watch for its exit. */
|
||||
onAgentProcessRead?: (process: RecognizedAgentProcess) => 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. */
|
||||
@@ -40,7 +46,7 @@ type PaneForegroundAgentTrackerDeps = {
|
||||
/** 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?: (reason: 'visible-pty' | 'command-finished') => void
|
||||
onConfirmedShellForeground?: (reason: 'visible-pty' | 'command-finished' | 'process-exit') => void
|
||||
onCommandFinishedUnavailable?: () => void
|
||||
onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void
|
||||
}
|
||||
@@ -60,6 +66,8 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
onCommandStarted: (expectedAgent?: TuiAgent | null) => void
|
||||
/** True when pane identity must remain visible until an async shell confirmation. */
|
||||
onCommandFinished: () => boolean
|
||||
/** The process monitor confirmed this agent exited (no agent, no children, settled). */
|
||||
onProcessExitConfirmed: (process: RecognizedAgentProcess) => void
|
||||
dispose: () => void
|
||||
} {
|
||||
let disposed = false
|
||||
@@ -76,6 +84,11 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
let hasKnownAgentEvidence = false
|
||||
let hasAgentExpectation = false
|
||||
const readProcess = createPaneForegroundProcessReader(deps)
|
||||
const clearAgentEvidence = (): void => {
|
||||
hasForegroundAgentEvidence = false
|
||||
hasKnownAgentEvidence = false
|
||||
hasAgentExpectation = false
|
||||
}
|
||||
|
||||
const trackablePtyId = (): string | null => {
|
||||
const ptyId = deps.getPtyId()
|
||||
@@ -112,6 +125,11 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
}
|
||||
|
||||
const hasPendingRead = (): boolean => scheduledReadReason !== null || activeReadReason !== null
|
||||
const hasCommandReadInFlight = (): boolean =>
|
||||
scheduledReadReason === 'command' ||
|
||||
activeReadReason === 'command' ||
|
||||
scheduledReadReason === 'command-finished' ||
|
||||
activeReadReason === 'command-finished'
|
||||
|
||||
// Why: the store entry outlives this tracker, so any capability a caller retained
|
||||
// pending a read must be released by whichever exit ends that read. A superseded
|
||||
@@ -169,9 +187,14 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
hasAgentExpectation = false
|
||||
deps.publish({
|
||||
agent: recognized.agent,
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false,
|
||||
...(requiresRoutingConfirmation ? { routingTrusted: true } : {})
|
||||
})
|
||||
// Why: nothing else re-derives this read in a pane without command marks.
|
||||
if (!remote) {
|
||||
deps.onAgentProcessRead?.(recognized)
|
||||
}
|
||||
if (reason === 'visible-pty') {
|
||||
deps.onVisibleForegroundSettled?.('agent')
|
||||
}
|
||||
@@ -212,9 +235,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
processName !== null &&
|
||||
isShellProcess(processName)
|
||||
) {
|
||||
hasForegroundAgentEvidence = false
|
||||
hasKnownAgentEvidence = false
|
||||
hasAgentExpectation = false
|
||||
clearAgentEvidence()
|
||||
deps.publish({ agent: null, shellForeground: true })
|
||||
deps.onConfirmedShellForeground?.(reason)
|
||||
deps.onVisibleForegroundSettled?.('shell')
|
||||
@@ -231,9 +252,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
}
|
||||
// Why: client-only unverifiable inspection is not confirmed shell evidence; retire
|
||||
// stale routing after the bounded D ladder without asserting shell truth.
|
||||
hasForegroundAgentEvidence = false
|
||||
hasKnownAgentEvidence = false
|
||||
hasAgentExpectation = false
|
||||
clearAgentEvidence()
|
||||
deps.publish({ agent: null, shellForeground: false })
|
||||
deps.onCommandFinishedUnavailable?.()
|
||||
return
|
||||
@@ -248,9 +267,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
// 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
|
||||
hasKnownAgentEvidence = false
|
||||
hasAgentExpectation = false
|
||||
clearAgentEvidence()
|
||||
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).
|
||||
@@ -268,12 +285,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
onVisiblePtyBound(expectsAgent = false) {
|
||||
// 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'
|
||||
) {
|
||||
if (hasCommandReadInFlight()) {
|
||||
return false
|
||||
}
|
||||
const hadReadBeforeVisibleBind = hasPendingRead()
|
||||
@@ -347,6 +359,28 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
||||
scheduleRead(COMMAND_SETTLE_MS, 0, 'command-finished')
|
||||
return true
|
||||
},
|
||||
onProcessExitConfirmed(process) {
|
||||
// Why: an in-flight command read owns the pane's next identity, and only a live read is ours.
|
||||
const published = deps.getPublishedEntry?.()
|
||||
if (
|
||||
disposed ||
|
||||
hasCommandReadInFlight() ||
|
||||
published?.agentEvidence !== 'process-read' ||
|
||||
resolveCompatibleAgentTypeForOwner(process.agent, published.agent) !== published.agent
|
||||
) {
|
||||
return
|
||||
}
|
||||
const hadVisibleRead = hasPendingRead()
|
||||
cancelPendingRead()
|
||||
clearAgentEvidence()
|
||||
// Why not shellForeground: a pane without command marks gets no command start to lift that
|
||||
// latch, so it would block the Enter sample that identifies the next agent typed there.
|
||||
deps.publish({ agent: null, shellForeground: false })
|
||||
if (hadVisibleRead) {
|
||||
deps.onVisibleForegroundSettled?.('shell')
|
||||
}
|
||||
deps.onConfirmedShellForeground?.('process-exit')
|
||||
},
|
||||
dispose() {
|
||||
const hadReadAtDispose = hasPendingRead()
|
||||
disposed = true
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveTitleDerivedPaneAgent } from '../sidebar/title-derived-pane-agent-identity'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
|
||||
import {
|
||||
createDeferred,
|
||||
flushAsyncTicks,
|
||||
processResult,
|
||||
useAgentCompletionCoordinatorLifecycle
|
||||
} from './agent-completion-coordinator-test-harness'
|
||||
import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker'
|
||||
|
||||
// Wires the pane's tracker and completion monitor the way pane-agent-identity.ts and
|
||||
// terminal-keydown-fit.ts do, for a pane whose shell emits no OSC 133 command marks.
|
||||
function createUnmarkedPane(args: { visible: boolean }) {
|
||||
let entry: PaneForegroundAgentEntry | undefined
|
||||
let foreground: string | null = 'codex'
|
||||
let hasChildren = true
|
||||
const readForegroundProcess = vi.fn(async (): Promise<string | null> => foreground)
|
||||
const confirmForegroundProcess = vi.fn(async (): Promise<string | null> => foreground)
|
||||
const onConfirmedShellForeground = vi.fn()
|
||||
let coordinator: ReturnType<typeof createAgentCompletionCoordinator> | null = null
|
||||
const tracker = createPaneForegroundAgentTracker({
|
||||
getPtyId: () => 'pty-1',
|
||||
isTrackablePtyId: () => true,
|
||||
readForegroundProcess,
|
||||
confirmForegroundProcess,
|
||||
publish: (next) => {
|
||||
entry = next
|
||||
},
|
||||
getPublishedEntry: () => entry,
|
||||
onAgentProcessRead: (process) => coordinator?.observeForegroundAgentProcess(process),
|
||||
onConfirmedShellForeground
|
||||
})
|
||||
coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
statusLane: 'pty',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(async () => processResult(foreground, hasChildren)),
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
shouldPollProcessCadence: () => args.visible,
|
||||
onForegroundAgentExited: (exited) => tracker.onProcessExitConfirmed(exited)
|
||||
})
|
||||
coordinator.startProcessTracking()
|
||||
return {
|
||||
tracker,
|
||||
onConfirmedShellForeground,
|
||||
getEntry: () => entry,
|
||||
setEntry: (next: PaneForegroundAgentEntry) => {
|
||||
entry = next
|
||||
},
|
||||
setForeground: (processName: string | null, children: boolean) => {
|
||||
foreground = processName
|
||||
hasChildren = children
|
||||
},
|
||||
confirmForegroundProcess
|
||||
}
|
||||
}
|
||||
|
||||
function sidebarAgent(title: string, entry: PaneForegroundAgentEntry | undefined) {
|
||||
return resolveTitleDerivedPaneAgent({
|
||||
title,
|
||||
defaultTitle: 'Terminal 2',
|
||||
titleShowsActivity: false,
|
||||
titleAgentType: null,
|
||||
launchAgentType: null,
|
||||
foreground: entry
|
||||
})
|
||||
}
|
||||
|
||||
describe('process monitor retires a latched foreground read', () => {
|
||||
useAgentCompletionCoordinatorLifecycle()
|
||||
|
||||
it('drops the row after Codex exits and the shell titles its prompt', async () => {
|
||||
const pane = createUnmarkedPane({ visible: true })
|
||||
pane.tracker.onVisiblePtyBound()
|
||||
await vi.advanceTimersByTimeAsync(2_500)
|
||||
expect(sidebarAgent('repo', pane.getEntry())).toBe('codex')
|
||||
|
||||
pane.setForeground('zsh', false)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
expect(pane.getEntry()).toEqual({ agent: null, shellForeground: false })
|
||||
expect(pane.onConfirmedShellForeground).toHaveBeenCalledWith('process-exit')
|
||||
expect(sidebarAgent('', pane.getEntry())).toBeNull()
|
||||
expect(sidebarAgent('user@host: ~/repo', pane.getEntry())).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the row when a killed Codex leaves its last title behind', async () => {
|
||||
const pane = createUnmarkedPane({ visible: true })
|
||||
pane.tracker.onVisiblePtyBound()
|
||||
await vi.advanceTimersByTimeAsync(2_500)
|
||||
|
||||
pane.setForeground('zsh', false)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
expect(sidebarAgent('repo', pane.getEntry())).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the row while Codex runs a nested shell', async () => {
|
||||
const pane = createUnmarkedPane({ visible: true })
|
||||
pane.tracker.onVisiblePtyBound()
|
||||
await vi.advanceTimersByTimeAsync(2_500)
|
||||
|
||||
pane.setForeground('zsh', true)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(sidebarAgent('repo', pane.getEntry())).toBe('codex')
|
||||
expect(pane.onConfirmedShellForeground).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('watches a hidden pane once its process read names the agent', async () => {
|
||||
const pane = createUnmarkedPane({ visible: false })
|
||||
pane.tracker.onVisiblePtyBound()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(sidebarAgent('repo', pane.getEntry())).toBe('codex')
|
||||
|
||||
pane.setForeground('zsh', false)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(sidebarAgent('repo', pane.getEntry())).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves an in-flight command-finished read to decide the pane', async () => {
|
||||
const pane = createUnmarkedPane({ visible: true })
|
||||
pane.tracker.onVisiblePtyBound()
|
||||
await vi.advanceTimersByTimeAsync(2_500)
|
||||
const pendingConfirm = createDeferred<string | null>()
|
||||
pane.confirmForegroundProcess.mockReturnValueOnce(pendingConfirm.promise)
|
||||
pane.tracker.onCommandFinished()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
pane.tracker.onProcessExitConfirmed({ agent: 'codex', processName: 'codex' })
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(pane.getEntry()).toMatchObject({ agent: 'codex', agentEvidence: 'process-read' })
|
||||
expect(pane.onConfirmedShellForeground).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retire a reattach launch record or another agent', () => {
|
||||
const pane = createUnmarkedPane({ visible: false })
|
||||
const launchRecord: PaneForegroundAgentEntry = {
|
||||
agent: 'codex',
|
||||
agentEvidence: 'launch-record',
|
||||
shellForeground: false
|
||||
}
|
||||
pane.setEntry(launchRecord)
|
||||
pane.tracker.onProcessExitConfirmed({ agent: 'codex', processName: 'codex' })
|
||||
expect(pane.getEntry()).toBe(launchRecord)
|
||||
|
||||
const claudeRead: PaneForegroundAgentEntry = {
|
||||
agent: 'claude',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
}
|
||||
pane.setEntry(claudeRead)
|
||||
pane.tracker.onProcessExitConfirmed({ agent: 'codex', processName: 'codex' })
|
||||
expect(pane.getEntry()).toBe(claudeRead)
|
||||
expect(pane.onConfirmedShellForeground).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@ type MockStoreState = {
|
||||
setAgentStatus: ReturnType<typeof vi.fn>
|
||||
dropAgentStatus: ReturnType<typeof vi.fn>
|
||||
clearAgentLaunchConfig: ReturnType<typeof vi.fn>
|
||||
setPaneForegroundAgent: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockStoreState: MockStoreState
|
||||
@@ -53,7 +54,8 @@ function makeMockStoreState(): MockStoreState {
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } },
|
||||
setAgentStatus: vi.fn(),
|
||||
dropAgentStatus: vi.fn(),
|
||||
clearAgentLaunchConfig: vi.fn()
|
||||
clearAgentLaunchConfig: vi.fn(),
|
||||
setPaneForegroundAgent: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +96,94 @@ describe('createParkedTerminalCommandStatusPolicy', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// #23767: a Codex that exits while parked must not keep its process identity (and sidebar row),
|
||||
// but a nested shell's leaked 133;D under a still-running agent must not drop it either.
|
||||
describe('parked command boundary with a known foreground agent', () => {
|
||||
const confirmForegroundProcess = vi.fn<(id: string) => Promise<string | null>>()
|
||||
const flushMicrotasks = async (): Promise<void> => {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
confirmForegroundProcess.mockReset()
|
||||
vi.stubGlobal('window', { api: { pty: { confirmForegroundProcess } } })
|
||||
mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = {
|
||||
agent: 'codex',
|
||||
shellForeground: false,
|
||||
routingTrusted: true
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps the agent when the confirmation still sees it (a leaked nested-shell marker)', async () => {
|
||||
confirmForegroundProcess.mockResolvedValue('codex')
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandFinished(0)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledWith(PTY_ID_LOCAL)
|
||||
expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalled()
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a shell', 'zsh'],
|
||||
['no answer', null]
|
||||
])('retires the agent when the confirmation finds %s', async (_label, processName) => {
|
||||
confirmForegroundProcess.mockResolvedValue(processName)
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandFinished(0)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(PANE_KEY, {
|
||||
agent: null,
|
||||
shellForeground: false
|
||||
})
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('retires an SSH pane without a fenced read it cannot make while parked', async () => {
|
||||
const policy = await createPolicy(PTY_ID_SSH)
|
||||
|
||||
policy.onCommandFinished(0)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(PANE_KEY, {
|
||||
agent: null,
|
||||
shellForeground: false
|
||||
})
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('writes nothing once the pane was revealed while the confirmation ran', async () => {
|
||||
confirmForegroundProcess.mockResolvedValue('zsh')
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandFinished(0)
|
||||
policy.dispose()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a pane with no process identity untouched at a parked command boundary', async () => {
|
||||
const policy = await createPolicy(PTY_ID_SSH)
|
||||
|
||||
policy.onCommandFinished(0)
|
||||
|
||||
expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalled()
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('seeds a Command Code working row with the current pane title', async () => {
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import { isShellProcess } from '../../../../shared/shell-process-detection'
|
||||
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
|
||||
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
setCommandCodeDoneSettleExecutor
|
||||
} from './command-code-done-settle'
|
||||
import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
|
||||
import { isRemoteExecutionHostPtyId } from './remote-execution-host-pty'
|
||||
|
||||
export type ParkedTerminalCommandStatusPolicy = {
|
||||
onCommandFinished: (bestEffortExitCode: number | null) => void
|
||||
@@ -150,6 +152,32 @@ export function createParkedTerminalCommandStatusPolicy(options: {
|
||||
settleCommandCodeDone
|
||||
)
|
||||
|
||||
// Why: a full-screen agent's nested shells leak 133;D, so make one confirming read (no retry
|
||||
// ladder, unlike the mounted tracker) and retire the process read only on a shell or no answer.
|
||||
const retireForegroundAgentUnlessConfirmed = async (): Promise<void> => {
|
||||
const entry = useAppStore.getState().paneForegroundAgentByPaneKey[paneKey]
|
||||
if (!entry?.agent) {
|
||||
return
|
||||
}
|
||||
// Why: a parked pane holds no remote incarnation to fence host evidence with, so it has no answer.
|
||||
const remote = isRemoteExecutionHostPtyId(ptyId) || parseAppSshPtyId(ptyId) !== null
|
||||
let processName: string | null = null
|
||||
if (!remote) {
|
||||
try {
|
||||
processName = await window.api.pty.confirmForegroundProcess(ptyId)
|
||||
} catch {
|
||||
processName = null
|
||||
}
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
if (disposed || state.paneForegroundAgentByPaneKey[paneKey] !== entry) {
|
||||
return
|
||||
}
|
||||
if (processName === null || isShellProcess(processName)) {
|
||||
state.setPaneForegroundAgent(paneKey, { agent: null, shellForeground: false })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onCommandFinished: (bestEffortExitCode: number | null): void => {
|
||||
if (disposed) {
|
||||
@@ -158,6 +186,7 @@ export function createParkedTerminalCommandStatusPolicy(options: {
|
||||
// Why: the finished command may have moved HEAD or the index (an agent running
|
||||
// `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll.
|
||||
dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode)
|
||||
void retireForegroundAgentUnlessConfirmed()
|
||||
// Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted
|
||||
// path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need
|
||||
// pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a
|
||||
|
||||
+5
-2
@@ -286,6 +286,7 @@ describe('connectPanePty', () => {
|
||||
expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -383,7 +384,8 @@ describe('connectPanePty', () => {
|
||||
it('keeps sleeping identity when the foreground check still sees the agent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex')
|
||||
// Why both: the pane's process monitor reads the same foreground the confirm read sees.
|
||||
vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex')
|
||||
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
|
||||
const ptyId = 'pty-sleeping-leaked-shell-marker'
|
||||
const transport = createMockTransport(ptyId)
|
||||
@@ -623,7 +625,8 @@ describe('connectPanePty', () => {
|
||||
it('keeps armed modes while the agent still owns the foreground after a leaked 133;D', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid')
|
||||
// Why both: the pane's process monitor reads the same foreground the confirm read sees.
|
||||
vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid')
|
||||
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
|
||||
const ptyId = 'pty-stale-mode-live-agent'
|
||||
const transport = createMockTransport(ptyId)
|
||||
|
||||
+7
@@ -287,6 +287,7 @@ describe('connectPanePty', () => {
|
||||
await vi.advanceTimersByTimeAsync(1200)
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -318,6 +319,7 @@ describe('connectPanePty', () => {
|
||||
expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId)
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -358,6 +360,7 @@ describe('connectPanePty', () => {
|
||||
expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId)
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -394,6 +397,7 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -405,6 +409,8 @@ describe('connectPanePty', () => {
|
||||
vi.useFakeTimers()
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
let foreground = 'bash.exe'
|
||||
// Why both: the pane's process monitor reads the same foreground the confirm read sees.
|
||||
vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground)
|
||||
vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground)
|
||||
const pane = createPane(1)
|
||||
const ptyId = 'pty-launched-droid-slow'
|
||||
@@ -440,6 +446,7 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
|
||||
+60
@@ -274,6 +274,7 @@ describe('connectPanePty', () => {
|
||||
expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]])
|
||||
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
@@ -416,6 +417,7 @@ describe('connectPanePty', () => {
|
||||
expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId)
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -425,6 +427,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks()
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -498,6 +501,7 @@ describe('connectPanePty', () => {
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'launch-record',
|
||||
shellForeground: false
|
||||
})
|
||||
expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter')
|
||||
@@ -505,6 +509,7 @@ describe('connectPanePty', () => {
|
||||
binding.sampleForegroundAgentOnFocus()
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'launch-record',
|
||||
shellForeground: false
|
||||
})
|
||||
expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter')
|
||||
@@ -513,12 +518,66 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u')
|
||||
})
|
||||
|
||||
it("keeps this session's process read when a parked pane re-attaches to the same agent", async () => {
|
||||
vi.useFakeTimers()
|
||||
const ptyId = 'pty-parked-reattach-keeps-read'
|
||||
const tabId = `tab-${ptyId}`
|
||||
const cacheKey = makePaneKey(tabId, LEAF_1)
|
||||
mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] }
|
||||
mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = {
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
}
|
||||
|
||||
await connectRestoredPaneForForegroundSampling({
|
||||
ptyId,
|
||||
tabId,
|
||||
isVisibleRef: { current: false },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds a launch record when the prior read named a different agent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ptyId = 'pty-parked-reattach-other-agent'
|
||||
const tabId = `tab-${ptyId}`
|
||||
const cacheKey = makePaneKey(tabId, LEAF_1)
|
||||
mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] }
|
||||
mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = {
|
||||
agent: 'claude',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
}
|
||||
|
||||
await connectRestoredPaneForForegroundSampling({
|
||||
ptyId,
|
||||
tabId,
|
||||
isVisibleRef: { current: false },
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'launch-record',
|
||||
shellForeground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('retires stale daemon launch identity when warm reattach finds the shell', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe')
|
||||
@@ -809,6 +868,7 @@ describe('connectPanePty', () => {
|
||||
})
|
||||
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
LEAF_1,
|
||||
createMockTransport,
|
||||
createPane,
|
||||
createManager,
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
|
||||
const {
|
||||
resetAndRefreshAllTerminalWebglAtlases,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({
|
||||
scheduleRuntimeGraphSync
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resetAndRefreshAllTerminalWebglAtlases
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (listener: (state: StoreState) => void) => {
|
||||
storeSubscribers.push(listener)
|
||||
return () => {
|
||||
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
|
||||
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
|
||||
})
|
||||
|
||||
vi.mock('./cache-timer-seeding', () => ({
|
||||
shouldSeedCacheTimerOnInitialTitle
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
info: toastInfo
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/codex-stale-pane-sweep', () => ({
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
}))
|
||||
|
||||
// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React).
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./pty-transport', () => ({
|
||||
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./remote-runtime-pty-transport', () => ({
|
||||
createRemoteRuntimePtyTransport: vi.fn(
|
||||
(_environmentId: string, options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher.
|
||||
vi.mock('./pty-dispatcher', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
getEagerPtyBufferHandle: vi.fn(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
function createDeps(overrides: Record<string, unknown> = {}) {
|
||||
return buildPaneConnectionDeps(() => mockStoreState, overrides)
|
||||
}
|
||||
|
||||
describe('connectPanePty process-exit retirement', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
async function connectRestoredPane(
|
||||
ptyId: string,
|
||||
visible: boolean
|
||||
): Promise<{ cacheKey: string; binding: { sampleForegroundAgentOnFocus: () => void } }> {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const tabId = `tab-${ptyId}`
|
||||
const transport = createMockTransport(ptyId)
|
||||
transport.getPtyId.mockImplementation(() => ptyId)
|
||||
transport.connect.mockImplementation(async () => null)
|
||||
transportFactoryQueue.push(transport)
|
||||
const deps = createDeps({
|
||||
tabId,
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: ptyId },
|
||||
isVisibleRef: { current: visible }
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: connectPanePty returns the pane binding, which exposes sampleForegroundAgentOnFocus.
|
||||
const binding = connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
deps as never
|
||||
) as unknown as { sampleForegroundAgentOnFocus: () => void }
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await flushAsyncTicks(20)
|
||||
return { cacheKey: makePaneKey(tabId, LEAF_1), binding }
|
||||
}
|
||||
|
||||
it('retires a hand-typed Codex read in a pane without command marks once it exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ptyId = 'pty-unmarked-codex-exit'
|
||||
let foreground: string | null = 'codex'
|
||||
let children = true
|
||||
vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground)
|
||||
vi.mocked(window.api.pty.hasChildProcesses).mockImplementation(async () => children)
|
||||
|
||||
const { cacheKey, binding } = await connectRestoredPane(ptyId, true)
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read'
|
||||
})
|
||||
|
||||
foreground = 'zsh'
|
||||
children = false
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: null,
|
||||
shellForeground: false
|
||||
})
|
||||
expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(cacheKey)
|
||||
|
||||
// Codex typed again in the same unmarked pane: the next sample must identify it.
|
||||
foreground = 'codex'
|
||||
children = true
|
||||
binding.sampleForegroundAgentOnFocus()
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read'
|
||||
})
|
||||
})
|
||||
})
|
||||
+1
@@ -359,6 +359,7 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
|
||||
@@ -167,6 +167,10 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
isRemotePtyId,
|
||||
getExpectedIncarnationId: () => session.remotePtyIncarnationId ?? null,
|
||||
publish: (entry) => useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry),
|
||||
getPublishedEntry: () => useAppStore.getState().paneForegroundAgentByPaneKey[session.cacheKey],
|
||||
// Why lazy: the completion coordinator is installed after this tracker.
|
||||
onAgentProcessRead: (process) =>
|
||||
session.agentCompletionCoordinator?.observeForegroundAgentProcess(process),
|
||||
hasKnownAgentIdentity: session.paneHasKnownAgentIdentity,
|
||||
onConfirmedShellForeground: (reason) => {
|
||||
// Why: a confirmed local shell proves any hibernation record for this pane is stale;
|
||||
@@ -181,7 +185,13 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
// surviving shell then receives pointer moves as typed SGR reports; the
|
||||
// replay guard keeps xterm's auto-replies from leaking to the shell.
|
||||
session.writeInputModeGround(CONFIRMED_SHELL_MODE_RESET)
|
||||
if (reason === 'visible-pty') {
|
||||
// Why: no 133;D backs these proofs, so a deferred command-finished drop keeps its own read.
|
||||
if (reason === 'process-exit') {
|
||||
// Why: reopen the one-shot visible sample so the next agent typed here is identified.
|
||||
session.visibleForegroundSamplePending = false
|
||||
session.visibleForegroundSampleSettled = false
|
||||
}
|
||||
if (reason === 'visible-pty' || reason === 'process-exit') {
|
||||
state.clearAgentLaunchConfig(session.cacheKey)
|
||||
return
|
||||
}
|
||||
@@ -202,6 +212,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
}
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: foreground.agent,
|
||||
agentEvidence: foreground.agentEvidence,
|
||||
routingRevoked: true,
|
||||
shellForeground: foreground.shellForeground
|
||||
})
|
||||
@@ -300,6 +311,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
// as a hint, but revoke bytes until one current provider confirmation lands.
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: foreground.agent,
|
||||
agentEvidence: foreground.agentEvidence,
|
||||
routingRevoked: true,
|
||||
shellForeground: false
|
||||
})
|
||||
@@ -311,6 +323,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
if (session.paneForegroundAgentTracker.hasReadInFlight()) {
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: foreground.agent,
|
||||
agentEvidence: foreground.agentEvidence,
|
||||
routingRevoked: true,
|
||||
shellForeground: false,
|
||||
routingConfirmationPending: true
|
||||
|
||||
@@ -156,8 +156,14 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi
|
||||
if (metadata?.launchAgent) {
|
||||
// Why: daemon launch identity can outlive the process while Orca is
|
||||
// closed. Use it to request confirmation, never as current byte authority.
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
const state = useAppStore.getState()
|
||||
const current = state.paneForegroundAgentByPaneKey[session.cacheKey]
|
||||
// Why: re-mounting a parked pane must not demote this session's own read of the same agent.
|
||||
const keepsRead =
|
||||
current?.agent === metadata.launchAgent && current.agentEvidence === 'process-read'
|
||||
state.setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: metadata.launchAgent,
|
||||
agentEvidence: keepsRead ? 'process-read' : 'launch-record',
|
||||
shellForeground: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,6 +207,9 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void
|
||||
currentAgentForExited !== exited.agent
|
||||
)
|
||||
},
|
||||
// Why: the pane's only re-derivation of a process read where the shell emits no command marks.
|
||||
onForegroundAgentExited: (exited) =>
|
||||
session.paneForegroundAgentTracker?.onProcessExitConfirmed(exited),
|
||||
dispatchCompletion: (title, meta) => {
|
||||
if (meta?.source === 'process-exit') {
|
||||
session.clearSuppressedTitleSideEffects()
|
||||
|
||||
@@ -70,6 +70,26 @@ describe('pane foreground agent slice', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a reattach seeds the launch agent; the read confirming the same agent must still land,
|
||||
// or sidebar rows would never treat the pane as process-backed.
|
||||
it('lands a process read that confirms a launch-record agent', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setPaneForegroundAgent('tab-1:leaf-1', {
|
||||
agent: 'codex',
|
||||
agentEvidence: 'launch-record',
|
||||
shellForeground: false
|
||||
})
|
||||
store.getState().setPaneForegroundAgent('tab-1:leaf-1', {
|
||||
agent: 'codex',
|
||||
agentEvidence: 'process-read',
|
||||
shellForeground: false
|
||||
})
|
||||
|
||||
expect(store.getState().paneForegroundAgentByPaneKey['tab-1:leaf-1']?.agentEvidence).toBe(
|
||||
'process-read'
|
||||
)
|
||||
})
|
||||
|
||||
it('sweeps only the closed tab prefix, not sibling tabs or prefix-share ids', () => {
|
||||
const store = createTestStore()
|
||||
store
|
||||
|
||||
@@ -2,9 +2,15 @@ import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
|
||||
/** 'launch-record': the daemon's launch identity on reattach, which can outlive the process. */
|
||||
export type PaneForegroundAgentEvidence = 'process-read' | 'launch-record'
|
||||
|
||||
export type PaneForegroundAgentEntry = {
|
||||
/** Recognized agent process in the pane's foreground; null when unknown. */
|
||||
agent: TuiAgent | null
|
||||
/** Where `agent` came from. Only 'process-read' is a live read; absent counts as unconfirmed,
|
||||
* so a writer that omits it cannot make a sidebar row look process-backed. */
|
||||
agentEvidence?: PaneForegroundAgentEvidence
|
||||
/** True only when fresh provider evidence is safe for input-byte routing. */
|
||||
routingTrusted?: boolean
|
||||
/** True after exit/input evidence revokes routing until provider confirmation. */
|
||||
@@ -46,6 +52,7 @@ export const createPaneForegroundAgentSlice: StateCreator<
|
||||
if (
|
||||
current &&
|
||||
current.agent === entry.agent &&
|
||||
current.agentEvidence === entry.agentEvidence &&
|
||||
current.routingTrusted === entry.routingTrusted &&
|
||||
current.routingRevoked === entry.routingRevoked &&
|
||||
current.routingConfirmationPending === entry.routingConfirmationPending &&
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { titleShowsNoAgent } from './shell-process-detection'
|
||||
|
||||
describe('titleShowsNoAgent', () => {
|
||||
it.each([
|
||||
'MINGW64:/c/Users/dev/repo',
|
||||
'MINGW32:/c/repo',
|
||||
'MSYS:/home/dev',
|
||||
'UCRT64:/c/repo',
|
||||
'CLANG64:/c/repo',
|
||||
'CLANGARM64:/c/repo'
|
||||
])('reads the Git Bash prompt title %s as the shell', (title) => {
|
||||
expect(titleShowsNoAgent(title)).toBe(true)
|
||||
})
|
||||
|
||||
it('still reads shell names and the default title as the shell', () => {
|
||||
expect(titleShowsNoAgent('zsh')).toBe(true)
|
||||
expect(titleShowsNoAgent('bash.exe')).toBe(true)
|
||||
expect(titleShowsNoAgent('Terminal 2', 'Terminal 2')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['demo-repo', 'Codex', '⠋ MINGW64:/c/repo', 'MINGW64 notes', 'mingw64:/c/repo', ''])(
|
||||
'does not read %j as the shell',
|
||||
(title) => {
|
||||
expect(titleShowsNoAgent(title)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -24,10 +24,19 @@ export function isShellProcess(processName: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** A shell name or the tab's neutral default title; blank titles are no evidence. */
|
||||
// Why: Git for Windows' prompt titles the pane `$MSYSTEM:$PWD` (`MINGW64:/c/repo`), and Git Bash
|
||||
// has no command marks, so that repaint is the only sign an agent there handed back to the shell.
|
||||
const MSYS_PROMPT_TITLE_RE = /^(?:MINGW(?:32|64)|MSYS|UCRT64|CLANG(?:32|64|ARM64)):\//
|
||||
|
||||
/** A shell name, a Git Bash prompt title, or the tab's neutral default title; blank is no evidence. */
|
||||
export function titleShowsNoAgent(title: string, defaultTitle?: string): boolean {
|
||||
const trimmed = title.trim()
|
||||
return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim())
|
||||
return (
|
||||
trimmed.length > 0 &&
|
||||
(isShellProcess(trimmed) ||
|
||||
MSYS_PROMPT_TITLE_RE.test(trimmed) ||
|
||||
trimmed === defaultTitle?.trim())
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a ConPTY-side buffer clear cannot reach PSReadLine's cached cursor
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('daemon foreground confirmation composes with pane tracking', () => {
|
||||
|
||||
expect(publish).toHaveBeenCalledExactlyOnceWith({
|
||||
agent: 'droid',
|
||||
agentEvidence: 'process-read',
|
||||
routingTrusted: true,
|
||||
shellForeground: false
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user