mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up)
Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so OSC 133;D and Command Code scrape signals went dark while parked. New parked-terminal-command-status.ts ports the store-level subset: git-UI nudge on every command finish, same-turn status-row drop for SSH PTYs (exact mounted-path parity — the foreground tracker refuses SSH ids), and the Command Code working seed / 1500ms done settle. Byte mode scans the same shared parsers for authority-off parity. Local-PTY status drops stay with the mounted pane: they need pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a real agent exit. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -43,6 +43,18 @@ vi.mock('./use-notification-dispatch', () => ({
|
||||
dispatchTerminalNotification
|
||||
}))
|
||||
|
||||
// Why: command-status semantics are covered in parked-terminal-command-status.test.ts;
|
||||
// these tests only prove the watcher wires bytes/facts into the policy.
|
||||
const commandStatusPolicy = {
|
||||
onCommandFinished: vi.fn(),
|
||||
onCommandCodeWorking: vi.fn(),
|
||||
onCommandCodeDone: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
vi.mock('./parked-terminal-command-status', () => ({
|
||||
createParkedTerminalCommandStatusPolicy: vi.fn(() => commandStatusPolicy)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/terminal-theme', () => ({
|
||||
getSystemPrefersDark: () => true
|
||||
}))
|
||||
@@ -110,6 +122,10 @@ describe('startParkedTerminalByteWatcher', () => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
dispatchTerminalNotification.mockClear()
|
||||
commandStatusPolicy.onCommandFinished.mockClear()
|
||||
commandStatusPolicy.onCommandCodeWorking.mockClear()
|
||||
commandStatusPolicy.onCommandCodeDone.mockClear()
|
||||
commandStatusPolicy.dispose.mockClear()
|
||||
onData = null
|
||||
mockStoreState = createMockStoreState()
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
@@ -372,6 +388,26 @@ describe('startParkedTerminalByteWatcher', () => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('scans OSC 133;D bytes into the parked command policy and disposes it with the watcher', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x1b]133;D;0\x07')
|
||||
expect(commandStatusPolicy.onCommandFinished).toHaveBeenCalledWith(0)
|
||||
|
||||
dispose()
|
||||
expect(commandStatusPolicy.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('feeds Command Code output through the parked byte detector', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('# Command Code v0.27.2')
|
||||
emit('⌘ Parsing...')
|
||||
|
||||
expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledTimes(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires completion when seeded with a working title and the agent goes idle while parked', async () => {
|
||||
// Why: the pane was working at park time; the watcher's fresh tracker
|
||||
// must be seeded or this working→idle transition can never fire.
|
||||
@@ -575,6 +611,32 @@ describe('startParkedTerminalByteWatcher', () => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('routes command lifecycle facts into the parked command policy', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'command-finished', exitCode: 0 },
|
||||
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
|
||||
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
|
||||
])
|
||||
|
||||
expect(commandStatusPolicy.onCommandFinished).toHaveBeenCalledWith(0)
|
||||
expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledWith('Fix the spinner')
|
||||
expect(commandStatusPolicy.onCommandCodeDone).toHaveBeenCalledWith('Fix the spinner')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('ignores replayed command lifecycle facts (no-attention-replay rule)', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([{ kind: 'command-finished', exitCode: 0 }], { seq: 5, replay: true })
|
||||
|
||||
expect(commandStatusPolicy.onCommandFinished).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not request a title snapshot for an ordinary parked watcher', async () => {
|
||||
enableMainAuthority()
|
||||
const getSideEffectSnapshot = vi.fn()
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState,
|
||||
isAgentTaskCompleteTrackingEnabledFromState
|
||||
} from './agent-task-complete-policy'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status'
|
||||
import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished'
|
||||
import { createParkedTerminalCommandStatusPolicy } from './parked-terminal-command-status'
|
||||
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
|
||||
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
|
||||
import { createPtyOutputProcessor } from './pty-transport'
|
||||
@@ -187,6 +190,16 @@ export function startParkedTerminalByteWatcher(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: command-lifecycle signals drive store-level policy only (git nudge, SSH same-turn
|
||||
// status drop, Command Code seed/settle); the pane-coupled parts stay with the mounted pane.
|
||||
const commandStatusPolicy = createParkedTerminalCommandStatusPolicy({
|
||||
ptyId,
|
||||
worktreeId,
|
||||
tabId,
|
||||
paneId,
|
||||
paneKey
|
||||
})
|
||||
|
||||
// Why: with the authority switch on, the fact consumer is the single policy consumer — registering byte parsers too would double-fire bells.
|
||||
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: useAppStore.getState().settings,
|
||||
@@ -214,12 +227,26 @@ export function startParkedTerminalByteWatcher(
|
||||
const observeTerminalGitHubPRLink = mainSideEffectAuthority
|
||||
? null
|
||||
: createTerminalGitHubPRLinkDetector()
|
||||
// Why (byte-parser mode only): mode parity — main's tracker emits these as facts; the byte
|
||||
// path scans the same shared parsers the mounted kill-switch-off pane uses.
|
||||
const commandFinishedScanner = mainSideEffectAuthority
|
||||
? null
|
||||
: createOsc133CommandFinishedScanner(commandStatusPolicy.onCommandFinished)
|
||||
const commandCodeOutputStatusDetector = mainSideEffectAuthority
|
||||
? null
|
||||
: createCommandCodeOutputStatusDetector({
|
||||
onWorking: commandStatusPolicy.onCommandCodeWorking,
|
||||
onDone: commandStatusPolicy.onCommandCodeDone
|
||||
})
|
||||
const unregisterFactConsumer = mainSideEffectAuthority
|
||||
? registerTerminalSideEffectFactConsumer({
|
||||
ptyId,
|
||||
// Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did.
|
||||
callbacks: {
|
||||
...sideEffectCallbacks,
|
||||
onCommandFinished: commandStatusPolicy.onCommandFinished,
|
||||
onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking,
|
||||
onCommandCodeDone: commandStatusPolicy.onCommandCodeDone,
|
||||
onPrLink: (link) =>
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
|
||||
// Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6).
|
||||
@@ -247,6 +274,8 @@ export function startParkedTerminalByteWatcher(
|
||||
: subscribeToPtyData(ptyId, (data) => {
|
||||
// Why: empty pane callbacks — no xterm to deliver bytes to, the watcher wants only the parser side effects.
|
||||
processor.processData(data, {})
|
||||
commandFinishedScanner?.scan(data)
|
||||
commandCodeOutputStatusDetector?.observe(data)
|
||||
if (observeTerminalGitHubPRLink) {
|
||||
for (const link of observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
|
||||
@@ -266,6 +295,8 @@ export function startParkedTerminalByteWatcher(
|
||||
unregisterFactConsumer?.()
|
||||
// Why: clears tracker/timer/detector state so the watcher can't fire after the revealed pane's live parsers take over.
|
||||
processor?.clearAccumulatedState()
|
||||
commandFinishedScanner?.reset()
|
||||
commandStatusPolicy.dispose()
|
||||
clearBellNotificationTimer()
|
||||
clearAgentTaskCompleteTimer()
|
||||
pendingBellNotification = false
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
|
||||
const PTY_ID_LOCAL = 'pty-1'
|
||||
const PTY_ID_SSH = 'ssh:target-1@@pty-9'
|
||||
const TAB_ID = 'tab-1'
|
||||
const WORKTREE_ID = 'repo-1::/tmp/wt-1'
|
||||
const PANE_KEY = `${TAB_ID}:11111111-1111-4111-8111-111111111111`
|
||||
const PANE_ID = 1
|
||||
// Mirrors COMMAND_CODE_OUTPUT_DONE_SETTLE_MS.
|
||||
const DONE_SETTLE_MS = 1500
|
||||
|
||||
const ROUTING = { connectionId: null }
|
||||
|
||||
type MockStoreState = {
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string | undefined>>
|
||||
setAgentStatus: ReturnType<typeof vi.fn>
|
||||
dropAgentStatus: ReturnType<typeof vi.fn>
|
||||
clearAgentLaunchConfig: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockStoreState: MockStoreState
|
||||
const dispatchTerminalCommandFinishedEvent = vi.fn()
|
||||
const resolveLiveAgentStatusConnectionRouting = vi.fn()
|
||||
const getConnectionIdFromState = vi.fn()
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mockStoreState }
|
||||
}))
|
||||
vi.mock('@/hooks/terminal-command-finished-event', () => ({
|
||||
dispatchTerminalCommandFinishedEvent
|
||||
}))
|
||||
vi.mock('@/lib/agent-status-connection-ownership', () => ({
|
||||
resolveLiveAgentStatusConnectionRouting
|
||||
}))
|
||||
vi.mock('@/lib/connection-owner-resolution', () => ({
|
||||
getConnectionIdFromState
|
||||
}))
|
||||
|
||||
function makeStatusEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'build the feature',
|
||||
agentType: 'claude',
|
||||
updatedAt: 1000,
|
||||
stateStartedAt: 1000,
|
||||
...overrides
|
||||
} as AgentStatusEntry
|
||||
}
|
||||
|
||||
async function createPolicy(ptyId: string) {
|
||||
const { createParkedTerminalCommandStatusPolicy } =
|
||||
await import('./parked-terminal-command-status')
|
||||
return createParkedTerminalCommandStatusPolicy({
|
||||
ptyId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: TAB_ID,
|
||||
paneId: PANE_ID,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
}
|
||||
|
||||
describe('createParkedTerminalCommandStatusPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
dispatchTerminalCommandFinishedEvent.mockClear()
|
||||
resolveLiveAgentStatusConnectionRouting.mockReset().mockReturnValue(ROUTING)
|
||||
getConnectionIdFromState.mockReset().mockReturnValue(null)
|
||||
mockStoreState = {
|
||||
agentStatusByPaneKey: {},
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } },
|
||||
setAgentStatus: vi.fn(),
|
||||
dropAgentStatus: vi.fn(),
|
||||
clearAgentLaunchConfig: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('seeds a Command Code working row with the current pane title', async () => {
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandCodeWorking('Fix the spinner')
|
||||
|
||||
expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
|
||||
PANE_KEY,
|
||||
{ state: 'working', prompt: 'Fix the spinner', agentType: 'command-code' },
|
||||
'✳ Build feature',
|
||||
undefined,
|
||||
ROUTING
|
||||
)
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('writes nothing when connection routing does not resolve', async () => {
|
||||
resolveLiveAgentStatusConnectionRouting.mockReturnValue(undefined)
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandCodeWorking('Fix the spinner')
|
||||
|
||||
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('leaves a settled done row alone when working repeats the same prompt', async () => {
|
||||
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
|
||||
state: 'done',
|
||||
prompt: 'Fix the spinner',
|
||||
agentType: 'command-code'
|
||||
})
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandCodeWorking('Fix the spinner')
|
||||
policy.onCommandCodeWorking('')
|
||||
|
||||
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('settles a still-working Command Code row to done after the settle window', async () => {
|
||||
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
|
||||
state: 'working',
|
||||
prompt: 'Fix the spinner',
|
||||
agentType: 'command-code'
|
||||
})
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandCodeDone('Fix the spinner')
|
||||
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(DONE_SETTLE_MS)
|
||||
|
||||
expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
|
||||
PANE_KEY,
|
||||
{ state: 'done', prompt: 'Fix the spinner', agentType: 'command-code' },
|
||||
'✳ Build feature',
|
||||
undefined,
|
||||
ROUTING
|
||||
)
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('abandons the done settle when another agent owns the row by then', async () => {
|
||||
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'claude' })
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandCodeDone('Fix the spinner')
|
||||
vi.advanceTimersByTime(DONE_SETTLE_MS)
|
||||
|
||||
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('cancels a pending done settle when a working repaint arrives', async () => {
|
||||
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
|
||||
state: 'working',
|
||||
prompt: 'Fix the spinner',
|
||||
agentType: 'command-code'
|
||||
})
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandCodeDone('Fix the spinner')
|
||||
policy.onCommandCodeWorking('Fix the spinner')
|
||||
vi.advanceTimersByTime(DONE_SETTLE_MS * 2)
|
||||
|
||||
const states = mockStoreState.setAgentStatus.mock.calls.map(([, payload]) => payload.state)
|
||||
expect(states).toEqual(['working'])
|
||||
policy.dispose()
|
||||
})
|
||||
|
||||
it('dispose cancels a pending done settle', async () => {
|
||||
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
|
||||
state: 'working',
|
||||
prompt: 'Fix the spinner',
|
||||
agentType: 'command-code'
|
||||
})
|
||||
const policy = await createPolicy(PTY_ID_LOCAL)
|
||||
|
||||
policy.onCommandCodeDone('Fix the spinner')
|
||||
policy.dispose()
|
||||
vi.advanceTimersByTime(DONE_SETTLE_MS * 2)
|
||||
|
||||
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('nudges git UI on command finished for every PTY class', async () => {
|
||||
const local = await createPolicy(PTY_ID_LOCAL)
|
||||
local.onCommandFinished(0)
|
||||
local.dispose()
|
||||
const ssh = await createPolicy(PTY_ID_SSH)
|
||||
ssh.onCommandFinished(0)
|
||||
ssh.dispose()
|
||||
|
||||
expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledTimes(2)
|
||||
expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
})
|
||||
|
||||
it('drops a same-turn status row on command finished for SSH PTYs only', async () => {
|
||||
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry()
|
||||
const local = await createPolicy(PTY_ID_LOCAL)
|
||||
local.onCommandFinished(0)
|
||||
// Why: local drops need the mounted pane's foreground process-confirm ladder
|
||||
// (leaked nested-shell 133;D protection), so the watcher must not drop them.
|
||||
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
|
||||
local.dispose()
|
||||
|
||||
const ssh = await createPolicy(PTY_ID_SSH)
|
||||
ssh.onCommandFinished(0)
|
||||
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(PANE_KEY)
|
||||
ssh.dispose()
|
||||
})
|
||||
|
||||
it('clears the launch registry on SSH command finished when no status row exists', async () => {
|
||||
const ssh = await createPolicy(PTY_ID_SSH)
|
||||
|
||||
ssh.onCommandFinished(0)
|
||||
|
||||
expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(PANE_KEY)
|
||||
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
|
||||
ssh.dispose()
|
||||
})
|
||||
|
||||
it('does nothing after dispose', async () => {
|
||||
const ssh = await createPolicy(PTY_ID_SSH)
|
||||
ssh.dispose()
|
||||
|
||||
ssh.onCommandFinished(0)
|
||||
ssh.onCommandCodeWorking('Fix the spinner')
|
||||
|
||||
expect(dispatchTerminalCommandFinishedEvent).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Parked-pane command-lifecycle status policy.
|
||||
* Why: parking unmounts TerminalPane, so OSC 133;D and Command Code scrape signals went dark.
|
||||
* This ports the store-level subset of pty-connection's handlers; the pane-coupled parts
|
||||
* (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane.
|
||||
*/
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
|
||||
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
// Mirrors pty-connection.ts COMMAND_CODE_OUTPUT_DONE_SETTLE_MS — the settle window must be
|
||||
// identical whether the pane is mounted or parked, or park/reveal changes completion timing.
|
||||
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
|
||||
|
||||
export type ParkedTerminalCommandStatusPolicy = {
|
||||
onCommandFinished: (bestEffortExitCode: number | null) => void
|
||||
onCommandCodeWorking: (prompt: string) => void
|
||||
onCommandCodeDone: (prompt: string) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createParkedTerminalCommandStatusPolicy(options: {
|
||||
ptyId: string
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
/** PaneManager pane id whose runtime-title slot the watcher writes; read for status title pairing. */
|
||||
paneId: number
|
||||
paneKey: string
|
||||
}): ParkedTerminalCommandStatusPolicy {
|
||||
const { ptyId, worktreeId, tabId, paneId, paneKey } = options
|
||||
let disposed = false
|
||||
let commandCodeOutputDoneTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearCommandCodeOutputDoneTimer = (): void => {
|
||||
if (commandCodeOutputDoneTimer !== null) {
|
||||
clearTimeout(commandCodeOutputDoneTimer)
|
||||
commandCodeOutputDoneTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveRouting = (): ReturnType<typeof resolveLiveAgentStatusConnectionRouting> => {
|
||||
if (disposed) {
|
||||
return undefined
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
return resolveLiveAgentStatusConnectionRouting({
|
||||
state,
|
||||
paneKey,
|
||||
ptyId,
|
||||
expectedConnectionId: getConnectionIdFromState(state, worktreeId)
|
||||
})
|
||||
}
|
||||
|
||||
// Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference
|
||||
// option: parked panes receive no key events, so inference never has evidence here.
|
||||
const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => {
|
||||
const state = useAppStore.getState()
|
||||
if (!entry) {
|
||||
// Why: an Orca-started agent can exit before its first hook status; clear the launch
|
||||
// registry on command exit like the mounted path does.
|
||||
state.clearAgentLaunchConfig(paneKey)
|
||||
return
|
||||
}
|
||||
const current = state.agentStatusByPaneKey[paneKey]
|
||||
if (!current) {
|
||||
state.clearAgentLaunchConfig(paneKey)
|
||||
return
|
||||
}
|
||||
const unchanged =
|
||||
current.state === entry.state &&
|
||||
current.prompt === entry.prompt &&
|
||||
current.updatedAt === entry.updatedAt &&
|
||||
current.stateStartedAt === entry.stateStartedAt &&
|
||||
current.agentType === entry.agentType
|
||||
if (!unchanged) {
|
||||
return
|
||||
}
|
||||
state.dropAgentStatus(paneKey)
|
||||
}
|
||||
|
||||
return {
|
||||
onCommandFinished: (): void => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
// 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
|
||||
// real agent exit, so their drop stays with the mounted pane.
|
||||
if (parseAppSshPtyId(ptyId) === null) {
|
||||
return
|
||||
}
|
||||
dropCommandFinishedStatusIfSameTurn(useAppStore.getState().agentStatusByPaneKey[paneKey])
|
||||
},
|
||||
|
||||
// Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only).
|
||||
onCommandCodeWorking: (prompt: string): void => {
|
||||
clearCommandCodeOutputDoneTimer()
|
||||
const routing = resolveRouting()
|
||||
if (!routing) {
|
||||
return
|
||||
}
|
||||
const currentState = useAppStore.getState()
|
||||
const currentEntry = currentState.agentStatusByPaneKey[paneKey]
|
||||
const currentTitle = currentState.runtimePaneTitlesByTabId?.[tabId]?.[paneId]
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (
|
||||
currentEntry?.agentType === 'command-code' &&
|
||||
currentEntry.state === 'done' &&
|
||||
(!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim())
|
||||
) {
|
||||
return
|
||||
}
|
||||
currentState.setAgentStatus(
|
||||
paneKey,
|
||||
{
|
||||
state: 'working',
|
||||
prompt:
|
||||
normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''),
|
||||
agentType: 'command-code'
|
||||
},
|
||||
currentTitle,
|
||||
undefined,
|
||||
routing
|
||||
)
|
||||
},
|
||||
|
||||
// Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering
|
||||
// the composer while tools run, so only complete the row if no active repaint arrives.
|
||||
onCommandCodeDone: (prompt: string): void => {
|
||||
clearCommandCodeOutputDoneTimer()
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (!normalizedPrompt) {
|
||||
return
|
||||
}
|
||||
commandCodeOutputDoneTimer = setTimeout(() => {
|
||||
commandCodeOutputDoneTimer = null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const routing = resolveRouting()
|
||||
if (!routing) {
|
||||
return
|
||||
}
|
||||
const currentState = useAppStore.getState()
|
||||
const currentEntry = currentState.agentStatusByPaneKey[paneKey]
|
||||
if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') {
|
||||
return
|
||||
}
|
||||
const currentPrompt = currentEntry.prompt.trim()
|
||||
if (currentPrompt && currentPrompt !== normalizedPrompt) {
|
||||
return
|
||||
}
|
||||
const currentTitle = currentState.runtimePaneTitlesByTabId?.[tabId]?.[paneId]
|
||||
currentState.setAgentStatus(
|
||||
paneKey,
|
||||
{
|
||||
state: 'done',
|
||||
prompt: currentPrompt || normalizedPrompt,
|
||||
agentType: 'command-code'
|
||||
},
|
||||
currentTitle,
|
||||
undefined,
|
||||
routing
|
||||
)
|
||||
}, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
|
||||
},
|
||||
|
||||
dispose: (): void => {
|
||||
disposed = true
|
||||
clearCommandCodeOutputDoneTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user