diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 4006f6f0..54908aaa 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -45,6 +45,7 @@ extension WorktreeTerminalState { agentDetectionPresenceBySurface.removeValue(forKey: surfaceID) lastWorkingAtBySurface.removeValue(forKey: surfaceID) lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceID) + lastAgentScreenScanBySurface.removeValue(forKey: surfaceID) if surfaceAgentStates[surfaceID]?.detectedAgent == nil { surfaceAgentStates.removeValue(forKey: surfaceID) } @@ -89,13 +90,13 @@ extension WorktreeTerminalState { let now = Date() let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) let activeText = view.bridge.readActiveText() ?? "" - // `detectState` is a `nonisolated` pure function that runs in well under a - // millisecond on a terminal-sized active screen, so the prior `Task.detached` hop - // bought nothing but allocator churn. In long sessions, each detection - // tick (300 ms or 2 s per surface) was leaving a task stack + closure - // capture behind that never reached ARC; over a 24 h session this added - // up to hundreds of MB of unreferenced allocations. - let raw = agent.detectState(in: activeText) + // Reuse the previous scan while the screen and detected agent are unchanged. + // A live-but-idle agent is polled every 300 ms and `detectState` re-splits, + // lowercases, and scans the whole screen each time; skipping that for + // identical text is the bulk of steady-state detection cost. Time-based + // stabilization below still runs every tick, so working→idle decay is + // unaffected. + let raw = cachedRawState(forSurfaceID: surfaceID, agent: agent, text: activeText) guard surfaces[surfaceID] != nil else { return false } var lastWorkingAt = lastWorkingAtBySurface[surfaceID] @@ -174,6 +175,36 @@ extension WorktreeTerminalState { return true } + /// Resolves the raw agent state for `text`, reusing `cache` when it already + /// holds a scan for the same `agent` and identical `text`. Returns the raw + /// state and the scan to store back for the next call. + /// + /// `detectState` is a `nonisolated` pure function of the screen, so reusing + /// its result for identical input is exactly equivalent to recomputing it. + /// It runs inline (no `Task.detached`): the detached hop bought only allocator + /// churn — over a long session each tick left a task stack + closure capture + /// that never reached ARC, adding up to hundreds of MB of unreferenced + /// allocations. + nonisolated static func resolveRawState( + agent: DetectedAgent, + text: String, + cache: AgentScreenScan? + ) -> (raw: AgentRawState, scan: AgentScreenScan) { + if let cache, cache.agent == agent, cache.text == text { + return (cache.raw, cache) + } + let raw = agent.detectState(in: text) + return (raw, AgentScreenScan(agent: agent, text: text, raw: raw)) + } + + /// Instance wrapper over `resolveRawState` that reads and writes the per-surface + /// memo, keeping `detectAgentState` to a single line at the call site. + private func cachedRawState(forSurfaceID surfaceID: UUID, agent: DetectedAgent, text: String) -> AgentRawState { + let (raw, scan) = Self.resolveRawState(agent: agent, text: text, cache: lastAgentScreenScanBySurface[surfaceID]) + lastAgentScreenScanBySurface[surfaceID] = scan + return raw + } + private func resolvedLaunchObservation( identified: IdentifiedAgentProcess?, previous: PaneAgentState @@ -341,6 +372,7 @@ extension WorktreeTerminalState { agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) lastWorkingAtBySurface.removeValue(forKey: surfaceId) lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceId) + lastAgentScreenScanBySurface.removeValue(forKey: surfaceId) lastEmittedAgentEntriesBySurface.removeValue(forKey: surfaceId) onAgentEntryRemoved?(surfaceId) } @@ -356,6 +388,7 @@ extension WorktreeTerminalState { agentDetectionPresenceBySurface.removeAll() lastWorkingAtBySurface.removeAll() lastAgentDetectionDiagnosticsBySurface.removeAll() + lastAgentScreenScanBySurface.removeAll() lastEmittedAgentEntriesBySurface.removeAll() for id in removedIDs { onAgentEntryRemoved?(id) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index ea133b34..7a44ad2f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -61,6 +61,15 @@ final class WorktreeTerminalState { let isFocused: Bool } + /// One memoized agent-screen scan: the `raw` state `detectState` produced for + /// `text` under `agent`. Cached per surface so an unchanged screen is not + /// re-parsed on the next poll. + struct AgentScreenScan: Equatable { + let agent: DetectedAgent + let text: String + let raw: AgentRawState + } + let tabManager: TerminalTabManager let runtime: GhosttyRuntime let worktree: Worktree @@ -102,6 +111,14 @@ final class WorktreeTerminalState { var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] var lastWorkingAtBySurface: [UUID: Date] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] + /// Memoizes the last agent-screen scan per surface so `detectAgentState` can + /// reuse it while the terminal text and detected agent are unchanged. A + /// live-but-idle agent is polled every 300 ms; without this each poll re-ran + /// `DetectedAgent.detectState` — line splitting, lowercasing, and heuristic + /// scans over identical text. Observation-ignored: a pure cache that never + /// drives the UI. + @ObservationIgnored + var lastAgentScreenScanBySurface: [UUID: AgentScreenScan] = [:] /// Last `ActiveAgentEntry` emitted per surface. `detectAgentState` re-emits /// whenever any `PaneAgentState` field changes, including internal /// bookkeeping (raw-state oscillation, session miss streaks, presence diff --git a/supacodeTests/AgentScreenScanCacheTests.swift b/supacodeTests/AgentScreenScanCacheTests.swift new file mode 100644 index 00000000..08587c05 --- /dev/null +++ b/supacodeTests/AgentScreenScanCacheTests.swift @@ -0,0 +1,53 @@ +import Testing + +@testable import supacode + +struct AgentScreenScanCacheTests { + /// With no cache, the helper scans from scratch and returns a scan that + /// round-trips the inputs and the freshly computed raw state. + @Test func scansFromScratchWithoutCache() { + let (raw, scan) = WorktreeTerminalState.resolveRawState( + agent: .claude, + text: "screen", + cache: nil + ) + + #expect(raw == DetectedAgent.claude.detectState(in: "screen")) + #expect(scan == WorktreeTerminalState.AgentScreenScan(agent: .claude, text: "screen", raw: raw)) + } + + /// When the cached agent and text both match, the helper returns the cached + /// raw state without recomputing. Proven by seeding a sentinel raw a fresh + /// scan would never produce and asserting it comes back unchanged. + @Test func reusesCachedRawWhenAgentAndTextMatch() { + let text = "" + let sentinel = WorktreeTerminalState.AgentScreenScan(agent: .claude, text: text, raw: .blocked) + #expect(DetectedAgent.claude.detectState(in: text) != .blocked) + + let (raw, scan) = WorktreeTerminalState.resolveRawState(agent: .claude, text: text, cache: sentinel) + + #expect(raw == .blocked) + #expect(scan == sentinel) + } + + /// A changed screen invalidates the cache and forces a rescan. + @Test func rescansWhenTextChanges() { + let cache = WorktreeTerminalState.AgentScreenScan(agent: .claude, text: "old", raw: .blocked) + + let (raw, scan) = WorktreeTerminalState.resolveRawState(agent: .claude, text: "new", cache: cache) + + #expect(raw == DetectedAgent.claude.detectState(in: "new")) + #expect(scan.text == "new") + } + + /// A different detected agent invalidates the cache even when the text is + /// identical, since raw state is agent-specific. + @Test func rescansWhenAgentChanges() { + let cache = WorktreeTerminalState.AgentScreenScan(agent: .codex, text: "screen", raw: .blocked) + + let (raw, scan) = WorktreeTerminalState.resolveRawState(agent: .claude, text: "screen", cache: cache) + + #expect(raw == DetectedAgent.claude.detectState(in: "screen")) + #expect(scan.agent == .claude) + } +}