diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 27c4d3415..5d78a73d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -93,6 +93,26 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onTitleChange = vi.fn() + const onBell = vi.fn() + const processor = createPtyOutputProcessor({ onTitleChange, onBell }) + const callbacks = { onData: vi.fn() } + + processor.processData('plain command output\r\n'.repeat(50), callbacks) + + expect(callbacks.onData).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + expect(onTitleChange).not.toHaveBeenCalled() + expect(onBell).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('preserves stale-title detection after compacting deferred side effects', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index d8557f62b..ed840e968 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -101,6 +101,7 @@ export function createPtyOutputProcessor({ let sideEffectDrainTimer: ReturnType | null = null let pendingSideEffects: PendingPtySideEffect[] = [] let pendingSideEffectIndex = 0 + let pendingWorkingTitleSideEffects = 0 const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( @@ -112,6 +113,20 @@ export function createPtyOutputProcessor({ ) : null + function isWorkingTitle(title: string | null): boolean { + return title !== null && detectAgentStatusFromTitle(title) === 'working' + } + + function countWorkingTitles(titles: string[]): number { + let count = 0 + for (const title of titles) { + if (isWorkingTitle(normalizeTerminalTitle(title))) { + count += 1 + } + } + return count + } + function applyObservedTerminalTitle(title: string, suppressAgentTracker = false): void { // Why: cursor-agent's native OSC title is the literal string "Cursor Agent" // and it re-emits that title many times per turn (on every internal redraw) @@ -152,6 +167,7 @@ export function createPtyOutputProcessor({ } function enqueuePtySideEffect(next: PendingPtySideEffect): void { + const workingTitleCount = countWorkingTitles(next.titles) const prior = pendingSideEffects.at(-1) if ( prior && @@ -164,9 +180,11 @@ export function createPtyOutputProcessor({ !next.containsBell ) { prior.scannedForTitles ||= next.scannedForTitles + pendingWorkingTitleSideEffects += workingTitleCount return } pendingSideEffects.push(next) + pendingWorkingTitleSideEffects += workingTitleCount } function schedulePtySideEffects( @@ -174,14 +192,22 @@ export function createPtyOutputProcessor({ payloads: ReturnType['payloads'], suppressAttentionEvents: boolean ): void { - const scannedForTitles = Boolean(onTitleChange && data.length > 0) + const scannedForTitles = Boolean(onTitleChange && data.includes('\x1b]')) const titles = scannedForTitles ? extractAllOscTitles(data) : [] const deliveredPayloads = onAgentStatus && !suppressAttentionEvents && payloads.length > 0 ? payloads : [] const containsBell = Boolean( onBell && !suppressAttentionEvents && bellDetector.chunkContainsBell(data) ) - if (!scannedForTitles && deliveredPayloads.length === 0 && !containsBell) { + const needsStaleTitleProbe = Boolean( + onTitleChange && + data.length > 0 && + titles.length === 0 && + !suppressAttentionEvents && + (isWorkingTitle(lastEmittedTitle) || pendingWorkingTitleSideEffects > 0) + ) + const shouldEmitEmptyTitleScan = scannedForTitles || needsStaleTitleProbe + if (!shouldEmitEmptyTitleScan && deliveredPayloads.length === 0 && !containsBell) { return } @@ -192,7 +218,7 @@ export function createPtyOutputProcessor({ enqueuePtySideEffect({ payloads: [], titles: [], - scannedForTitles, + scannedForTitles: shouldEmitEmptyTitleScan, containsBell, suppressAttentionEvents }) @@ -206,11 +232,11 @@ export function createPtyOutputProcessor({ suppressAttentionEvents }) } - if (titles.length === 0 && scannedForTitles) { + if (titles.length === 0 && shouldEmitEmptyTitleScan) { enqueuePtySideEffect({ payloads: [], titles: [], - scannedForTitles: true, + scannedForTitles: shouldEmitEmptyTitleScan, containsBell: false, suppressAttentionEvents }) @@ -260,6 +286,10 @@ export function createPtyOutputProcessor({ } function applyPtySideEffect(next: PendingPtySideEffect): void { + pendingWorkingTitleSideEffects -= countWorkingTitles(next.titles) + if (pendingWorkingTitleSideEffects < 0) { + pendingWorkingTitleSideEffects = 0 + } if (onAgentStatus) { for (const payload of next.payloads) { onAgentStatus(payload) @@ -367,6 +397,7 @@ export function createPtyOutputProcessor({ clearSideEffectDrainTimer() pendingSideEffects.length = 0 pendingSideEffectIndex = 0 + pendingWorkingTitleSideEffects = 0 clearStaleTitleTimer() agentTracker?.reset() bellDetector.reset()