Fix typed OMP remote title flicker (#6954)
This commit is contained in:
parent
f1bcb77392
commit
0ff2c09002
|
|
@ -12262,6 +12262,303 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('normalizes Pi-compatible mobile session status to OMP for an unknown-launch foreground omp PTY', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
|
||||
const getForegroundProcess = vi.fn(async () => 'omp')
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'typed-omp-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Terminal'
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp')
|
||||
expect(result.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: 'OMP ready',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalHandle: terminal.handle,
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(result.tabs[0]).not.toHaveProperty('launchAgent')
|
||||
})
|
||||
|
||||
it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => {
|
||||
const foregroundProcess = deferred<string | null>()
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
|
||||
const getForegroundProcess = vi.fn(() => foregroundProcess.promise)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'typed-omp-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Terminal'
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp')
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
foregroundProcess.resolve('omp')
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: 'OMP ready',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalHandle: terminal.handle,
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => {
|
||||
const foregroundProcess = deferred<string | null>()
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
|
||||
const getForegroundProcess = vi.fn(() => foregroundProcess.promise)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'typed-omp-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Terminal'
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
foregroundProcess.resolve('omp')
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: 'OMP ready',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalHandle: terminal.handle,
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('coalesces same-status title frames behind one post-title foreground probe', async () => {
|
||||
const staleForegroundProcess = deferred<string | null>()
|
||||
const freshForegroundProcess = deferred<string | null>()
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
|
||||
const getForegroundProcess = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(staleForegroundProcess.promise)
|
||||
.mockReturnValueOnce(freshForegroundProcess.promise)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'typed-omp-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Terminal'
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124)
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi done\x07', 125)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
staleForegroundProcess.resolve(null)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
freshForegroundProcess.resolve('omp')
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: 'OMP ready',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalHandle: terminal.handle,
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('starts a post-title foreground probe when an older pending probe finds no owner', async () => {
|
||||
const staleForegroundProcess = deferred<string | null>()
|
||||
const freshForegroundProcess = deferred<string | null>()
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
|
||||
const getForegroundProcess = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(staleForegroundProcess.promise)
|
||||
.mockReturnValueOnce(freshForegroundProcess.promise)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'typed-omp-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Terminal'
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
;(
|
||||
runtime as unknown as {
|
||||
refreshPtyForegroundAgentFromController: (ptyId: string) => Promise<boolean>
|
||||
}
|
||||
).refreshPtyForegroundAgentFromController('pty-typed-omp')
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
staleForegroundProcess.resolve(null)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
freshForegroundProcess.resolve('omp')
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: 'OMP ready',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalHandle: terminal.handle,
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('keeps Pi-compatible mobile session status as Pi for an unknown-launch foreground pi PTY', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-pi' })
|
||||
const getForegroundProcess = vi.fn(async () => 'pi')
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'typed-pi-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Terminal'
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-typed-pi', '\x1b]0;Pi ready\x07', 123)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-pi')
|
||||
expect(result.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: 'Pi ready',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'done',
|
||||
agentType: 'pi',
|
||||
terminalHandle: terminal.handle,
|
||||
terminalTitle: 'Pi ready'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(result.tabs[0]).not.toHaveProperty('launchAgent')
|
||||
})
|
||||
|
||||
it('keeps renderer-vetted mobile agent status for custom-titled terminals', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
type AgentStatusEntry
|
||||
} from '../../shared/agent-status-types'
|
||||
import {
|
||||
hasCompatibleAgentTitleIdentity,
|
||||
normalizeCompatibleAgentStatusEntryForOwner,
|
||||
normalizeCompatibleAgentTitleForOwner
|
||||
} from '../../shared/agent-title-owner'
|
||||
|
|
@ -942,6 +943,12 @@ type RuntimePtyWorktreeRecord = {
|
|||
waitBlockedAt: number | null
|
||||
}
|
||||
|
||||
type PtyForegroundAgentRefresh = {
|
||||
promise: Promise<boolean>
|
||||
startedAfterTitleObservation: number
|
||||
requestedAfterTitleObservation: number
|
||||
}
|
||||
|
||||
function copySleepingAgentLaunchConfig(
|
||||
config: SleepingAgentLaunchConfig
|
||||
): SleepingAgentLaunchConfig {
|
||||
|
|
@ -1925,7 +1932,8 @@ export class OrcaRuntimeService {
|
|||
private resolvedWorktreeGeneration = 0
|
||||
private cloneInFlightByPath = new Map<string, Promise<void>>()
|
||||
private agentDetector: AgentDetector | null = null
|
||||
private ptyForegroundAgentRefreshes = new Map<string, Promise<void>>()
|
||||
private ptyForegroundAgentRefreshes = new Map<string, PtyForegroundAgentRefresh>()
|
||||
private ptyDelayedForegroundSnapshotTitleObservations = new Map<string, number>()
|
||||
private _orchestrationDb: OrchestrationDb | null = null
|
||||
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
|
||||
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
|
||||
|
|
@ -3253,7 +3261,7 @@ export class OrcaRuntimeService {
|
|||
|
||||
/**
|
||||
* Publishes a PTY-backed terminal tab snapshot to the synced mobile session,
|
||||
* normalizing Pi-compatible titles based on launch ownership.
|
||||
* normalizing Pi-compatible titles based on launch or foreground ownership.
|
||||
*/
|
||||
private publishPtyBackedMobileSessionTerminal(
|
||||
worktreeId: string,
|
||||
|
|
@ -3269,9 +3277,10 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
): void {
|
||||
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const ownerAgent = pty.launchAgent ?? pty.foregroundAgent
|
||||
const title = normalizeCompatibleAgentTitleForOwner(
|
||||
args.title ?? getLatestPtyTitle(pty) ?? 'Terminal',
|
||||
pty.launchAgent
|
||||
ownerAgent
|
||||
)
|
||||
const existingTab = existing?.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
|
|
@ -5194,11 +5203,25 @@ export class OrcaRuntimeService {
|
|||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolvePtyTuiIdleWaiters(pty, ptyId)
|
||||
}
|
||||
const shouldDelayMobileSnapshot =
|
||||
shouldTouchPtyBackedSessionTabs &&
|
||||
this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle)
|
||||
let foregroundRefresh: Promise<boolean> | undefined
|
||||
// Why: gate on an actual status transition — braille spinner frames
|
||||
// mutate the title every tick, so probing per-title-change would stream
|
||||
// a foreground query per frame during active work.
|
||||
if (prevStatus !== pty.lastAgentStatus) {
|
||||
this.refreshPtyForegroundAgent(ptyId)
|
||||
foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, {
|
||||
afterTitleObservation: observedAt
|
||||
})
|
||||
} else if (shouldDelayMobileSnapshot) {
|
||||
// Why: same-status compatible title changes can arrive before the
|
||||
// foreground owner probe settles; publishing them would flicker.
|
||||
foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt)
|
||||
}
|
||||
if (foregroundRefresh && shouldDelayMobileSnapshot) {
|
||||
shouldTouchPtyBackedSessionTabs = false
|
||||
this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8718,6 +8741,15 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
private shouldDelayPtyBackedMobileSnapshotForForegroundAgent(
|
||||
pty: RuntimePtyWorktreeRecord,
|
||||
title: string
|
||||
): boolean {
|
||||
return (
|
||||
!pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules an asynchronous query to check which agent process is currently
|
||||
* running in the foreground of a PTY.
|
||||
|
|
@ -8726,19 +8758,75 @@ export class OrcaRuntimeService {
|
|||
void this.refreshPtyForegroundAgentFromController(ptyId)
|
||||
}
|
||||
|
||||
private getPendingForegroundAgentRefreshForTitle(
|
||||
ptyId: string,
|
||||
titleObservedAt: number
|
||||
): Promise<boolean> | undefined {
|
||||
if (!this.ptyForegroundAgentRefreshes.has(ptyId)) {
|
||||
return undefined
|
||||
}
|
||||
return this.refreshPtyForegroundAgentFromController(ptyId, {
|
||||
afterTitleObservation: titleObservedAt
|
||||
})
|
||||
}
|
||||
|
||||
private delayPtyBackedMobileSnapshotForForegroundAgent(
|
||||
ptyId: string,
|
||||
titleObservedAt: number,
|
||||
foregroundRefresh: Promise<boolean>
|
||||
): void {
|
||||
this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt)
|
||||
void foregroundRefresh.then((foregroundAgentChanged) => {
|
||||
if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) {
|
||||
return
|
||||
}
|
||||
this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId)
|
||||
if (!foregroundAgentChanged) {
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates and manages in-flight foreground agent refresh queries
|
||||
* for a specific PTY.
|
||||
*/
|
||||
private refreshPtyForegroundAgentFromController(ptyId: string): Promise<void> {
|
||||
private refreshPtyForegroundAgentFromController(
|
||||
ptyId: string,
|
||||
options: { afterTitleObservation?: number } = {}
|
||||
): Promise<boolean> {
|
||||
const startedAfterTitleObservation = options.afterTitleObservation ?? 0
|
||||
const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId)
|
||||
if (pendingRefresh) {
|
||||
return pendingRefresh
|
||||
pendingRefresh.requestedAfterTitleObservation = Math.max(
|
||||
pendingRefresh.requestedAfterTitleObservation,
|
||||
startedAfterTitleObservation
|
||||
)
|
||||
return pendingRefresh.promise
|
||||
}
|
||||
const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => {
|
||||
this.ptyForegroundAgentRefreshes.delete(ptyId)
|
||||
const entry: PtyForegroundAgentRefresh = {
|
||||
promise: Promise.resolve(false),
|
||||
startedAfterTitleObservation,
|
||||
requestedAfterTitleObservation: startedAfterTitleObservation
|
||||
}
|
||||
const refresh = (async (): Promise<boolean> => {
|
||||
while (true) {
|
||||
entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation
|
||||
const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId)
|
||||
if (
|
||||
foregroundAgentChanged ||
|
||||
entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation
|
||||
) {
|
||||
return foregroundAgentChanged
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) {
|
||||
this.ptyForegroundAgentRefreshes.delete(ptyId)
|
||||
}
|
||||
})
|
||||
this.ptyForegroundAgentRefreshes.set(ptyId, refresh)
|
||||
entry.promise = refresh
|
||||
this.ptyForegroundAgentRefreshes.set(ptyId, entry)
|
||||
return refresh
|
||||
}
|
||||
|
||||
|
|
@ -8746,34 +8834,35 @@ export class OrcaRuntimeService {
|
|||
* Queries the PTY controller for the active foreground process, identifies if it
|
||||
* is a recognized agent, and updates the PTY's foreground agent state if changed.
|
||||
*/
|
||||
private async loadPtyForegroundAgentFromController(ptyId: string): Promise<void> {
|
||||
private async loadPtyForegroundAgentFromController(ptyId: string): Promise<boolean> {
|
||||
if (!this.ptyController) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (!pty?.connected) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
// Why: foregroundAgent is only consulted as the owner fallback when
|
||||
// launchAgent is unknown, so a known launchAgent makes the relay
|
||||
// getForegroundProcess round-trip pure waste (covers all launched agents).
|
||||
if (pty.launchAgent) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
let foregroundProcess: string | null
|
||||
try {
|
||||
foregroundProcess = await this.ptyController.getForegroundProcess(ptyId)
|
||||
} catch {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const foregroundAgent = foregroundProcess
|
||||
? (recognizeAgentProcess(foregroundProcess)?.agent ?? null)
|
||||
: null
|
||||
if (pty.foregroundAgent === foregroundAgent) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
pty.foregroundAgent = foregroundAgent
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
return true
|
||||
}
|
||||
|
||||
private getFreshExplicitAgentStatusForHandle(handle: string): {
|
||||
|
|
|
|||
|
|
@ -230,6 +230,9 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
|||
if (/Codex( working)?/.test(title)) {
|
||||
return /working/.test(title) ? 'working' : 'idle'
|
||||
}
|
||||
if (/^\s*(?:[\u2800-\u28ff]\s+)?(?:Pi|OMP)(?: ready| idle)?\s*$/i.test(title)) {
|
||||
return /[\u2800-\u28ff]/u.test(title) ? 'working' : 'idle'
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
|
@ -726,15 +729,18 @@ describe('connectPanePty', () => {
|
|||
markWorktreeUnread: vi.fn(),
|
||||
observeTerminalGitHubPullRequestLink: vi.fn(),
|
||||
recordTerminalInput: vi.fn(),
|
||||
setAgentStatus: vi.fn((paneKey: string, payload: Record<string, unknown>) => {
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
...payload,
|
||||
paneKey,
|
||||
updatedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
stateHistory: []
|
||||
setAgentStatus: vi.fn(
|
||||
(paneKey: string, payload: Record<string, unknown>, terminalTitle?: string | null) => {
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
...payload,
|
||||
paneKey,
|
||||
...(terminalTitle ? { terminalTitle } : {}),
|
||||
updatedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
removeAgentStatus: vi.fn(),
|
||||
dropAgentStatus: vi.fn(),
|
||||
markTerminalTabUnread: vi.fn(),
|
||||
|
|
@ -924,6 +930,451 @@ describe('connectPanePty', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-omp')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'omp\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('omp\r')
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready')
|
||||
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready')
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes after shell word deletion edits a typed command to omp', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-omp-edited')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'pi \x17omp\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r')
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready')
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-pi')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'pi\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('pi\r')
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready')
|
||||
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready')
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'pi',
|
||||
terminalTitle: 'Pi ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const now = Date.now()
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi',
|
||||
paneKey,
|
||||
terminalTitle: 'Pi ready',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now,
|
||||
stateHistory: []
|
||||
}
|
||||
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } }
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'omp\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('omp\r')
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready')
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'pi',
|
||||
terminalTitle: 'Pi ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } }
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'omp\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('omp\r')
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready')
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'pi',
|
||||
terminalTitle: 'Pi ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets a new typed omp command override a stale retained done status', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const now = Date.now()
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi',
|
||||
paneKey,
|
||||
terminalTitle: 'Pi ready',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now,
|
||||
stateHistory: []
|
||||
}
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-stale-done')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'omp\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready')
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks cursor edits when inferring a typed omp command', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r')
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks delete-key cursor edits when inferring a typed omp command', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-delete-edit')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips manual agent inference for large paste chunks', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-large-paste')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`)
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'pi',
|
||||
terminalTitle: 'Pi ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes manual agent inference when large paste input is cancelled', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'x'.repeat(4097))
|
||||
sendTerminalInputThroughPane(pane, '\x03')
|
||||
sendTerminalInputThroughPane(pane, 'omp\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves typed shell ownership through same-chunk command-finished side effects', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('remote:web-env-1@@pty-command-finished')
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks?: ConnectCallbacks }) => {
|
||||
dataCallbackRef.current = callbacks?.onData ?? null
|
||||
return 'remote:web-env-1@@pty-command-finished'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(1, 1)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
sendTerminalInputThroughPane(pane, 'omp\r')
|
||||
await flushAsyncTicks()
|
||||
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
const dataCallback = dataCallbackRef.current
|
||||
if (!dataCallback || !onTitleChange || !onAgentStatus) {
|
||||
throw new Error('missing remote PTY callbacks')
|
||||
}
|
||||
dataCallback('\x1b]133;D;0\x07')
|
||||
onTitleChange('Pi ready', 'Pi ready')
|
||||
onAgentStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'omp',
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('queues visible bulk output off the synchronous xterm write path', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,11 @@ import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stabl
|
|||
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
|
||||
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry,
|
||||
type AgentType
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
|
||||
import {
|
||||
createAgentInterruptInference,
|
||||
|
|
@ -156,6 +160,10 @@ import {
|
|||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import {
|
||||
isExpectedAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
} from '../../../../shared/agent-process-recognition'
|
||||
import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types'
|
||||
import { isWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
|
||||
|
|
@ -165,7 +173,6 @@ import {
|
|||
beginAgentStartupDeliveryAttempt,
|
||||
releaseAgentStartupDeliveryAttempt
|
||||
} from '@/lib/agent-startup-delayed-delivery'
|
||||
import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition'
|
||||
|
||||
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
|
||||
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
|
||||
|
|
@ -176,6 +183,7 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
|
|||
const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
|
||||
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
|
||||
const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500
|
||||
const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096
|
||||
const STARTUP_DRAFT_PASTE_QUIET_MS = 1500
|
||||
const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000
|
||||
const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
|
||||
|
|
@ -1238,12 +1246,219 @@ export function connectPanePty(
|
|||
}
|
||||
/**
|
||||
* Resolves the authoritative owner agent type for this pane, checking tab launch,
|
||||
* pane startup, and store state configuration.
|
||||
* pane startup, typed command ownership, and store state configuration.
|
||||
*
|
||||
* Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an
|
||||
* OMP-owned pane back to Pi; the stored status agentType is only the last-resort
|
||||
* fallback because it can itself be a Pi-compatible frame.
|
||||
* OMP-owned pane back to Pi; command ownership covers manually typed `omp`
|
||||
* in generic terminals where launch metadata does not exist.
|
||||
*/
|
||||
let commandInferredPaneAgent: TuiAgent | null = null
|
||||
let pendingShellCommandLine = ''
|
||||
let pendingShellCommandCursor = 0
|
||||
let commandInferredPaneAgentGeneration = 0
|
||||
let shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
const resetPendingShellCommandLine = (): void => {
|
||||
pendingShellCommandLine = ''
|
||||
pendingShellCommandCursor = 0
|
||||
}
|
||||
const rememberCommandInferredPaneAgent = (): void => {
|
||||
const commandLine = pendingShellCommandLine.trim()
|
||||
resetPendingShellCommandLine()
|
||||
const nextAgent = commandLine
|
||||
? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null)
|
||||
: null
|
||||
commandInferredPaneAgent = nextAgent
|
||||
commandInferredPaneAgentGeneration += 1
|
||||
}
|
||||
const clearCommandInferredPaneAgent = (): void => {
|
||||
commandInferredPaneAgent = null
|
||||
resetPendingShellCommandLine()
|
||||
commandInferredPaneAgentGeneration += 1
|
||||
}
|
||||
const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => {
|
||||
const generation = commandInferredPaneAgentGeneration
|
||||
resetPendingShellCommandLine()
|
||||
queueMicrotask(() => {
|
||||
setTimeout(() => {
|
||||
if (commandInferredPaneAgentGeneration === generation) {
|
||||
clearCommandInferredPaneAgent()
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
const appendPendingShellCommandInput = (text: string): void => {
|
||||
const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length
|
||||
if (available <= 0) {
|
||||
shellCommandInferenceSuspendedUntilCommandEnd = true
|
||||
return
|
||||
}
|
||||
const inserted = text.slice(0, available)
|
||||
pendingShellCommandLine =
|
||||
pendingShellCommandLine.slice(0, pendingShellCommandCursor) +
|
||||
inserted +
|
||||
pendingShellCommandLine.slice(pendingShellCommandCursor)
|
||||
pendingShellCommandCursor += inserted.length
|
||||
if (inserted.length < text.length) {
|
||||
shellCommandInferenceSuspendedUntilCommandEnd = true
|
||||
}
|
||||
}
|
||||
const deletePendingShellCommandWord = (): void => {
|
||||
const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor)
|
||||
const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor)
|
||||
const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '')
|
||||
pendingShellCommandLine = nextBeforeCursor + afterCursor
|
||||
pendingShellCommandCursor = nextBeforeCursor.length
|
||||
}
|
||||
const cancelSuspendedShellCommandInference = (): void => {
|
||||
if (!shellCommandInferenceSuspendedUntilCommandEnd) {
|
||||
return
|
||||
}
|
||||
shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
resetPendingShellCommandLine()
|
||||
}
|
||||
const deletePendingShellCommandCharacter = (): void => {
|
||||
if (pendingShellCommandCursor === 0) {
|
||||
return
|
||||
}
|
||||
pendingShellCommandLine =
|
||||
pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) +
|
||||
pendingShellCommandLine.slice(pendingShellCommandCursor)
|
||||
pendingShellCommandCursor -= 1
|
||||
}
|
||||
const deletePendingShellCommandCharacterAtCursor = (): void => {
|
||||
if (pendingShellCommandCursor >= pendingShellCommandLine.length) {
|
||||
return
|
||||
}
|
||||
pendingShellCommandLine =
|
||||
pendingShellCommandLine.slice(0, pendingShellCommandCursor) +
|
||||
pendingShellCommandLine.slice(pendingShellCommandCursor + 1)
|
||||
}
|
||||
const movePendingShellCommandCursor = (delta: number): void => {
|
||||
pendingShellCommandCursor = Math.min(
|
||||
pendingShellCommandLine.length,
|
||||
Math.max(0, pendingShellCommandCursor + delta)
|
||||
)
|
||||
}
|
||||
const consumeShellCommandCsiSequence = (data: string, index: number): number | null => {
|
||||
if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') {
|
||||
return null
|
||||
}
|
||||
let cursor = index + 2
|
||||
while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) {
|
||||
cursor += 1
|
||||
}
|
||||
const final = data[cursor]
|
||||
if (!final || !/[~A-Za-z]/.test(final)) {
|
||||
return null
|
||||
}
|
||||
const params = data.slice(index + 2, cursor)
|
||||
if (final === 'D') {
|
||||
movePendingShellCommandCursor(-1)
|
||||
} else if (final === 'C') {
|
||||
movePendingShellCommandCursor(1)
|
||||
} else if (final === 'H' || (final === '~' && params === '1')) {
|
||||
pendingShellCommandCursor = 0
|
||||
} else if (final === 'F' || (final === '~' && params === '4')) {
|
||||
pendingShellCommandCursor = pendingShellCommandLine.length
|
||||
} else if (final === '~' && params === '3') {
|
||||
deletePendingShellCommandCharacterAtCursor()
|
||||
} else if (final === '~' && (params === '200' || params === '201')) {
|
||||
// Bracketed paste wrappers are terminal framing, not shell command text.
|
||||
} else {
|
||||
resetPendingShellCommandLine()
|
||||
}
|
||||
return cursor + 1
|
||||
}
|
||||
const getLivePaneAgentTitle = (): string | null => {
|
||||
const state = useAppStore.getState()
|
||||
const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === deps.tabId
|
||||
)?.title
|
||||
return runtimeTitle ?? tabTitle ?? null
|
||||
}
|
||||
const hasFreshPaneAgentSurface = (): boolean => {
|
||||
const state = useAppStore.getState()
|
||||
const entry = state.agentStatusByPaneKey[cacheKey]
|
||||
const now = Date.now()
|
||||
const entryIsFresh =
|
||||
entry &&
|
||||
typeof entry.updatedAt === 'number' &&
|
||||
now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS
|
||||
if (entryIsFresh && entry.state !== 'done') {
|
||||
return true
|
||||
}
|
||||
const liveTitle = getLivePaneAgentTitle()
|
||||
return detectAgentStatusFromTitle(liveTitle ?? '') !== null
|
||||
}
|
||||
const observeAcceptedShellCommandInput = (data: string): void => {
|
||||
if (commandInferredPaneAgent) {
|
||||
return
|
||||
}
|
||||
// Why: bytes typed inside a live agent TUI are prompt text, not shell
|
||||
// commands, even if they spell another agent binary name.
|
||||
if (hasFreshPaneAgentSurface()) {
|
||||
resetPendingShellCommandLine()
|
||||
return
|
||||
}
|
||||
if (shellCommandInferenceSuspendedUntilCommandEnd) {
|
||||
if (data.includes('\x03') || data.includes('\x15')) {
|
||||
shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
resetPendingShellCommandLine()
|
||||
}
|
||||
if (data.includes('\r') || data.includes('\n')) {
|
||||
shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) {
|
||||
resetPendingShellCommandLine()
|
||||
shellCommandInferenceSuspendedUntilCommandEnd = !data.includes('\r') && !data.includes('\n')
|
||||
return
|
||||
}
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const char = data[index]!
|
||||
if (char === '\r' || char === '\n') {
|
||||
shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
rememberCommandInferredPaneAgent()
|
||||
if (commandInferredPaneAgent) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === '\x7f' || char === '\b') {
|
||||
deletePendingShellCommandCharacter()
|
||||
continue
|
||||
}
|
||||
if (char === '\x17') {
|
||||
deletePendingShellCommandWord()
|
||||
continue
|
||||
}
|
||||
if (char === '\x03' || char === '\x15') {
|
||||
resetPendingShellCommandLine()
|
||||
continue
|
||||
}
|
||||
if (char === '\x1b') {
|
||||
const nextIndex = consumeShellCommandCsiSequence(data, index)
|
||||
if (nextIndex !== null) {
|
||||
index = nextIndex - 1
|
||||
continue
|
||||
}
|
||||
resetPendingShellCommandLine()
|
||||
continue
|
||||
}
|
||||
if (char < ' ') {
|
||||
resetPendingShellCommandLine()
|
||||
continue
|
||||
}
|
||||
if (char >= ' ') {
|
||||
appendPendingShellCommandInput(char)
|
||||
if (shellCommandInferenceSuspendedUntilCommandEnd) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const getAuthoritativePaneAgent = (): AgentType | undefined => {
|
||||
const state = useAppStore.getState()
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
|
|
@ -1253,6 +1468,7 @@ export function connectPanePty(
|
|||
tab?.launchAgent ??
|
||||
paneStartup?.launchAgent ??
|
||||
paneStartup?.initialAgentStatus?.agent ??
|
||||
commandInferredPaneAgent ??
|
||||
state.agentStatusByPaneKey[cacheKey]?.agentType
|
||||
)
|
||||
}
|
||||
|
|
@ -1522,6 +1738,7 @@ export function connectPanePty(
|
|||
}
|
||||
const commandLifecycle = createTerminalCommandLifecycle({
|
||||
onCommandFinished: () => {
|
||||
clearCommandInferredPaneAgentAfterPtySideEffects()
|
||||
// Why: the finished command may have moved HEAD or the index (e.g.
|
||||
// `git checkout`); nudge git UI now instead of waiting for a poll.
|
||||
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
|
||||
|
|
@ -2192,6 +2409,7 @@ export function connectPanePty(
|
|||
}
|
||||
}
|
||||
const onAgentExited = (): void => {
|
||||
clearCommandInferredPaneAgent()
|
||||
// Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"),
|
||||
// the agent has exited. Clear any running cache timer so the sidebar doesn't
|
||||
// show a stale countdown for a tab that no longer has an active Claude session.
|
||||
|
|
@ -2461,6 +2679,11 @@ export function connectPanePty(
|
|||
// excluded because those transports do not expose sendInputAccepted.
|
||||
const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data)
|
||||
if (acknowledgedIntent && transport.sendInputAccepted) {
|
||||
if (acknowledgedIntent === 'ctrl-c') {
|
||||
// Why: the accepted-write callback is async; let the next command be
|
||||
// inferred if the user cancelled an oversized line and immediately typed.
|
||||
cancelSuspendedShellCommandInference()
|
||||
}
|
||||
clearPendingTerminalInputIntent()
|
||||
markTerminalInputSent()
|
||||
const writePromise = transport
|
||||
|
|
@ -2468,6 +2691,7 @@ export function connectPanePty(
|
|||
.then((accepted) => {
|
||||
if (accepted) {
|
||||
recordAcceptedTerminalInputForHibernation()
|
||||
observeAcceptedShellCommandInput(data)
|
||||
observeAcceptedTerminalInput(data, acknowledgedIntent)
|
||||
interruptInference.observeInputIntent(acknowledgedIntent)
|
||||
observeTitleOnlyInterrupt()
|
||||
|
|
@ -2482,6 +2706,7 @@ export function connectPanePty(
|
|||
if (intent) {
|
||||
if (transport.sendInput(data)) {
|
||||
markAcceptedTerminalInputSent()
|
||||
observeAcceptedShellCommandInput(data)
|
||||
observeAcceptedTerminalInput(data, intent)
|
||||
}
|
||||
clearPendingTerminalInputIntent()
|
||||
|
|
@ -2489,6 +2714,7 @@ export function connectPanePty(
|
|||
}
|
||||
if (transport.sendInput(data)) {
|
||||
markAcceptedTerminalInputSent()
|
||||
observeAcceptedShellCommandInput(data)
|
||||
observeAcceptedTerminalInput(data)
|
||||
observeSentTerminalInputIntent(data)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
MAX_OSC_TITLE_CHARS
|
||||
} from './agent-detection'
|
||||
import {
|
||||
hasCompatibleAgentTitleIdentity,
|
||||
normalizeCompatibleAgentStatusEntryForOwner,
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
|
|
@ -114,6 +115,14 @@ describe('Pi-compatible title detection', () => {
|
|||
expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi')
|
||||
})
|
||||
|
||||
it('identifies titles whose compatible identity can be re-owned', () => {
|
||||
expect(hasCompatibleAgentTitleIdentity('Pi ready')).toBe(true)
|
||||
expect(hasCompatibleAgentTitleIdentity('π - tmp')).toBe(true)
|
||||
expect(hasCompatibleAgentTitleIdentity('\u280b OMP')).toBe(true)
|
||||
expect(hasCompatibleAgentTitleIdentity('Fix pi bugs')).toBe(false)
|
||||
expect(hasCompatibleAgentTitleIdentity('\u280b Codex')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes Pi-compatible status identity and terminal title to the owner', () => {
|
||||
const status = normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ function getProfileForTitle(title: string): TitleProfileMatch | null {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: unknown-launch remote sessions need to wait for foreground ownership
|
||||
* before publishing title frames whose identity can be re-owned.
|
||||
*/
|
||||
export function hasCompatibleAgentTitleIdentity(title: string): boolean {
|
||||
return Boolean(getProfileForTitle(title)?.profile.titleIdentityGroup)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the agent status (working, permission, idle) from a terminal title,
|
||||
* accounting for legacy Pi titles.
|
||||
|
|
|
|||
Loading…
Reference in New Issue