diff --git a/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md b/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md new file mode 100644 index 00000000..3fb728cd --- /dev/null +++ b/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md @@ -0,0 +1,170 @@ +# Prowl CLI Agents Command Plan + +## Context + +Issue: + +The request is to expose the same Active Agents roster that Prowl already shows +in the sidebar through the `prowl` CLI. The CLI should be read-only for this +feature: switching/focusing is already covered by existing commands such as +`prowl focus --pane `, `prowl read --pane `, and `prowl send --pane `. + +Before implementing the command, agent detection scheduling should be made +reliable and efficient independently of the Active Agents panel visibility. The +CLI command should not depend on whether the panel is expanded, whether Shelf +status markers are visible, or any other UI-only preference. + +## Proposed Command + +Add: + +```bash +prowl agents +prowl agents --json +``` + +Do not add a first-class "switch agent" subcommand. Users and automation can +resolve `pane.id` from `prowl agents --json`, then call existing pane-oriented +commands. + +## Output Semantics + +`prowl agents` should expose detected agents, not the worktree-level task status +from `prowl list`. + +Important distinction: + +- `prowl list` currently reports `task.status` at worktree level as + `running | idle | null`. +- `prowl agents` should report per-pane agent detection state as + `working | blocked | done | idle`, plus the raw detector state. + +The command should return only panes where an agent is currently detected or has +a retained Active Agents entry. Empty shells and ordinary non-agent commands +should not appear. + +## JSON Schema Sketch + +Schema version: `prowl.cli.agents.v1` + +```json +{ + "count": 2, + "agents": [ + { + "id": "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", + "type": "codex", + "name": "codex", + "status": "blocked", + "raw_state": "blocked", + "last_changed_at": "2026-06-13T04:12:25Z", + "project": { + "name": "Prowl", + "branch": "feature/cli-agents", + "path": "/Users/onevcat/Sync/github/Prowl" + }, + "worktree": { + "id": "Prowl:/Users/onevcat/Sync/github/Prowl", + "name": "feature/cli-agents", + "path": "/Users/onevcat/Sync/github/Prowl", + "root_path": "/Users/onevcat/Sync/github/Prowl", + "kind": "git" + }, + "tab": { + "id": "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", + "title": "issue 330", + "selected": true + }, + "pane": { + "id": "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", + "index": 1, + "title": "codex", + "cwd": "/Users/onevcat/Sync/github/Prowl", + "focused": false + } + } + ] +} +``` + +Notes: + +- `id` should equal `pane.id` / `surfaceID`, matching Active Agents entries. +- `type` should be the normalized `DetectedAgent.rawValue`. +- `name` should be `ActiveAgentEntry.displayName`, preserving command aliases + such as `omp`. +- `status` should be `ActiveAgentEntry.displayState.rawValue`. +- `raw_state` should be `ActiveAgentEntry.rawState.rawValue`. +- `last_changed_at` should use ISO-8601. + +## Project vs Owning Worktree + +An agent may run in a different directory than the worktree that owns its +terminal pane. The CLI should expose both: + +- `project`: display-oriented repository/branch resolved from + `ActiveAgentEntry.workingDirectory`, using the same rules as the Active Agents + panel (`SidebarListView.activeAgentRowDisplay`). +- `worktree`: the actual terminal owner, used for focus/read/send targeting. + +This prevents automation from losing the concrete pane while still showing the +human-facing project label users expect. + +## Text Rendering + +Default text output should optimize for scanability: + +```text +Blocked codex Prowl:feature/cli-agents issue 330 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 +Working claude Notes:main review EF65FF31-1B72-40B2-80DA-3AA87B7B6858 +``` + +Suggested ordering: + +1. `blocked` +2. `working` +3. `done` +4. `idle` + +Within each status group, preserve Active Agents insertion order unless a later +UX pass finds a better sort. + +## Implementation Plan + +1. Add shared CLI input/payload models: + - `AgentsInput` + - `AgentsCommandPayload` + - `AgentsCommandAgent` + - nested `project`, `worktree`, `tab`, and `pane` payload structs +2. Add `Command.agents(AgentsInput)` and route it through `CLICommandRouter`. +3. Add `AgentsCommandHandler`. + - Snapshot source: `appStore.state.repositories.activeAgents.entries` + - Repository metadata: reuse `SidebarListView.activeAgentWorktreeMetadata` + and `SidebarListView.activeAgentRowDisplay`. + - Terminal metadata: reuse existing target/list snapshot builders where + possible to resolve tab selected state, pane title, cwd, and focus. +4. Add `ProwlCLI/Commands/AgentsCommand.swift` and register it in + `ProwlCommand`. +5. Add text rendering in `OutputRenderer.renderAgents`. +6. Update `docs/components/cli.md` and `docs/components/active-agents.md`. + +## Test Plan + +- Command envelope round-trip for `agents`. +- Router dispatch test. +- Handler payload test covering: + - status/raw state passthrough + - alias display name (`omp` vs `pi`) + - project label from `workingDirectory` + - owning worktree/pane still present + - focused pane marking +- CLI integration test for JSON output. +- CLI text rendering test for status ordering and empty state. + +## Open Questions + +- Whether `idle` agents should be included by default or hidden behind a flag. + Initial recommendation: include them because the Active Agents panel includes + retained idle/done entries, and automation can filter by status. +- Whether to add filtering flags such as `--status blocked` later. Initial + recommendation: skip flags for v1; JSON + `jq` is enough. diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index a4977dd4..26fff9b5 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -58,8 +58,10 @@ A **Done** pane becomes **Idle** the moment you focus it. ## How often it runs -- ~**300 ms** when an agent is present or you're typing (active). -- ~**2 s** when idle. +- **No polling** for cold panes that have not received recent input. +- ~**2 s** for a short warm window after typing, paste, CLI input, or an initial + command starts in the pane. +- ~**300 ms** once an agent is detected, so Working/Blocked/Done stays responsive. The heavier process probe is throttled (cached ≈ 0.75 s per process group unless something changes) so many panes don't add up to high CPU. Status indicators redraw on a diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index cd2136ff..0894218f 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -50,7 +50,6 @@ struct TerminalClient { case prune(Set) case setNotificationsEnabled(Bool) case setCommandFinishedNotification(enabled: Bool, threshold: Int) - case setAgentDetectionEnabled(Bool) case setCanvasMode(Bool) case setSelectedWorktreeID(Worktree.ID?) case saveLayoutSnapshot diff --git a/supacode/Domain/AgentDetection/AgentDetectionSchedule.swift b/supacode/Domain/AgentDetection/AgentDetectionSchedule.swift new file mode 100644 index 00000000..c412c07b --- /dev/null +++ b/supacode/Domain/AgentDetection/AgentDetectionSchedule.swift @@ -0,0 +1,44 @@ +import Foundation + +enum AgentDetectionSchedule: Equatable, Sendable { + static let warmWindow: TimeInterval = 30 + + case cold + case warm(until: Date) + case active + + func warmed(now: Date) -> Self { + switch self { + case .active: + return .active + case .cold, .warm: + return .warm(until: now.addingTimeInterval(Self.warmWindow)) + } + } + + func observedAgent(now _: Date) -> Self { + .active + } + + func observedNoAgent(now: Date) -> Self { + switch self { + case .active: + return .warm(until: now.addingTimeInterval(Self.warmWindow)) + case .warm(let until) where until > now: + return .warm(until: until) + case .cold, .warm: + return .cold + } + } + + func nextInterval(now: Date) -> Duration? { + switch self { + case .cold: + return nil + case .warm(let until): + return until > now ? idleAgentDetectionInterval : nil + case .active: + return activeAgentDetectionInterval + } + } +} diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index 21cf8b55..3e5c802f 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -128,11 +128,4 @@ struct ActiveAgentsFeature { max(minimumPanelHeight, min(maximumPanelHeight, height - reservedSidebarListHeight)) } - static func detectionEnabled( - isPanelHidden: Bool, - autoShowPanel: Bool, - isShelfVisible: Bool - ) -> Bool { - isShelfVisible || !isPanelHidden || autoShowPanel - } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 756b8b0c..d19b4c2f 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -81,7 +81,6 @@ struct AppFeature { case endSearch case systemNotificationsPermissionFailed(errorMessage: String?) case systemNotificationTapped(worktreeID: Worktree.ID, surfaceID: UUID) - case syncAgentDetectionEnabled case alert(PresentationAction) case terminalEvent(TerminalClient.Event) } @@ -104,35 +103,6 @@ struct AppFeature { @Dependency(WorktreeInfoWatcherClient.self) var worktreeInfoWatcher @Dependency(CustomShortcutRegistryClient.self) var customShortcutRegistryClient - func agentDetectionEnabled( - state: State, - autoShowPanel: Bool? = nil, - showShelfStatus: Bool? = nil - ) -> Bool { - let shelfStatusVisible = - state.repositories.isShowingShelf && (showShelfStatus ?? state.settings.showActiveAgentStatusInShelf) - return ActiveAgentsFeature.detectionEnabled( - isPanelHidden: state.repositories.activeAgents.isPanelHidden, - autoShowPanel: autoShowPanel ?? state.settings.autoShowActiveAgentsPanel, - isShelfVisible: shelfStatusVisible - ) - } - - static func repositoriesActionMayChangeShelfVisibility(_ action: RepositoriesFeature.Action) -> Bool { - switch action { - case .selectArchivedWorktrees, .selectCanvas, .selectShelf, .selectTabbed, .setTopSegment, .toggleCanvas, - .toggleShelf: - return true - // `isShowingShelf` also requires a non-empty repository list, so actions - // that add or remove repositories can flip it without touching `isShelfActive`. - case .repositoriesLoaded, .repositoryManagement(.repositoryRemoved), - .repositoryManagement(.openRepositoriesFinished): - return true - default: - return false - } - } - var body: some Reducer { let core = Reduce { state, action in switch action { @@ -141,15 +111,11 @@ struct AppFeature { appLogger.info("[LayoutRestore] appLaunched: launchRestoreMode=\(String(describing: state.launchRestoreMode))") state.launchedAt = now state.repositories.launchRestoreMode = state.launchRestoreMode - let agentDetectionEnabled = agentDetectionEnabled(state: state) analyticsClient.capture("app_launched", nil) return .merge( .send(.repositories(.task)), .send(.settings(.task)), .send(.updates(.task)), - .run { _ in - await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) - }, .run { _ in await dockClient.setNotificationBadge(0) }, @@ -414,11 +380,6 @@ struct AppFeature { state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled state.settings.keybindingUserOverrides = settings.keybindingUserOverrides state.repositories.showActiveAgentTabTitles = settings.showActiveAgentTabTitles - let agentDetectionEnabled = agentDetectionEnabled( - state: state, - autoShowPanel: settings.autoShowActiveAgentsPanel, - showShelfStatus: settings.showActiveAgentStatusInShelf - ) if let selectedWorktree = state.repositories.selectedTerminalWorktree { let rootURL = selectedWorktree.repositoryRootURL @Shared(.repositorySettings(rootURL)) var repositorySettings @@ -481,9 +442,6 @@ struct AppFeature { ) ) }, - .run { _ in - await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) - }, .run { _ in await worktreeInfoWatcher.send( .setPullRequestTrackingEnabled(settings.githubIntegrationEnabled) @@ -940,12 +898,6 @@ struct AppFeature { } ) - case .syncAgentDetectionEnabled: - let agentDetectionEnabled = agentDetectionEnabled(state: state) - return .run { _ in - await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) - } - case .alert(.dismiss): state.alert = nil return .none @@ -960,20 +912,6 @@ struct AppFeature { case .alert: return .none - case .repositories(.activeAgents(.togglePanelVisibility)): - let nextIsPanelHidden = !state.repositories.activeAgents.isPanelHidden - let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( - isPanelHidden: nextIsPanelHidden, - autoShowPanel: state.settings.autoShowActiveAgentsPanel, - isShelfVisible: state.repositories.isShowingShelf && state.settings.showActiveAgentStatusInShelf - ) - return .run { _ in - await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) - } - - case .repositories(let action) where Self.repositoriesActionMayChangeShelfVisibility(action): - return .send(.syncAgentDetectionEnabled) - case .repositories: return .none diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 264f76ac..4dd83d17 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -15,7 +15,6 @@ final class WorktreeTerminalManager { private var notificationsEnabled = true private var commandFinishedNotificationEnabled = true private var commandFinishedNotificationThreshold = 10 - private var agentDetectionEnabled = true private var preferredFontSize: Float32? private let baselineFontSize: Float32 private var lastNotificationIndicatorCount: Int? @@ -156,8 +155,6 @@ final class WorktreeTerminalManager { setNotificationsEnabled(enabled) case .setCommandFinishedNotification(let enabled, let threshold): setCommandFinishedNotification(enabled: enabled, threshold: threshold) - case .setAgentDetectionEnabled(let enabled): - setAgentDetectionEnabled(enabled) case .setCanvasMode(let enabled): if enabled { terminalLogger.info("[CanvasExit] enteringCanvas previousSelectedWorktree=\(selectedWorktreeID ?? "nil")") @@ -242,7 +239,6 @@ final class WorktreeTerminalManager { enabled: commandFinishedNotificationEnabled, threshold: commandFinishedNotificationThreshold ) - state.setAgentDetectionEnabled(agentDetectionEnabled) state.isSelected = { [weak self] in self?.selectedWorktreeID == worktree.id } @@ -458,14 +454,6 @@ final class WorktreeTerminalManager { } } - func setAgentDetectionEnabled(_ enabled: Bool) { - guard agentDetectionEnabled != enabled else { return } - agentDetectionEnabled = enabled - for state in states.values { - state.setAgentDetectionEnabled(enabled) - } - } - func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool { states[worktreeID]?.hasUnseenNotification == true } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index b79d8658..b8ee672d 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -2,40 +2,60 @@ import Foundation import GhosttyKit extension WorktreeTerminalState { - func setAgentDetectionEnabled(_ enabled: Bool) { - guard agentDetectionEnabled != enabled else { return } - agentDetectionEnabled = enabled + func wakeAgentDetection(forSurfaceID surfaceID: UUID) { + guard let view = surfaces[surfaceID], + let tabId = tabId(containing: surfaceID) + else { + return + } + wakeAgentDetection(for: view, tabId: tabId) + } - if enabled { - for (surfaceID, view) in surfaces { - guard let tabId = tabId(containing: surfaceID) else { continue } - startAgentDetection(for: view, tabId: tabId) - } - } else { - cleanupAllAgentDetectionState() + func wakeAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID, now: Date = Date()) { + agentDetectionSchedules[view.id] = (agentDetectionSchedules[view.id] ?? .cold).warmed(now: now) + if surfaceAgentStates[view.id] == nil { + surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: now) } + startAgentDetectionTaskIfNeeded(for: view, tabId: tabId) } - func startAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID) { - guard agentDetectionEnabled else { return } - agentDetectionTasks[view.id]?.cancel() - surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: Date()) + func startAgentDetectionTaskIfNeeded(for view: GhosttySurfaceView, tabId: TerminalTabID) { + guard agentDetectionTasks[view.id] == nil else { return } agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in while !Task.isCancelled { guard let self, let view, self.surfaces[view.id] != nil else { return } - await self.detectAgentState(for: view, tabId: tabId) - let hasAgent = self.surfaceAgentStates[view.id]?.detectedAgent != nil - try? await Task.sleep(for: hasAgent ? activeAgentDetectionInterval : idleAgentDetectionInterval) + let hasAgent = await self.detectAgentState(for: view, tabId: tabId) + let now = Date() + let schedule = self.agentDetectionSchedules[view.id] ?? .cold + self.agentDetectionSchedules[view.id] = + hasAgent ? schedule.observedAgent(now: now) : schedule.observedNoAgent(now: now) + + guard let interval = self.agentDetectionSchedules[view.id]?.nextInterval(now: now) else { + self.finishColdAgentDetection(forSurfaceID: view.id) + return + } + try? await Task.sleep(for: interval) } } } - func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) async { + func finishColdAgentDetection(forSurfaceID surfaceID: UUID) { + agentDetectionTasks.removeValue(forKey: surfaceID) + agentDetectionSchedules.removeValue(forKey: surfaceID) + agentDetectionPresenceBySurface.removeValue(forKey: surfaceID) + lastWorkingAtBySurface.removeValue(forKey: surfaceID) + lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceID) + if surfaceAgentStates[surfaceID]?.detectedAgent == nil { + surfaceAgentStates.removeValue(forKey: surfaceID) + } + } + + func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) async -> Bool { let surfaceID = view.id let childPID = view.bridge.childPID() let processGroupID = view.bridge.foregroundProcessGroupID() let job = await AgentProcessProbe.shared.foregroundJob(processGroupID: processGroupID, childPID: childPID) - guard surfaces[surfaceID] != nil else { return } + guard surfaces[surfaceID] != nil else { return false } let identified = job.flatMap { identifyAgentInJob($0) } let probedAgent = identified?.agent @@ -63,7 +83,7 @@ extension WorktreeTerminalState { ) } removeAgentEntryIfNeeded(surfaceID: surfaceID) - return + return false } let now = Date() @@ -76,7 +96,7 @@ extension WorktreeTerminalState { // 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) - guard surfaces[surfaceID] != nil else { return } + guard surfaces[surfaceID] != nil else { return false } var lastWorkingAt = lastWorkingAtBySurface[surfaceID] let stabilized = stabilizeAgentState( @@ -128,9 +148,10 @@ extension WorktreeTerminalState { ) ) } - guard next != previous else { return } + guard next != previous else { return true } surfaceAgentStates[surfaceID] = next emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) + return true } func markAgentSeen(surfaceID: UUID) { @@ -204,6 +225,7 @@ extension WorktreeTerminalState { func cleanupAgentDetectionState(forSurfaceId surfaceId: UUID) { agentDetectionTasks[surfaceId]?.cancel() agentDetectionTasks.removeValue(forKey: surfaceId) + agentDetectionSchedules.removeValue(forKey: surfaceId) surfaceAgentStates.removeValue(forKey: surfaceId) agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) lastWorkingAtBySurface.removeValue(forKey: surfaceId) @@ -217,6 +239,7 @@ extension WorktreeTerminalState { } let removedIDs = Array(surfaceAgentStates.keys) agentDetectionTasks.removeAll() + agentDetectionSchedules.removeAll() surfaceAgentStates.removeAll() agentDetectionPresenceBySurface.removeAll() lastWorkingAtBySurface.removeAll() diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 9107c58f..e0ea530b 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -357,7 +357,9 @@ extension WorktreeTerminalState { configureBridgeCallbacks(for: view, tabId: tabId) configureSurfaceCallbacks(for: view, tabId: tabId) surfaces[view.id] = view - startAgentDetection(for: view, tabId: tabId) + if initialInput?.isEmpty == false { + wakeAgentDetection(for: view, tabId: tabId) + } return view } @@ -423,6 +425,7 @@ extension WorktreeTerminalState { view.onKeyInput = { [weak self, weak view] in guard let self, let view else { return } self.recordKeyInput(forSurfaceID: view.id) + self.wakeAgentDetection(for: view, tabId: tabId) self.markNotificationsRead(forSurfaceID: view.id) } view.onFontSizeShortcut = { [weak self] in diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index c02f67ec..8faf1861 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -70,11 +70,11 @@ final class WorktreeTerminalState { var surfaces: [UUID: GhosttySurfaceView] = [:] var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] var surfaceAgentStates: [UUID: PaneAgentState] = [:] + var agentDetectionSchedules: [UUID: AgentDetectionSchedule] = [:] var agentDetectionTasks: [UUID: Task] = [:] var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] var lastWorkingAtBySurface: [UUID: Date] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] - var agentDetectionEnabled = true var tabIsRunningById: [TerminalTabID: Bool] = [:] var surfaceRunningStartedAtById: [UUID: Date] = [:] var runScriptTabId: TerminalTabID? @@ -213,6 +213,7 @@ final class WorktreeTerminalState { func insertCommittedText(_ text: String, in tabId: TerminalTabID) -> Bool { guard let surface = surfaceView(for: tabId) else { return false } surface.insertCommittedTextForBroadcast(text) + wakeAgentDetection(forSurfaceID: surface.id) return true } @@ -220,6 +221,7 @@ final class WorktreeTerminalState { func insertCommittedText(_ text: String, in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } surface.insertCommittedTextForBroadcast(text) + wakeAgentDetection(forSurfaceID: surface.id) return true } @@ -232,13 +234,21 @@ final class WorktreeTerminalState { @discardableResult func submitLine(in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } - return surface.submitLine() + let submitted = surface.submitLine() + if submitted { + wakeAgentDetection(forSurfaceID: surface.id) + } + return submitted } @discardableResult func sendKeyToken(_ token: String, in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } - return surface.sendCLIKeyToken(token) + let sent = surface.sendCLIKeyToken(token) + if sent { + wakeAgentDetection(forSurfaceID: surface.id) + } + return sent } var taskStatus: WorktreeTaskStatus { diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index 89e6e437..8f92f259 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -69,37 +69,6 @@ struct ActiveAgentsFeatureTests { #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 250) == 120) } - @Test func detectionStaysEnabledForVisiblePanelAutoShowOrShelf() { - #expect( - ActiveAgentsFeature.detectionEnabled( - isPanelHidden: false, - autoShowPanel: false, - isShelfVisible: false - ) - ) - #expect( - ActiveAgentsFeature.detectionEnabled( - isPanelHidden: true, - autoShowPanel: true, - isShelfVisible: false - ) - ) - #expect( - ActiveAgentsFeature.detectionEnabled( - isPanelHidden: true, - autoShowPanel: false, - isShelfVisible: true - ) - ) - #expect( - !ActiveAgentsFeature.detectionEnabled( - isPanelHidden: true, - autoShowPanel: false, - isShelfVisible: false - ) - ) - } - @Test func rowDisplayUsesDetectedCommandTokenBeforeAgentFallback() { let ompEntry = entry( id: UUID(0), diff --git a/supacodeTests/AgentDetectionScheduleTests.swift b/supacodeTests/AgentDetectionScheduleTests.swift new file mode 100644 index 00000000..d3e2431d --- /dev/null +++ b/supacodeTests/AgentDetectionScheduleTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import supacode + +struct AgentDetectionScheduleTests { + @Test func coldSurfacesDoNotScheduleDetection() { + let now = Date(timeIntervalSince1970: 100) + let schedule = AgentDetectionSchedule.cold + + #expect(schedule.nextInterval(now: now) == nil) + } + + @Test func userActivityWarmsAColdSurfaceForAShortWindow() { + let now = Date(timeIntervalSince1970: 100) + let schedule = AgentDetectionSchedule.cold.warmed(now: now) + + #expect(schedule.nextInterval(now: now) == .seconds(2)) + #expect(schedule.nextInterval(now: now.addingTimeInterval(29.9)) == .seconds(2)) + #expect(schedule.nextInterval(now: now.addingTimeInterval(30.1)) == nil) + } + + @Test func warmingExtendsTheWindow() { + let now = Date(timeIntervalSince1970: 100) + let first = AgentDetectionSchedule.cold.warmed(now: now) + let extended = first.warmed(now: now.addingTimeInterval(20)) + + #expect(extended.nextInterval(now: now.addingTimeInterval(49.9)) == .seconds(2)) + #expect(extended.nextInterval(now: now.addingTimeInterval(50.1)) == nil) + } + + @Test func detectedAgentKeepsActiveDetectionUntilItDisappears() { + let now = Date(timeIntervalSince1970: 100) + let active = AgentDetectionSchedule.cold.warmed(now: now).observedAgent(now: now) + + #expect(active.nextInterval(now: now.addingTimeInterval(120)) == .milliseconds(300)) + + let cooldown = active.observedNoAgent(now: now.addingTimeInterval(120)) + #expect(cooldown.nextInterval(now: now.addingTimeInterval(149.9)) == .seconds(2)) + #expect(cooldown.nextInterval(now: now.addingTimeInterval(150.1)) == nil) + } +} diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index f3e13560..fc55813d 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -45,9 +45,6 @@ struct AppFeatureArchivedSelectionTests { $0.repositories.worktreeHistoryBackStack = [worktree.id] $0.repositories.selection = .archivedWorktrees } - // Leaving the worktree view can flip Shelf visibility, so AppFeature - // resyncs agent detection alongside the selection change. - await store.receive(\.syncAgentDetectionEnabled) await store.receive(\.repositories.delegate.selectedWorktreeChanged) await store.finish() #expect(saved.value.isEmpty) diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 86fb0de6..34badd4d 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -102,74 +102,6 @@ struct AppFeatureSettingsChangedTests { } } - @Test(.dependencies) func syncAgentDetectionKeepsDetectionEnabledWhenShelfVisible() async { - let worktree = makeWorktree() - let repository = makeRepository(worktrees: [worktree]) - let sentTerminalCommands = LockIsolated<[TerminalClient.Command]>([]) - var settings = SettingsFeature.State() - settings.autoShowActiveAgentsPanel = false - var state = AppFeature.State(settings: settings) - state.repositories.repositories = [repository] - state.repositories.isShelfActive = true - state.repositories.activeAgents.$isPanelHidden.withLock { $0 = true } - let store = TestStore(initialState: state) { - AppFeature() - } withDependencies: { - $0.terminalClient.send = { command in - sentTerminalCommands.withValue { $0.append(command) } - } - } - - await store.send(.syncAgentDetectionEnabled) - await store.finish() - - #expect(sentTerminalCommands.value == [.setAgentDetectionEnabled(true)]) - } - - @Test(.dependencies) func syncAgentDetectionDisablesShelfOnlyDetectionWhenShelfStatusOptedOut() async { - let worktree = makeWorktree() - let repository = makeRepository(worktrees: [worktree]) - let sentTerminalCommands = LockIsolated<[TerminalClient.Command]>([]) - var settings = SettingsFeature.State() - settings.autoShowActiveAgentsPanel = false - settings.showActiveAgentStatusInShelf = false - var state = AppFeature.State(settings: settings) - state.repositories.repositories = [repository] - state.repositories.isShelfActive = true - state.repositories.activeAgents.$isPanelHidden.withLock { $0 = true } - let store = TestStore(initialState: state) { - AppFeature() - } withDependencies: { - $0.terminalClient.send = { command in - sentTerminalCommands.withValue { $0.append(command) } - } - } - - await store.send(.syncAgentDetectionEnabled) - await store.finish() - - #expect(sentTerminalCommands.value == [.setAgentDetectionEnabled(false)]) - } - - @Test func repositoryListChangesTriggerShelfVisibilityResync() { - // `isShowingShelf` requires a non-empty repository list, so adding or - // removing repositories must resync detection even though `isShelfActive` - // is untouched. - #expect( - AppFeature.repositoriesActionMayChangeShelfVisibility( - .repositoryManagement(.repositoryRemoved("/tmp/repo", selectionWasRemoved: false)) - ) - ) - #expect( - AppFeature.repositoriesActionMayChangeShelfVisibility( - .repositoriesLoaded([], failures: [], roots: [], animated: false) - ) - ) - #expect( - !AppFeature.repositoriesActionMayChangeShelfVisibility(.selectNextShelfBook) - ) - } - @Test func appStateInitializesActiveAgentTabTitleDisplayFromSettings() { var settings = SettingsFeature.State() settings.showActiveAgentTabTitles = true diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index c481789a..4226cd0a 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -89,6 +89,50 @@ struct WorktreeTerminalManagerTests { #expect(state.canCloseFocusedSurface == false) } + @Test func newEmptyTabStartsColdAgentDetection() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + #expect(state.agentDetectionSchedules[surfaceId] == nil) + #expect(state.agentDetectionTasks[surfaceId] == nil) + } + + @Test func wakingSurfaceStartsWarmAgentDetection() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + state.wakeAgentDetection(forSurfaceID: surfaceId) + + let schedule = try #require(state.agentDetectionSchedules[surfaceId]) + #expect(schedule.nextInterval(now: Date()) != nil) + #expect(state.agentDetectionTasks[surfaceId] != nil) + + state.cleanupAllAgentDetectionState() + } + + @Test func initialInputStartsWarmAgentDetection() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab(initialInput: "codex\n")) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + let schedule = try #require(state.agentDetectionSchedules[surfaceId]) + #expect(schedule.nextInterval(now: Date()) != nil) + #expect(state.agentDetectionTasks[surfaceId] != nil) + + state.cleanupAllAgentDetectionState() + } + @Test func firstTabUsesTabSurfaceContext() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree()