From 43756a7dbe9413bb6df1051a447ff00b50e67592 Mon Sep 17 00:00:00 2001 From: Francisco Arredondo <95440147+frarredondo@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:17:02 -0700 Subject: [PATCH 1/4] feat: at-a-glance agent status (blocked / working / done) Classify each agent pane from its live terminal screen, OSC title, and PTY activity into blocked / working / idle, debounced, and surface it on the sidebar session rows, the pane tabs, and the Pane Chat entry. Roll up per session (blocked > working > idle); a pane that finishes unseen reads as done. - pure manifest detection engine + Claude/Codex/generic rule sets (unit tested) - OSC title/progress capture in TerminalStateEmulator - AgentStatusMonitor: screen + PTY-activity arbitration with debounce, wired into terminalPanelManager on a 500ms poll, emits panel:agentStatus - panelStore keeps per-panel status keyed by sessionId; one shared rollup used by the store getter and the useAgentStatus hook - session-row accent bar (amber sweep), dot+spinner on tabs and Pane Chat - README Status Cues row updated Closes #365 --- README.md | 2 +- frontend/src/App.tsx | 8 + .../src/components/ProjectSessionList.tsx | 15 +- .../src/components/SessionStatusBadge.tsx | 31 ++ frontend/src/components/Sidebar.tsx | 24 +- .../components/panels/PanelTabStatusDot.tsx | 33 ++ .../src/components/panels/PanelTabStrip.tsx | 9 +- frontend/src/components/ui/AgentStatusDot.tsx | 61 ++++ .../src/components/ui/StatusAccentBar.tsx | 50 +++ .../components/ui/agentStatusVisual.test.ts | 15 + .../src/components/ui/agentStatusVisual.ts | 31 ++ frontend/src/hooks/useAgentStatus.ts | 25 ++ frontend/src/stores/panelStore.test.ts | 50 +++ frontend/src/stores/panelStore.ts | 26 ++ frontend/src/types/electron.d.ts | 2 + frontend/src/types/panelStore.ts | 7 + frontend/src/utils/agentStatus.test.ts | 52 +++ frontend/src/utils/agentStatus.ts | 48 +++ frontend/tailwind.config.js | 6 + main/src/preload.ts | 6 + .../agentStatus/agentStatusMonitor.test.ts | 80 +++++ .../agentStatus/agentStatusMonitor.ts | 129 ++++++++ .../agentStatus/agentStatusPipeline.test.ts | 46 +++ .../agentStatus/manifestEngine.test.ts | 141 +++++++++ .../services/agentStatus/manifestEngine.ts | 253 +++++++++++++++ .../services/agentStatus/manifests.test.ts | 117 +++++++ main/src/services/agentStatus/manifests.ts | 295 ++++++++++++++++++ main/src/services/terminalPanelManager.ts | 99 ++++++ .../services/terminalStateEmulator.test.ts | 25 ++ main/src/services/terminalStateEmulator.ts | 22 ++ shared/types/agentStatus.ts | 52 +++ 31 files changed, 1734 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/SessionStatusBadge.tsx create mode 100644 frontend/src/components/panels/PanelTabStatusDot.tsx create mode 100644 frontend/src/components/ui/AgentStatusDot.tsx create mode 100644 frontend/src/components/ui/StatusAccentBar.tsx create mode 100644 frontend/src/components/ui/agentStatusVisual.test.ts create mode 100644 frontend/src/components/ui/agentStatusVisual.ts create mode 100644 frontend/src/hooks/useAgentStatus.ts create mode 100644 frontend/src/stores/panelStore.test.ts create mode 100644 frontend/src/utils/agentStatus.test.ts create mode 100644 frontend/src/utils/agentStatus.ts create mode 100644 main/src/services/agentStatus/agentStatusMonitor.test.ts create mode 100644 main/src/services/agentStatus/agentStatusMonitor.ts create mode 100644 main/src/services/agentStatus/agentStatusPipeline.test.ts create mode 100644 main/src/services/agentStatus/manifestEngine.test.ts create mode 100644 main/src/services/agentStatus/manifestEngine.ts create mode 100644 main/src/services/agentStatus/manifests.test.ts create mode 100644 main/src/services/agentStatus/manifests.ts create mode 100644 shared/types/agentStatus.ts diff --git a/README.md b/README.md index 2c414b19..2a26189e 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Each of these is a small thing. Together they compound fast. | **Terminal Popover** | Highlight any text in a terminal and an intelligent popover offers the right action: copy, open in browser, or show in explorer. | Terminal text selection popover | | **Built-in Browser** | Preview any URL in a tab next to your terminals so every pane can see its own running dev server without alt-tabbing. | Built-in browser tab previewing a local dev server | | **Resource Manager** | Built-in CPU and memory monitor broken down per pane and per process, so you can catch a runaway agent before it eats your laptop. | Built-in resource manager | -| **Status Cues** | Project dots show where work is happening, pane names breathe while active, and a dashed underline marks panes that finished while you were looking elsewhere. | Session activity status dots | +| **Status Cues** | Every AI pane reports its state at a glance — a red dot when an agent is blocked waiting on your approval, an amber pulse while it works, and a "done" cue when it finishes while you're looking elsewhere. The same rollup colors the project dots and pane tabs, so a whole screen of parallel agents reads in one glance. | Session agent status dots: blocked, working, done | | **Jump + Refresh** | Jump to top, jump to bottom, or hard-refresh any terminal from the toolbar to unstick a frozen state in one click. | Terminal jump and refresh controls | | **Auto Secrets Copy** | Every pane automatically mirrors `.env` files and secrets from your root project so your worktree is runnable the moment it's created. | | | **Isolated Ports** | Each pane runs on its own port range automatically, so you can spin up five dev servers in parallel without a single conflict. | | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 28891b70..52bc6e0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -174,6 +174,14 @@ function App() { return () => unsubscribe?.(); }, []); + // Global agent status listener (blocked / working / done) for AI/CLI panels. + useEffect(() => { + const unsubscribe = window.electronAPI?.events?.onPanelAgentStatus?.((data) => { + usePanelStore.getState().setAgentStatus(data.panelId, data.sessionId, data.state); + }); + return () => unsubscribe?.(); + }, []); + useEffect(() => { const clearViewedCompletedActivity = (event: Event) => { const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId; diff --git a/frontend/src/components/ProjectSessionList.tsx b/frontend/src/components/ProjectSessionList.tsx index de3cb2d8..2a97373d 100644 --- a/frontend/src/components/ProjectSessionList.tsx +++ b/frontend/src/components/ProjectSessionList.tsx @@ -8,7 +8,11 @@ import { CreateSessionDialog } from './CreateSessionDialog'; import { AddProjectDialog } from './AddProjectDialog'; import { Dropdown } from './ui/Dropdown'; import { Tooltip } from './ui/Tooltip'; +import { StatusAccentBar } from './ui/StatusAccentBar'; +import { AgentStatusDot } from './ui/AgentStatusDot'; import type { DropdownItem } from './ui/Dropdown'; +import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus'; +import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; import { API } from '../utils/api'; import { cn } from '../utils/cn'; import type { Session, GitStatus } from '../types/session'; @@ -69,6 +73,7 @@ export function ProjectSessionList({ const activeView = useNavigationStore(s => s.activeView); const navigateToSessions = useNavigationStore(s => s.navigateToSessions); const navigateToPaneChat = useNavigationStore(s => s.navigateToPaneChat); + const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID); const navigateToProject = useNavigationStore(s => s.navigateToProject); const setSidebarNavigationScope = useNavigationStore(s => s.setSidebarNavigationScope); // Expansion state lives in the navigation store so the always-mounted @@ -322,6 +327,7 @@ export function ProjectSessionList({ > Pane Chat + {showRemoteDesktopLink && onRemoteDesktopClick && ( @@ -664,6 +670,7 @@ function SessionRow({ return sessionPanels.some(p => s.activityStatus[p.id] === 'active') ? 'active' : 'idle'; }); const hasUnviewedCompletedActivity = usePanelStore(s => Boolean(s.unviewedCompletedActivity[session.id])); + const agentDisplayStatus = useSessionAgentDisplayStatus(session.id); // Queue the initial refresh even when cached status is available, so cached // PR state is corrected by the background git/PR refresh path. @@ -727,13 +734,13 @@ function SessionRow({ return (
+ {/* Always-present left accent bar reflecting the agent status. */} + } side="right" diff --git a/frontend/src/components/SessionStatusBadge.tsx b/frontend/src/components/SessionStatusBadge.tsx new file mode 100644 index 00000000..5589048d --- /dev/null +++ b/frontend/src/components/SessionStatusBadge.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { usePanelStore } from '../stores/panelStore'; +import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus'; +import { AgentStatusDot } from './ui/AgentStatusDot'; + +interface SessionStatusBadgeProps { + sessionId: string; + size?: 'sm' | 'md'; +} + +/** + * Session dot for the sidebar / session list. Shows the herd-of-agents status + * (blocked / working / done / idle) when the session has AI/CLI panels; for + * sessions with only plain shells it falls back to the legacy active/idle dot. + */ +export const SessionStatusBadge: React.FC = ({ sessionId, size = 'md' }) => { + const displayStatus = useSessionAgentDisplayStatus(sessionId); + const isActive = usePanelStore((s) => s.getSessionActivityStatus(sessionId) === 'active'); + + if (displayStatus === 'unknown') { + // No agent panels — preserve the original binary activity indicator. + const color = isActive + ? 'bg-status-warning opacity-100 duration-150' + : 'bg-text-muted/20 opacity-40 duration-[3s]'; + return ( +
+ ); + } + + return ; +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a5bb50c1..352a3249 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -16,7 +16,10 @@ import { Dropdown } from './ui/Dropdown'; import type { DropdownItem } from './ui/Dropdown'; import { useSessionStore } from '../stores/sessionStore'; import { useNavigationStore } from '../stores/navigationStore'; -import { usePanelStore } from '../stores/panelStore'; +import { SessionStatusBadge } from './SessionStatusBadge'; +import { AgentStatusDot } from './ui/AgentStatusDot'; +import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus'; +import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; import { API } from '../utils/api'; import type { Project } from '../types/project'; import { useSessionNavigationHotkeys } from '../hooks/useSessionNavigationHotkeys'; @@ -326,8 +329,6 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick, const sessions = useSessionStore((state) => state.sessions); const activeSessionId = useSessionStore((state) => state.activeSessionId); const setActiveSession = useSessionStore((state) => state.setActiveSession); - const activityStatus = usePanelStore(s => s.activityStatus); - const panelsBySession = usePanelStore(s => s.panels); const remoteFooterStatus = useMemo( () => getRemoteFooterStatus(remoteConnectionState, remoteHostState), [remoteConnectionState, remoteHostState], @@ -352,6 +353,7 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick, const activeView = useNavigationStore((state) => state.activeView); const navigateToProject = useNavigationStore((state) => state.navigateToProject); const navigateToPaneChat = useNavigationStore((state) => state.navigateToPaneChat); + const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID); const setSidebarNavigationScope = useNavigationStore((state) => state.setSidebarNavigationScope); useSessionNavigationHotkeys({ projects, sessionSortAscending }); @@ -421,13 +423,14 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick, navigateToPaneChat(); }} aria-label="Pane Chat" - className={`w-8 h-8 rounded flex items-center justify-center transition-colors ${ + className={`relative w-8 h-8 rounded flex items-center justify-center transition-colors ${ activeView === 'pane-chat' ? 'bg-interactive/20 text-interactive ring-1 ring-interactive/50' : 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary' }`} > + {showRemoteDesktopLink && ( @@ -464,10 +467,6 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick, {/* Session status badges — grouped under this project */} {projectSessions.map((session) => { const isActive = session.id === activeSessionId; - const sessionPanels = panelsBySession[session.id] || []; - const isSessionActive = sessionPanels.some(p => activityStatus[p.id] === 'active'); - const statusColor = isSessionActive ? 'bg-status-warning opacity-100 duration-150' : 'bg-text-muted/20 opacity-40 duration-[3s]'; - const isAnimated = isSessionActive; return ( } side="right"> ); diff --git a/frontend/src/components/panels/PanelTabStatusDot.tsx b/frontend/src/components/panels/PanelTabStatusDot.tsx new file mode 100644 index 00000000..16a56f5b --- /dev/null +++ b/frontend/src/components/panels/PanelTabStatusDot.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { cn } from '../../utils/cn'; +import { usePanelStore } from '../../stores/panelStore'; +import { usePanelAgentDisplayStatus } from '../../hooks/useAgentStatus'; +import { AgentStatusDot } from '../ui/AgentStatusDot'; + +interface PanelTabStatusDotProps { + panelId: string; + sessionId: string; +} + +/** + * Per-tab status dot: the agent status (blocked / working / done / idle) for + * AI/CLI panels, falling back to the legacy active/idle activity dot for plain + * terminal panels. + */ +export const PanelTabStatusDot: React.FC = ({ panelId, sessionId }) => { + const displayStatus = usePanelAgentDisplayStatus(panelId, sessionId); + const isActive = usePanelStore((s) => s.activityStatus[panelId] === 'active'); + + if (displayStatus === 'unknown') { + return ( + + ); + } + + return ; +}; diff --git a/frontend/src/components/panels/PanelTabStrip.tsx b/frontend/src/components/panels/PanelTabStrip.tsx index 7db7d681..10178402 100644 --- a/frontend/src/components/panels/PanelTabStrip.tsx +++ b/frontend/src/components/panels/PanelTabStrip.tsx @@ -15,6 +15,7 @@ import { Tooltip } from '../ui/Tooltip'; import { Kbd } from '../ui/Kbd'; import { usePanelStore } from '../../stores/panelStore'; import { ClaudeIcon, OpenAIIcon } from '../ui/BrandIcons'; +import { PanelTabStatusDot } from './PanelTabStatusDot'; import type { PanelTabPresentationResolver } from '../../types/panelComponents'; // --------------------------------------------------------------------------- @@ -130,7 +131,6 @@ export const PanelTabStrip: React.FC = React.memo(({ const [stripDropIndex, setStripDropIndex] = useState(null); const [rovingPanelId, setRovingPanelId] = useState(activePanelId ?? panels[0]?.id ?? null); const previousActivePanelIdRef = useRef(activePanelId); - const getPanelActivityStatus = usePanelStore(s => s.getPanelActivityStatus); useEffect(() => { const activePanelChanged = previousActivePanelIdRef.current !== activePanelId; @@ -445,12 +445,7 @@ export const PanelTabStrip: React.FC = React.memo(({ compact ? "gap-1" : "gap-2", )}> {panel.type === 'terminal' && ( - + )} {getPanelIcon(panel.type, panel, compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} {/* Bold marks the primary group's strip: the group the top diff --git a/frontend/src/components/ui/AgentStatusDot.tsx b/frontend/src/components/ui/AgentStatusDot.tsx new file mode 100644 index 00000000..c56d4084 --- /dev/null +++ b/frontend/src/components/ui/AgentStatusDot.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { cn } from '../../utils/cn'; +import type { AgentDisplayStatus } from '../../../../shared/types/agentStatus'; +import { agentStatusVisual } from './agentStatusVisual'; + +interface AgentStatusDotProps { + status: AgentDisplayStatus; + size?: 'sm' | 'md'; + className?: string; +} + +const sizeClasses = { + sm: 'w-2 h-2', + md: 'w-2.5 h-2.5', +}; + +const spinnerSizeClasses = { + sm: 'w-3 h-3 border-2', + md: 'w-3.5 h-3.5 border-2', +}; + +/** + * At-a-glance agent status indicator. Working renders as an amber spinner; blocked + * (red), done (blue), and idle (green) render as a dot — the "dot + spinner" + * variation. Renders nothing for `unknown` so non-agent panels show no badge. + */ +export const AgentStatusDot: React.FC = ({ status, size = 'md', className }) => { + const visual = agentStatusVisual(status); + if (!visual) return null; + + if (status === 'working') { + // Amber ring spinner conveys active work more clearly than a pulsing dot. + return ( + + ); + } + + return ( + + ); +}; diff --git a/frontend/src/components/ui/StatusAccentBar.tsx b/frontend/src/components/ui/StatusAccentBar.tsx new file mode 100644 index 00000000..a5f64232 --- /dev/null +++ b/frontend/src/components/ui/StatusAccentBar.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { cn } from '../../utils/cn'; +import type { AgentDisplayStatus } from '../../../../shared/types/agentStatus'; + +interface StatusAccentBarProps { + status: AgentDisplayStatus; + /** Whether this row is the selected one (used only to color the fallback bar). */ + isActive?: boolean; + className?: string; +} + +const barColor: Record, string> = { + blocked: 'bg-status-error', + working: 'bg-status-warning', + done: 'bg-status-info', + idle: 'bg-status-success', +}; + +/** + * The always-present left accent bar on a session row. It follows the at-a-glance + * agent status: red = blocked, amber (with an up/down loading sweep) = working, + * blue = done, green = idle. For rows with no tracked agent (`unknown`) it shows + * the selection accent when active and nothing otherwise. + */ +export const StatusAccentBar: React.FC = ({ status, isActive, className }) => { + if (status === 'unknown') { + return ( +
+ ); + } + + return ( +
+ {status === 'working' && ( +
+ )} +
+ ); +}; diff --git a/frontend/src/components/ui/agentStatusVisual.test.ts b/frontend/src/components/ui/agentStatusVisual.test.ts new file mode 100644 index 00000000..7eaa2269 --- /dev/null +++ b/frontend/src/components/ui/agentStatusVisual.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { agentStatusVisual } from './agentStatusVisual'; + +describe('agentStatusVisual', () => { + it('maps each status to its token, label, and animation', () => { + expect(agentStatusVisual('blocked')).toEqual({ colorClass: 'bg-status-error', label: 'blocked', animate: true }); + expect(agentStatusVisual('working')).toEqual({ colorClass: 'bg-status-warning', label: 'working', animate: true }); + expect(agentStatusVisual('done')).toEqual({ colorClass: 'bg-status-info', label: 'done', animate: false }); + expect(agentStatusVisual('idle')).toEqual({ colorClass: 'bg-status-success', label: 'idle', animate: false }); + }); + + it('returns null for unknown so no badge renders', () => { + expect(agentStatusVisual('unknown')).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/agentStatusVisual.ts b/frontend/src/components/ui/agentStatusVisual.ts new file mode 100644 index 00000000..64c28279 --- /dev/null +++ b/frontend/src/components/ui/agentStatusVisual.ts @@ -0,0 +1,31 @@ +import type { AgentDisplayStatus } from '../../../../shared/types/agentStatus'; + +export interface AgentStatusVisual { + /** Tailwind background token for the status dot. */ + colorClass: string; + /** Short human label, e.g. for tooltips / aria. */ + label: string; + /** Whether the dot should animate (working/blocked draw the eye). */ + animate: boolean; +} + +/** + * Single source of truth for how an {@link AgentDisplayStatus} looks: blocked is + * red and pulses, working is amber and pulses, a freshly finished agent is a blue + * "done" cue, a seen-idle agent is calm green. `unknown` (no agent / plain shell) + * returns null so callers render no badge. + */ +export function agentStatusVisual(status: AgentDisplayStatus): AgentStatusVisual | null { + switch (status) { + case 'blocked': + return { colorClass: 'bg-status-error', label: 'blocked', animate: true }; + case 'working': + return { colorClass: 'bg-status-warning', label: 'working', animate: true }; + case 'done': + return { colorClass: 'bg-status-info', label: 'done', animate: false }; + case 'idle': + return { colorClass: 'bg-status-success', label: 'idle', animate: false }; + case 'unknown': + return null; + } +} diff --git a/frontend/src/hooks/useAgentStatus.ts b/frontend/src/hooks/useAgentStatus.ts new file mode 100644 index 00000000..26d474ad --- /dev/null +++ b/frontend/src/hooks/useAgentStatus.ts @@ -0,0 +1,25 @@ +import { usePanelStore } from '../stores/panelStore'; +import { rollupSessionAgentState, toAgentDisplayStatus } from '../utils/agentStatus'; +import type { AgentDisplayStatus } from '../../../shared/types/agentStatus'; + +/** + * Session-level at-a-glance status for the sidebar / session list: the panels' + * states rolled up (blocked > working > idle) and mapped to a display status, + * where a session that finished while the user was elsewhere reads as `done`. + * + * Rolls up by the sessionId carried on each status event (not `panels`), so + * background sessions and Pane Chat — whose panels aren't loaded into the store — + * still light up. + */ +export function useSessionAgentDisplayStatus(sessionId: string): AgentDisplayStatus { + const raw = usePanelStore((s) => rollupSessionAgentState(s.agentStatus, s.agentStatusSession, sessionId)); + const unseen = usePanelStore((s) => Boolean(s.unviewedCompletedActivity[sessionId])); + return toAgentDisplayStatus(raw, unseen); +} + +/** Per-panel display status for pane tabs. */ +export function usePanelAgentDisplayStatus(panelId: string, sessionId: string): AgentDisplayStatus { + const raw = usePanelStore((s) => s.agentStatus[panelId]); + const unseen = usePanelStore((s) => Boolean(s.unviewedCompletedActivity[sessionId])); + return toAgentDisplayStatus(raw, unseen); +} diff --git a/frontend/src/stores/panelStore.test.ts b/frontend/src/stores/panelStore.test.ts new file mode 100644 index 00000000..f80fa857 --- /dev/null +++ b/frontend/src/stores/panelStore.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { usePanelStore } from './panelStore'; +import type { ToolPanel } from '../../../shared/types/panels'; + +const panel = (id: string, sessionId: string): ToolPanel => + ({ id, sessionId, type: 'terminal', state: { isActive: false, customState: {} } } as unknown as ToolPanel); + +const reset = () => + usePanelStore.setState({ panels: {}, activePanels: {}, activityStatus: {}, agentStatus: {}, agentStatusSession: {} }); + +describe('panelStore agent status', () => { + beforeEach(reset); + + it('stores and reads a panel agent state', () => { + const store = usePanelStore.getState(); + store.setAgentStatus('p1', 's1', 'working'); + expect(usePanelStore.getState().getPanelAgentState('p1')).toBe('working'); + }); + + it('rolls session state up by event sessionId, without panels loaded', () => { + const store = usePanelStore.getState(); + // Note: no setPanels — rollup must work purely from status events. + store.setAgentStatus('a', 's1', 'idle'); + store.setAgentStatus('b', 's1', 'working'); + expect(usePanelStore.getState().getSessionAgentState('s1')).toBe('working'); + usePanelStore.getState().setAgentStatus('c', 's1', 'blocked'); + expect(usePanelStore.getState().getSessionAgentState('s1')).toBe('blocked'); + }); + + it('does not mix status across sessions', () => { + const store = usePanelStore.getState(); + store.setAgentStatus('a', 's1', 'blocked'); + store.setAgentStatus('b', 's2', 'idle'); + expect(usePanelStore.getState().getSessionAgentState('s1')).toBe('blocked'); + expect(usePanelStore.getState().getSessionAgentState('s2')).toBe('idle'); + }); + + it('returns unknown for a session with no tracked agent panels', () => { + expect(usePanelStore.getState().getSessionAgentState('s2')).toBe('unknown'); + }); + + it('clears agent status when a panel is removed', () => { + const store = usePanelStore.getState(); + store.setPanels('s1', [panel('a', 's1')]); + store.setAgentStatus('a', 's1', 'blocked'); + store.removePanel('s1', 'a'); + expect(usePanelStore.getState().getPanelAgentState('a')).toBeUndefined(); + expect(usePanelStore.getState().getSessionAgentState('s1')).toBe('unknown'); + }); +}); diff --git a/frontend/src/stores/panelStore.ts b/frontend/src/stores/panelStore.ts index 3cccc529..2b6ab038 100644 --- a/frontend/src/stores/panelStore.ts +++ b/frontend/src/stores/panelStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; import { PanelStore } from '../types/panelStore'; import { ToolPanel } from '../../../shared/types/panels'; +import { rollupSessionAgentState } from '../utils/agentStatus'; // FIX: Use immer for safe immutable updates export const usePanelStore = create()( @@ -9,6 +10,8 @@ export const usePanelStore = create()( panels: {}, activePanels: {}, activityStatus: {}, + agentStatus: {}, + agentStatusSession: {}, lastActivityAt: {}, unviewedCompletedActivity: {}, layouts: {}, @@ -54,6 +57,8 @@ export const usePanelStore = create()( delete state.activePanels[sessionId]; } delete state.activityStatus[panelId]; + delete state.agentStatus[panelId]; + delete state.agentStatusSession[panelId]; delete state.lastActivityAt[panelId]; }); }, @@ -86,6 +91,20 @@ export const usePanelStore = create()( }); }, + setAgentStatus: (panelId, sessionId, agentState) => { + set((state) => { + state.agentStatus[panelId] = agentState; + state.agentStatusSession[panelId] = sessionId; + }); + }, + + clearAgentStatus: (panelId) => { + set((state) => { + delete state.agentStatus[panelId]; + delete state.agentStatusSession[panelId]; + }); + }, + markUnviewedCompletedActivity: (sessionId, completedAt) => { set((state) => { state.unviewedCompletedActivity[sessionId] = completedAt ?? new Date().toISOString(); @@ -123,6 +142,13 @@ export const usePanelStore = create()( const actStatus = get().activityStatus; return sessionPanels.some((p) => actStatus[p.id] === 'active') ? 'active' : 'idle'; }, + getPanelAgentState: (panelId) => get().agentStatus[panelId], + getSessionAgentState: (sessionId) => { + // Roll up by the sessionId carried on each status event, so background + // sessions (whose panels aren't loaded into the store) still light up. + const { agentStatus, agentStatusSession } = get(); + return rollupSessionAgentState(agentStatus, agentStatusSession, sessionId); + }, hasUnviewedCompletedActivity: (sessionId) => { return Boolean(get().unviewedCompletedActivity[sessionId]); }, diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 898ad6ac..52281e2a 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -25,6 +25,7 @@ import type { PanePermissionResponse, } from '../../../shared/types/daemon'; import type { ToolPanel } from '../../../shared/types/panels'; +import type { PanelAgentStatusEvent } from '../../../shared/types/agentStatus'; import type { PaneChatAgent, PaneChatState } from '../../../shared/types/paneChat'; import type { CreateSessionRequest } from './session'; import type { DetectedProjectConfig } from '../../../shared/types/projectConfig'; @@ -344,6 +345,7 @@ interface ElectronAPI { onPanelUpdated: (callback: (panel: ToolPanel) => void) => () => void; onPanelDeleted: (callback: (data: { panelId: string; sessionId: string }) => void) => () => void; onPanelActivityStatus: (callback: (data: { panelId: string; sessionId: string; status: 'active' | 'idle'; lastActivityAt?: string }) => void) => () => void; + onPanelAgentStatus: (callback: (data: PanelAgentStatusEvent) => void) => () => void; onPanelPromptAdded: (callback: (data: { panelId: string; content: string }) => void) => () => void; onPanelResponseAdded: (callback: (data: { panelId: string; content: string }) => void) => () => void; diff --git a/frontend/src/types/panelStore.ts b/frontend/src/types/panelStore.ts index a4de47c3..7fe16d56 100644 --- a/frontend/src/types/panelStore.ts +++ b/frontend/src/types/panelStore.ts @@ -1,10 +1,13 @@ import { ToolPanel, SessionPanelLayout } from '../../../shared/types/panels'; +import { AgentState } from '../../../shared/types/agentStatus'; export interface PanelStore { // State (using plain objects instead of Maps for React reactivity) panels: Record; // sessionId -> panels activePanels: Record; // sessionId -> active panelId activityStatus: Record; // panelId -> status + agentStatus: Record; // panelId -> detected agent state (blocked/working/idle) + agentStatusSession: Record; // panelId -> sessionId (so status rolls up without panels loaded) lastActivityAt: Record; // panelId -> last PTY output timestamp unviewedCompletedActivity: Record; // sessionId -> completion timestamp @@ -20,6 +23,8 @@ export interface PanelStore { updatePanelState: (panel: ToolPanel) => void; setActivityStatus: (panelId: string, status: 'active' | 'idle', lastActivityAt?: string) => void; clearActivityStatus: (panelId: string) => void; + setAgentStatus: (panelId: string, sessionId: string, state: AgentState) => void; + clearAgentStatus: (panelId: string) => void; markUnviewedCompletedActivity: (sessionId: string, completedAt?: string) => void; clearUnviewedCompletedActivity: (sessionId: string) => void; @@ -32,6 +37,8 @@ export interface PanelStore { getActivePanel: (sessionId: string) => ToolPanel | undefined; getPanelActivityStatus: (panelId: string) => 'active' | 'idle'; getSessionActivityStatus: (sessionId: string) => 'active' | 'idle'; + getPanelAgentState: (panelId: string) => AgentState | undefined; + getSessionAgentState: (sessionId: string) => AgentState; hasUnviewedCompletedActivity: (sessionId: string) => boolean; getLayout: (sessionId: string) => SessionPanelLayout | undefined; getFocusedGroupId: (sessionId: string) => string | undefined; diff --git a/frontend/src/utils/agentStatus.test.ts b/frontend/src/utils/agentStatus.test.ts new file mode 100644 index 00000000..e8a57d55 --- /dev/null +++ b/frontend/src/utils/agentStatus.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { rollupAgentState, rollupSessionAgentState, toAgentDisplayStatus } from './agentStatus'; + +describe('rollupSessionAgentState', () => { + const agentStatus = { p1: 'working', p2: 'idle', p3: 'blocked' } as const; + const session = { p1: 's1', p2: 's1', p3: 's2' }; + + it('rolls up only the panels belonging to the session (no panels map needed)', () => { + expect(rollupSessionAgentState({ ...agentStatus }, session, 's1')).toBe('working'); + expect(rollupSessionAgentState({ ...agentStatus }, session, 's2')).toBe('blocked'); + }); + + it('returns unknown for a session with no tracked panels', () => { + expect(rollupSessionAgentState({ ...agentStatus }, session, 'nope')).toBe('unknown'); + }); + + it('rolls up Pane Chat by its session id', () => { + const status = { '__pane_chat_terminal__': 'working' } as const; + const sess = { '__pane_chat_terminal__': '__pane_chat_session__' }; + expect(rollupSessionAgentState({ ...status }, sess, '__pane_chat_session__')).toBe('working'); + }); +}); + +describe('rollupAgentState', () => { + it('applies precedence blocked > working > idle', () => { + expect(rollupAgentState(['idle', 'working', 'blocked'])).toBe('blocked'); + expect(rollupAgentState(['idle', 'working'])).toBe('working'); + expect(rollupAgentState(['idle', 'idle'])).toBe('idle'); + }); + + it('returns unknown when nothing is tracked', () => { + expect(rollupAgentState([])).toBe('unknown'); + expect(rollupAgentState([undefined, 'unknown'])).toBe('unknown'); + }); +}); + +describe('toAgentDisplayStatus', () => { + it('maps unseen idle to done and seen idle to idle', () => { + expect(toAgentDisplayStatus('idle', true)).toBe('done'); + expect(toAgentDisplayStatus('idle', false)).toBe('idle'); + }); + + it('passes blocked and working through unchanged', () => { + expect(toAgentDisplayStatus('blocked', true)).toBe('blocked'); + expect(toAgentDisplayStatus('working', false)).toBe('working'); + }); + + it('treats missing/unknown as unknown', () => { + expect(toAgentDisplayStatus(undefined, true)).toBe('unknown'); + expect(toAgentDisplayStatus('unknown', false)).toBe('unknown'); + }); +}); diff --git a/frontend/src/utils/agentStatus.ts b/frontend/src/utils/agentStatus.ts new file mode 100644 index 00000000..5e2497fe --- /dev/null +++ b/frontend/src/utils/agentStatus.ts @@ -0,0 +1,48 @@ +import type { AgentDisplayStatus, AgentState } from '../../../shared/types/agentStatus'; + +/** + * Roll several panel {@link AgentState}s up into one, with precedence + * blocked > working > idle. Returns `unknown` when no agent panel is tracked + * (e.g. a session with only plain-shell panels), so callers can hide the badge. + */ +export function rollupAgentState(states: Array): AgentState { + let sawWorking = false; + let sawIdle = false; + for (const state of states) { + if (state === 'blocked') return 'blocked'; + if (state === 'working') sawWorking = true; + else if (state === 'idle') sawIdle = true; + } + if (sawWorking) return 'working'; + return sawIdle ? 'idle' : 'unknown'; +} + +/** + * Roll up every tracked panel belonging to `sessionId` (matched via the sessionId + * carried on each status event), independent of whether the session's panels are + * loaded into the store — so background sessions and Pane Chat still light up. + */ +export function rollupSessionAgentState( + agentStatus: Record, + agentStatusSession: Record, + sessionId: string, +): AgentState { + const states: AgentState[] = []; + for (const panelId of Object.keys(agentStatus)) { + if (agentStatusSession[panelId] === sessionId) states.push(agentStatus[panelId]); + } + return rollupAgentState(states); +} + +/** + * Map a raw {@link AgentState} to the status shown in the UI. A finished agent + * the user hasn't looked at yet reads as `done`; once seen it is plain `idle`. + */ +export function toAgentDisplayStatus( + raw: AgentState | undefined, + unseen: boolean, +): AgentDisplayStatus { + if (!raw || raw === 'unknown') return 'unknown'; + if (raw === 'idle') return unseen ? 'done' : 'idle'; + return raw; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 63c68837..6708c44a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -239,6 +239,7 @@ export default { }, animation: { shimmer: 'shimmer 2s linear infinite', + 'status-working': 'status-working-sweep 1.4s ease-in-out infinite alternate', 'in': 'in 0.2s ease-out', 'fade-in': 'fade-in 0.2s ease-out', 'zoom-in-95': 'zoom-in-95 0.2s ease-out', @@ -250,6 +251,11 @@ export default { '0%': { transform: 'translateX(-100%)' }, '100%': { transform: 'translateX(100%)' }, }, + // Vertical "loading" light that sweeps up and down the working status bar. + 'status-working-sweep': { + '0%': { transform: 'translateY(-120%)' }, + '100%': { transform: 'translateY(120%)' }, + }, 'in': { '0%': { opacity: 0, transform: 'scale(0.95)' }, '100%': { opacity: 1, transform: 'scale(1)' }, diff --git a/main/src/preload.ts b/main/src/preload.ts index cfa39219..4ce5c9ad 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -18,6 +18,7 @@ import type { RemotePaneConnectionProfile, } from '../../shared/types/remoteDaemon'; import type { ToolPanel } from '../../shared/types/panels'; +import type { PanelAgentStatusEvent } from '../../shared/types/agentStatus'; interface LogEntry { timestamp: string; @@ -839,6 +840,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('panel:activityStatus', wrappedCallback); return () => ipcRenderer.removeListener('panel:activityStatus', wrappedCallback); }, + onPanelAgentStatus: (callback: (data: PanelAgentStatusEvent) => void) => { + const wrappedCallback = (_event: Electron.IpcRendererEvent, data: PanelAgentStatusEvent) => callback(data); + ipcRenderer.on('panel:agentStatus', wrappedCallback); + return () => ipcRenderer.removeListener('panel:agentStatus', wrappedCallback); + }, // Folder events onFolderCreated: (callback: (folder: Folder) => void) => { diff --git a/main/src/services/agentStatus/agentStatusMonitor.test.ts b/main/src/services/agentStatus/agentStatusMonitor.test.ts new file mode 100644 index 00000000..c0e1664e --- /dev/null +++ b/main/src/services/agentStatus/agentStatusMonitor.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { AgentStatusMonitor } from './agentStatusMonitor'; +import type { AgentDetectionResult } from '../../../../shared/types/agentStatus'; + +const detection = (partial: Partial): AgentDetectionResult => ({ + state: 'idle', + visibleBlocker: false, + visibleWorking: false, + visibleIdle: false, + skipStateUpdate: false, + matchedRuleId: null, + ...partial, +}); + +const opts = { + workingActivityWindowMs: 600, + workingToIdleHoldMs: 700, + startupGraceMs: 3000, +}; + +describe('AgentStatusMonitor', () => { + it('publishes working while PTY bytes are flowing', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + m.noteActivity('p', 10); + expect(m.update('p', detection({ state: 'idle' }), 20)).toBe('working'); + expect(m.getState('p')).toBe('working'); + }); + + it('settles to idle after activity stops and the hold elapses (past startup grace)', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + m.noteActivity('p', 4000); + expect(m.update('p', detection({ state: 'idle' }), 4010)).toBe('working'); + // Activity window lapses (no new bytes) -> idle candidate begins. + expect(m.update('p', detection({ state: 'idle' }), 4800)).toBeNull(); // holding + // Hold elapses -> idle published. + expect(m.update('p', detection({ state: 'idle' }), 5600)).toBe('idle'); + }); + + it('publishes blocked immediately, overriding recent activity', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + m.noteActivity('p', 4000); + m.update('p', detection({ state: 'idle' }), 4010); // working + const changed = m.update('p', detection({ state: 'blocked', visibleBlocker: true }), 4020); + expect(changed).toBe('blocked'); + }); + + it('holds the prior state on skipStateUpdate detections', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + m.noteActivity('p', 4000); + m.update('p', detection({ state: 'idle' }), 4010); // working + expect(m.update('p', detection({ state: 'unknown', skipStateUpdate: true }), 4020)).toBeNull(); + expect(m.getState('p')).toBe('working'); + }); + + it('suppresses premature idle during the startup grace window', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + // No activity, idle detection, but still inside 3s grace -> not idle yet. + expect(m.update('p', detection({ state: 'idle' }), 500)).toBe('working'); + expect(m.getState('p')).toBe('working'); + }); + + it('emits only on change', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + m.noteActivity('p', 4000); + expect(m.update('p', detection({ state: 'working' }), 4010)).toBe('working'); + expect(m.update('p', detection({ state: 'working' }), 4020)).toBeNull(); + }); + + it('ignores unregistered panels', () => { + const m = new AgentStatusMonitor(opts); + expect(m.update('ghost', detection({ state: 'working' }), 0)).toBeNull(); + expect(m.getState('ghost')).toBeUndefined(); + }); +}); diff --git a/main/src/services/agentStatus/agentStatusMonitor.ts b/main/src/services/agentStatus/agentStatusMonitor.ts new file mode 100644 index 00000000..2ccf78b9 --- /dev/null +++ b/main/src/services/agentStatus/agentStatusMonitor.ts @@ -0,0 +1,129 @@ +/** + * Continuous agent-status state machine. + * + * Owns per-panel status trackers and arbitrates a published {@link AgentState} + * from three signals: the screen/OSC {@link AgentDetectionResult}, recent PTY + * byte-activity (the "working" authority), and elapsed time. It is deliberately + * timer-free and clock-injectable — the caller re-evaluates on PTY output and on + * a short poll, so debounce/grace windows resolve purely from timestamps, which + * keeps the machine fully unit-testable. + * + * Arbitration precedence: a visible blocker wins immediately; otherwise recent + * activity (or a working detection) means working; otherwise idle — but idle is + * held briefly after working (to ride out spinner gaps) and suppressed during a + * short startup grace so a booting agent doesn't flash "done". + */ + +import type { AgentDetectionResult, AgentState } from '../../../../shared/types/agentStatus'; + +export interface AgentStatusMonitorOptions { + /** Bytes seen within this window count as "working". */ + workingActivityWindowMs?: number; + /** How long to hold `working` after activity stops before going idle. */ + workingToIdleHoldMs?: number; + /** Idle is suppressed for this long after a panel registers. */ + startupGraceMs?: number; +} + +interface PanelTracker { + startedAt: number; + lastActivityAt: number | undefined; + idleSince: number | undefined; + published: AgentState | undefined; +} + +const DEFAULTS: Required = { + workingActivityWindowMs: 600, + workingToIdleHoldMs: 700, + startupGraceMs: 3000, +}; + +export class AgentStatusMonitor { + private readonly trackers = new Map(); + private readonly options: Required; + + constructor(options: AgentStatusMonitorOptions = {}) { + this.options = { ...DEFAULTS, ...options }; + } + + /** Begin tracking an agent panel. Only registered panels ever emit. */ + register(panelId: string, now: number): void { + this.trackers.set(panelId, { + startedAt: now, + lastActivityAt: undefined, + idleSince: undefined, + published: undefined, + }); + } + + unregister(panelId: string): void { + this.trackers.delete(panelId); + } + + isTracked(panelId: string): boolean { + return this.trackers.has(panelId); + } + + /** Number of panels currently tracked. */ + get size(): number { + return this.trackers.size; + } + + /** Record that PTY bytes were produced for a panel at `now`. */ + noteActivity(panelId: string, now: number): void { + const tracker = this.trackers.get(panelId); + if (tracker) tracker.lastActivityAt = now; + } + + getState(panelId: string): AgentState | undefined { + return this.trackers.get(panelId)?.published; + } + + /** + * Re-evaluate a panel. Returns the newly published state when it changed, or + * null when unchanged / still debouncing / not tracked. + */ + update(panelId: string, detection: AgentDetectionResult, now: number): AgentState | null { + const tracker = this.trackers.get(panelId); + if (!tracker) return null; + + // Agent-owned viewer (transcript/model picker): hold the known state. + if (detection.skipStateUpdate) return null; + + const { workingActivityWindowMs, workingToIdleHoldMs, startupGraceMs } = this.options; + const recentlyActive = + tracker.lastActivityAt !== undefined && now - tracker.lastActivityAt < workingActivityWindowMs; + + let candidate: AgentState; + if (detection.state === 'blocked') { + candidate = 'blocked'; + } else if (detection.state === 'working' || recentlyActive) { + candidate = 'working'; + } else { + candidate = 'idle'; + } + + // Startup grace: a freshly launched agent shouldn't flash idle before it boots. + if (candidate === 'idle' && now - tracker.startedAt < startupGraceMs) { + candidate = tracker.published ?? 'working'; + } + + // Working -> idle debounce: ride out spinner gaps before declaring done. + let holding = false; + if (candidate === 'idle' && tracker.published === 'working') { + if (tracker.idleSince === undefined) { + tracker.idleSince = now; + holding = true; + } else if (now - tracker.idleSince < workingToIdleHoldMs) { + holding = true; + } + } else { + tracker.idleSince = undefined; + } + if (holding) return null; + + if (tracker.published === candidate) return null; + tracker.published = candidate; + return candidate; + } +} diff --git a/main/src/services/agentStatus/agentStatusPipeline.test.ts b/main/src/services/agentStatus/agentStatusPipeline.test.ts new file mode 100644 index 00000000..0f8e9205 --- /dev/null +++ b/main/src/services/agentStatus/agentStatusPipeline.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { TerminalStateEmulator } from '../terminalStateEmulator'; +import { AgentStatusMonitor } from './agentStatusMonitor'; +import { detectAgentState } from './manifestEngine'; +import { CLAUDE_MANIFEST } from './manifests'; + +/** + * End-to-end pipeline: raw PTY bytes -> TerminalStateEmulator screen/OSC -> + * manifest detection -> monitor arbitration. Proves the pieces compose on real + * ANSI/OSC sequences, not just synthetic inputs. + */ +async function classify(emulator: TerminalStateEmulator, monitor: AgentStatusMonitor, now: number) { + await emulator.waitForIdle(); + const detection = detectAgentState(CLAUDE_MANIFEST, { + screen: emulator.getScreenText(), + oscTitle: emulator.getOscTitle(), + oscProgress: emulator.getOscProgress(), + }); + return monitor.update('p', detection, now); +} + +describe('agent status pipeline (emulator -> detect -> monitor)', () => { + it('goes working (spinner title) -> blocked (permission prompt) on real sequences', async () => { + const emulator = new TerminalStateEmulator(60, 12); + const monitor = new AgentStatusMonitor({ + workingActivityWindowMs: 600, + workingToIdleHoldMs: 700, + startupGraceMs: 3000, + }); + monitor.register('p', 0); + + // Agent starts working: OSC title carries a braille spinner + PTY bytes flow. + emulator.write('\x1b]2;⠹ Claude\x07Thinking...'); + monitor.noteActivity('p', 4000); + expect(await classify(emulator, monitor, 4010)).toBe('working'); + + // Agent pauses for approval: title clears, a permission prompt is drawn. + emulator.write('\x1b[2J\x1b[H'); + emulator.write('\x1b]2;\x07'); // clear title + emulator.write('Bash command\r\n rm -rf build\r\n\r\n'); + emulator.write('Do you want to proceed?\r\n'); + emulator.write('❯ 1. Yes\r\n 2. No, tell Claude what to do differently (esc)\r\n'); + // Bytes stopped flowing; blocker should win regardless of prior activity. + expect(await classify(emulator, monitor, 5000)).toBe('blocked'); + }); +}); diff --git a/main/src/services/agentStatus/manifestEngine.test.ts b/main/src/services/agentStatus/manifestEngine.test.ts new file mode 100644 index 00000000..cfdcb710 --- /dev/null +++ b/main/src/services/agentStatus/manifestEngine.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { + detectAgentState, + extractRegion, + ruleMatches, + type AgentManifest, + type ManifestRule, +} from './manifestEngine'; + +const input = (screen: string, oscTitle = '', oscProgress = '') => ({ + screen, + oscTitle, + oscProgress, +}); + +const rule = (partial: Partial & { id: string }): ManifestRule => ({ + state: 'blocked', + priority: 100, + region: 'whole_recent', + ...partial, +}); + +const manifest = (rules: ManifestRule[]): AgentManifest => ({ id: 'test', rules }); + +describe('extractRegion', () => { + it('returns osc fields for osc regions and screen for whole_recent', () => { + const i = input('line a\nline b', 'the title', '4;0'); + expect(extractRegion(i, 'osc_title')).toBe('the title'); + expect(extractRegion(i, 'osc_progress')).toBe('4;0'); + expect(extractRegion(i, 'whole_recent')).toBe('line a\nline b'); + }); + + it('bottom_non_empty_lines(N) keeps the last N non-empty lines to end', () => { + const i = input('one\n\ntwo\nthree\n\n'); + expect(extractRegion(i, 'bottom_non_empty_lines(2)')).toBe('two\nthree\n\n'); + expect(extractRegion(i, 'bottom_non_empty_lines(1)')).toBe('three\n\n'); + }); + + it('after_last_horizontal_rule returns everything after the last rule (or all if none)', () => { + expect(extractRegion(input('a\nb\nc'), 'after_last_horizontal_rule')).toBe('a\nb\nc'); + expect( + extractRegion(input('head\n──────\ntail line'), 'after_last_horizontal_rule'), + ).toBe('tail line'); + }); + + it('after_last_prompt_marker returns content after the last codex prompt line', () => { + expect( + extractRegion(input('output\n› typed prompt\nbelow'), 'after_last_prompt_marker'), + ).toBe('below'); + }); + + it('prompt_box_body returns the body between the box borders', () => { + const screen = 'context\n──────\n ❯ hello \n──────'; + expect(extractRegion(input(screen), 'prompt_box_body').trim()).toBe('❯ hello'); + }); +}); + +describe('ruleMatches', () => { + it('contains is case-insensitive and AND-combined', () => { + const r = rule({ id: 'c', contains: ['Do you want', 'proceed?'] }); + expect(ruleMatches(r, 'DO YOU WANT to PROCEED?')).toBe(true); + expect(ruleMatches(r, 'do you want to continue?')).toBe(false); + }); + + it('not gate blocks an otherwise-matching rule', () => { + const r = rule({ id: 'n', contains: ['proceed'], not: [{ contains: ['select model'] }] }); + expect(ruleMatches(r, 'proceed?')).toBe(true); + expect(ruleMatches(r, 'proceed? select model')).toBe(false); + }); + + it('any gate requires at least one child to match', () => { + const r = rule({ id: 'a', any: [{ contains: ['yes'] }, { contains: ['❯'] }] }); + expect(ruleMatches(r, 'press yes')).toBe(true); + expect(ruleMatches(r, 'nothing here')).toBe(false); + }); + + it('lineRegex must match some line', () => { + const r = rule({ id: 'l', lineRegex: [/^\s*❯?\s*1\.\s*yes\b/i] }); + expect(ruleMatches(r, 'prefix\n ❯ 1. Yes, do it\nsuffix')).toBe(true); + expect(ruleMatches(r, 'no numbered option')).toBe(false); + }); +}); + +describe('detectAgentState', () => { + it('falls back to idle with no matched rule for a known agent', () => { + const m = manifest([rule({ id: 'blk', contains: ['do you want to proceed?'] })]); + const result = detectAgentState(m, input('❯ just a prompt')); + expect(result.state).toBe('idle'); + expect(result.matchedRuleId).toBeNull(); + expect(result.visibleBlocker).toBe(false); + }); + + it('selects the highest-priority matching rule', () => { + const m = manifest([ + rule({ id: 'low', priority: 100, state: 'idle', contains: ['prompt'] }), + rule({ id: 'high', priority: 900, state: 'blocked', contains: ['proceed'] }), + ]); + const result = detectAgentState(m, input('prompt — do you want to proceed')); + expect(result.state).toBe('blocked'); + expect(result.matchedRuleId).toBe('high'); + }); + + it('sets visibleBlocker only when the matched blocked rule declares it', () => { + const m = manifest([ + rule({ id: 'blk', state: 'blocked', visibleBlocker: true, contains: ['proceed?'] }), + ]); + const result = detectAgentState(m, input('do you want to proceed?')); + expect(result.state).toBe('blocked'); + expect(result.visibleBlocker).toBe(true); + }); + + it('honors skipStateUpdate rules (transcript viewer)', () => { + const m = manifest([ + rule({ + id: 'viewer', + state: 'unknown', + priority: 1000, + skipStateUpdate: true, + contains: ['showing detailed transcript'], + }), + ]); + const result = detectAgentState(m, input('showing detailed transcript · ctrl+o to toggle')); + expect(result.skipStateUpdate).toBe(true); + expect(result.matchedRuleId).toBe('viewer'); + }); + + it('detects working from an osc_title spinner rule', () => { + const m = manifest([ + rule({ + id: 'spin', + state: 'working', + region: 'osc_title', + visibleWorking: true, + regex: [/^[\u{2800}-\u{28FF}] /u], + }), + ]); + const result = detectAgentState(m, input('', '⠉ Claude')); + expect(result.state).toBe('working'); + expect(result.visibleWorking).toBe(true); + }); +}); diff --git a/main/src/services/agentStatus/manifestEngine.ts b/main/src/services/agentStatus/manifestEngine.ts new file mode 100644 index 00000000..e72a7a29 --- /dev/null +++ b/main/src/services/agentStatus/manifestEngine.ts @@ -0,0 +1,253 @@ +/** + * Pure, side-effect-free agent-status detection engine. + * + * Evaluates a per-agent {@link AgentManifest} of priority-ordered rules against a + * live terminal snapshot (screen text + OSC title/progress) and returns the + * winning {@link AgentState}. The engine is intentionally dependency-free and + * deterministic so it stays trivially unit-testable; all terminal reads and IPC + * happen in the caller (see agentStatusMonitor). + * + * The rule/region semantics mirror a small, well-tested subset of screen-manifest + * detection: regions carve the snapshot, and gates combine `contains` / `regex` / + * `lineRegex` / `all` / `any` / `not` matchers. Highest priority wins; ties keep + * the earlier rule. + */ + +import type { + AgentDetectionInput, + AgentDetectionResult, + AgentState, +} from '../../../../shared/types/agentStatus'; + +/** A boolean matcher over a region of text. Nestable via all/any/not. */ +export interface Gate { + /** Case-insensitive substrings; all must be present. */ + contains?: string[]; + /** Patterns; all must match the region text. */ + regex?: RegExp[]; + /** Patterns; each must match at least one line of the region. */ + lineRegex?: RegExp[]; + /** All nested gates must match. */ + all?: Gate[]; + /** At least one nested gate must match (when non-empty). */ + any?: Gate[]; + /** No nested gate may match. */ + not?: Gate[]; +} + +/** A single manifest rule: a gate plus the state it implies when matched. */ +export interface ManifestRule extends Gate { + id: string; + state: AgentState; + priority: number; + /** Region spec, e.g. `osc_title`, `whole_recent`, `bottom_non_empty_lines(3)`. */ + region: string; + /** When matched, hold the previously known state instead of adopting `state`. */ + skipStateUpdate?: boolean; + visibleBlocker?: boolean; + visibleWorking?: boolean; + visibleIdle?: boolean; +} + +export interface AgentManifest { + id: string; + rules: ManifestRule[]; +} + +// --------------------------------------------------------------------------- +// Region extraction +// --------------------------------------------------------------------------- + +function isHorizontalRule(line: string): boolean { + const trimmed = line.trim(); + if (trimmed.length === 0) return false; + let ruleChars = 0; + for (const ch of trimmed) { + if (ch === '─') ruleChars += 1; + else break; + } + if (ruleChars === 0) return false; + const suffix = Array.from(trimmed).slice(ruleChars).join('').trimStart(); + return suffix.length === 0 || ruleChars >= 3; +} + +function isCodexPromptLine(line: string): boolean { + return line === '›' || line.startsWith('› '); +} + +/** Join lines [start..end) preserving the original newline layout. */ +function joinLines(lines: string[], start: number, end: number = lines.length): string { + return lines.slice(start, end).join('\n'); +} + +function bottomNonEmptyLines(content: string, count: number): string { + const lines = content.split('\n'); + let seen = 0; + let startIndex = -1; + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (lines[i].trim().length > 0) { + seen += 1; + startIndex = i; + if (seen === count) break; + } + } + if (startIndex === -1) return ''; + return joinLines(lines, startIndex); +} + +function afterLastHorizontalRule(content: string): string { + const lines = content.split('\n'); + let lastRuleIndex = -1; + for (let i = 0; i < lines.length; i += 1) { + if (isHorizontalRule(lines[i])) lastRuleIndex = i; + } + if (lastRuleIndex === -1) return content; + return joinLines(lines, lastRuleIndex + 1); +} + +function afterLastPromptMarker(content: string): string { + const lines = content.split('\n'); + let index = -1; + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (isCodexPromptLine(lines[i])) { + index = i; + break; + } + } + if (index === -1) return content; + return joinLines(lines, index + 1); +} + +/** Index of the prompt box's top border: the 2nd horizontal rule from the bottom. */ +function promptBoxTopBorderIndex(lines: string[]): number { + let borderCount = 0; + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (isHorizontalRule(lines[i])) { + borderCount += 1; + if (borderCount === 2) return i; + } + } + return -1; +} + +function promptBoxBody(content: string): string { + const lines = content.split('\n'); + const top = promptBoxTopBorderIndex(lines); + if (top === -1) return ''; + let end = lines.length; + for (let i = top + 1; i < lines.length; i += 1) { + if (isHorizontalRule(lines[i])) { + end = i; + break; + } + } + return joinLines(lines, top + 1, end); +} + +function regionCount(spec: string, name: string): number | null { + const prefix = `${name}(`; + if (!spec.startsWith(prefix) || !spec.endsWith(')')) return null; + const inner = spec.slice(prefix.length, -1); + if (!/^\d+$/.test(inner)) return null; + return Number.parseInt(inner, 10); +} + +/** Extract the text a rule's `region` spec points at. Unknown specs → "". */ +export function extractRegion(input: AgentDetectionInput, spec: string): string { + const trimmed = spec.trim(); + switch (trimmed) { + case 'osc_title': + return input.oscTitle; + case 'osc_progress': + return input.oscProgress; + case 'whole_recent': + return input.screen; + case 'after_last_horizontal_rule': + return afterLastHorizontalRule(input.screen); + case 'after_last_prompt_marker': + return afterLastPromptMarker(input.screen); + case 'prompt_box_body': + return promptBoxBody(input.screen); + default: { + const nonEmpty = regionCount(trimmed, 'bottom_non_empty_lines'); + if (nonEmpty !== null) return bottomNonEmptyLines(input.screen, nonEmpty); + return ''; + } + } +} + +// --------------------------------------------------------------------------- +// Gate / rule matching +// --------------------------------------------------------------------------- + +function gateMatches(gate: Gate, text: string, lowerText: string): boolean { + if (gate.contains && !gate.contains.every((needle) => lowerText.includes(needle.toLowerCase()))) { + return false; + } + if (gate.regex && !gate.regex.every((re) => re.test(text))) { + return false; + } + if (gate.lineRegex) { + const lines = text.split('\n'); + if (!gate.lineRegex.every((re) => lines.some((line) => re.test(line)))) { + return false; + } + } + if (gate.all && !gate.all.every((nested) => gateMatches(nested, text, lowerText))) { + return false; + } + if (gate.any && gate.any.length > 0 && !gate.any.some((nested) => gateMatches(nested, text, lowerText))) { + return false; + } + if (gate.not && gate.not.some((nested) => gateMatches(nested, text, lowerText))) { + return false; + } + return true; +} + +/** True when a rule's gate matches the given region text. */ +export function ruleMatches(rule: ManifestRule, text: string): boolean { + return gateMatches(rule, text, text.toLowerCase()); +} + +// --------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------- + +const IDLE_FALLBACK: AgentDetectionResult = { + state: 'idle', + visibleBlocker: false, + visibleWorking: false, + visibleIdle: false, + skipStateUpdate: false, + matchedRuleId: null, +}; + +/** + * Evaluate a manifest against a snapshot. Returns the highest-priority matching + * rule's state (ties resolved to the earlier rule); with no match, a known agent + * falls back to `idle`. + */ +export function detectAgentState( + manifest: AgentManifest, + input: AgentDetectionInput, +): AgentDetectionResult { + let winner: ManifestRule | null = null; + for (const rule of manifest.rules) { + if (!ruleMatches(rule, extractRegion(input, rule.region))) continue; + if (winner === null || rule.priority > winner.priority) { + winner = rule; + } + } + + if (winner === null) return { ...IDLE_FALLBACK }; + + return { + state: winner.state, + visibleBlocker: Boolean(winner.visibleBlocker) && winner.state === 'blocked', + visibleWorking: Boolean(winner.visibleWorking) && winner.state === 'working', + visibleIdle: Boolean(winner.visibleIdle) && winner.state === 'idle', + skipStateUpdate: Boolean(winner.skipStateUpdate), + matchedRuleId: winner.id, + }; +} diff --git a/main/src/services/agentStatus/manifests.test.ts b/main/src/services/agentStatus/manifests.test.ts new file mode 100644 index 00000000..01a33e5c --- /dev/null +++ b/main/src/services/agentStatus/manifests.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { detectAgentState } from './manifestEngine'; +import { + CLAUDE_MANIFEST, + CODEX_MANIFEST, + GENERIC_MANIFEST, + getManifestForAgent, +} from './manifests'; + +const screen = (s: string, oscTitle = '', oscProgress = '') => ({ screen: s, oscTitle, oscProgress }); + +describe('getManifestForAgent', () => { + it('resolves bespoke manifests and generic fallback', () => { + expect(getManifestForAgent('claude')).toBe(CLAUDE_MANIFEST); + expect(getManifestForAgent('codex')).toBe(CODEX_MANIFEST); + expect(getManifestForAgent('aider')).toBe(GENERIC_MANIFEST); + expect(getManifestForAgent(undefined)).toBeNull(); + expect(getManifestForAgent(null)).toBeNull(); + }); +}); + +describe('CLAUDE_MANIFEST', () => { + it('classifies a bash permission prompt as blocked', () => { + const s = [ + '● I will run a command', + '', + 'Bash command', + ' ls -la', + '', + 'Do you want to proceed?', + '❯ 1. Yes', + ' 2. No, and tell Claude what to do differently (esc)', + ].join('\n'); + const r = detectAgentState(CLAUDE_MANIFEST, screen(s)); + expect(r.state).toBe('blocked'); + expect(r.visibleBlocker).toBe(true); + }); + + it('classifies a generic permission prompt after a rule as blocked', () => { + const s = [ + 'context', + '──────────────────────', + 'Do you want to proceed?', + '❯ 1. Yes', + ' 2. No (esc to cancel)', + ].join('\n'); + const r = detectAgentState(CLAUDE_MANIFEST, screen(s)); + expect(r.state).toBe('blocked'); + }); + + it('classifies an empty prompt box as idle via live_prompt_box', () => { + const s = ['some prior output', '────────────', ' ❯ ', '────────────'].join('\n'); + const r = detectAgentState(CLAUDE_MANIFEST, screen(s)); + expect(r.state).toBe('idle'); + expect(r.matchedRuleId).toBe('live_prompt_box'); + }); + + it('detects working from a braille-spinner OSC title', () => { + const r = detectAgentState(CLAUDE_MANIFEST, screen('', '⠙ Building the thing')); + expect(r.state).toBe('working'); + expect(r.visibleWorking).toBe(true); + }); + + it('detects idle from the ✳ OSC title', () => { + const r = detectAgentState(CLAUDE_MANIFEST, screen('', '✳ Ready')); + expect(r.state).toBe('idle'); + }); + + it('holds prior state on the transcript viewer', () => { + const s = ['Showing detailed transcript (ctrl+o to toggle)'].join('\n'); + const r = detectAgentState(CLAUDE_MANIFEST, screen(s)); + expect(r.skipStateUpdate).toBe(true); + expect(r.matchedRuleId).toBe('transcript_viewer'); + }); +}); + +describe('CODEX_MANIFEST', () => { + it('classifies the Action Required title as blocked', () => { + const r = detectAgentState(CODEX_MANIFEST, screen('working on it', 'Action Required · Codex')); + expect(r.state).toBe('blocked'); + expect(r.visibleBlocker).toBe(true); + }); + + it('detects working from a codex spinner OSC title', () => { + const r = detectAgentState(CODEX_MANIFEST, screen('', '⠹ Codex')); + expect(r.state).toBe('working'); + }); + + it('classifies an allow-command prompt as blocked', () => { + const s = ['Codex wants to run a command', 'allow command?', ' Yes No'].join('\n'); + const r = detectAgentState(CODEX_MANIFEST, screen(s)); + expect(r.state).toBe('blocked'); + }); + + it('classifies a [y/n] weak blocker as blocked', () => { + const r = detectAgentState(CODEX_MANIFEST, screen('Continue? [y/n]')); + expect(r.state).toBe('blocked'); + }); + + it('detects working from the "Working (… esc to interrupt)" status line', () => { + const s = ['some output', '• Working (5s • esc to interrupt) · thinking'].join('\n'); + const r = detectAgentState(CODEX_MANIFEST, screen(s)); + expect(r.state).toBe('working'); + }); + + it('classifies a plain title as idle', () => { + const r = detectAgentState(CODEX_MANIFEST, screen('', 'Codex')); + expect(r.state).toBe('idle'); + }); +}); + +describe('GENERIC_MANIFEST', () => { + it('detects a y/n prompt as blocked and a bare prompt as idle', () => { + expect(detectAgentState(GENERIC_MANIFEST, screen('Overwrite file? (y/n)')).state).toBe('blocked'); + expect(detectAgentState(GENERIC_MANIFEST, screen('$ ')).state).toBe('idle'); + }); +}); diff --git a/main/src/services/agentStatus/manifests.ts b/main/src/services/agentStatus/manifests.ts new file mode 100644 index 00000000..7239db9a --- /dev/null +++ b/main/src/services/agentStatus/manifests.ts @@ -0,0 +1,295 @@ +/** + * Per-agent status-detection manifests for Pane's at-a-glance agent status. + * + * Each manifest is a priority-ordered rule set consumed by {@link detectAgentState} + * in manifestEngine. Rules classify a pane's live terminal snapshot as `blocked` + * (waiting on the human), `working`, or `idle`; `unknown` + skipStateUpdate marks + * agent-owned viewers (transcript/model picker) so the previously known state is + * held. Working is also corroborated by PTY byte-activity in the monitor. + * + * Rules encode the visible chrome each CLI agent renders (permission prompts, + * spinners, prompt boxes) so classification is derived from what the user would + * see on screen, not from process-level guesswork. + */ + +import type { AgentManifest } from './manifestEngine'; + +/** Braille spinner glyphs Claude/Codex animate in their OSC title / status line. */ +const SPINNER_TITLE = /^[\u{2800}-\u{28FF}] /u; +const CODEX_SPINNER = /(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)/u; + +export const CLAUDE_MANIFEST: AgentManifest = { + id: 'claude', + rules: [ + { + id: 'osc_title_working', + state: 'working', + priority: 1100, + region: 'osc_title', + visibleWorking: true, + regex: [SPINNER_TITLE], + }, + { + id: 'btw_overlay_working', + state: 'working', + priority: 975, + region: 'bottom_non_empty_lines(5)', + visibleWorking: true, + lineRegex: [/^\s*\/btw(?:\s|$)/, /esc to close\s*$/i], + }, + { + id: 'transcript_viewer', + state: 'unknown', + priority: 1000, + region: 'bottom_non_empty_lines(3)', + skipStateUpdate: true, + contains: ['showing detailed transcript'], + any: [ + { contains: ['ctrl+o', 'to toggle'] }, + { contains: ['ctrl+e', 'show all'] }, + { contains: ['ctrl+e', 'collapse'] }, + { contains: ['↑↓ scroll'] }, + { contains: ['? for shortcuts'] }, + ], + }, + { + id: 'live_blocked_form', + state: 'blocked', + priority: 980, + region: 'after_last_horizontal_rule', + visibleBlocker: true, + contains: ['enter to select', 'esc to cancel'], + any: [ + { contains: ['tab/arrow keys to navigate'] }, + { contains: ['arrow keys to navigate'] }, + { contains: ['arrows to navigate'] }, + { contains: ['↑/↓ to navigate'] }, + { contains: ['↑↓ to navigate'] }, + ], + }, + { + id: 'dynamic_workflow_prompt', + state: 'blocked', + priority: 980, + region: 'whole_recent', + visibleBlocker: true, + contains: ['run a dynamic workflow?', 'esc to cancel'], + }, + { + id: 'live_prompt_box', + state: 'idle', + priority: 950, + region: 'prompt_box_body', + visibleIdle: true, + lineRegex: [/^\s*❯/], + not: [ + { contains: ['enter to select'] }, + { contains: ['esc to cancel'] }, + { contains: ['tab/arrow keys'] }, + { contains: ['arrow keys to navigate'] }, + { contains: ['↑/↓ to navigate'] }, + ], + }, + { + id: 'model_picker_menu', + state: 'unknown', + priority: 900, + region: 'whole_recent', + skipStateUpdate: true, + contains: ['select model', 'enter to set as default', 'esc to cancel'], + not: [{ contains: ['do you want to proceed?'] }, { contains: ['enter to select'] }], + }, + { + id: 'bash_permission_prompt', + state: 'blocked', + priority: 850, + region: 'whole_recent', + visibleBlocker: true, + contains: ['do you want to proceed?'], + any: [ + { contains: ['bash command'] }, + { contains: ['bash('] }, + { contains: ['contains expansion'] }, + { contains: ['tab to amend'] }, + { contains: ['ctrl+e to explain'] }, + ], + all: [ + { + any: [ + { lineRegex: [/^\s*❯?\s*yes\b/i] }, + { lineRegex: [/^\s*1\.\s*yes\b/i] }, + { lineRegex: [/^\s*2\.\s*no\b/i] }, + ], + }, + ], + }, + { + id: 'generic_permission_prompt', + state: 'blocked', + priority: 840, + region: 'after_last_horizontal_rule', + visibleBlocker: true, + contains: ['do you want to proceed?', 'esc to cancel'], + all: [ + { + any: [ + { lineRegex: [/^\s*❯?\s*1\.\s*yes\b/i] }, + { lineRegex: [/^\s*2\.\s*yes\b/i] }, + { lineRegex: [/^\s*2\.\s*no\b/i] }, + { lineRegex: [/^\s*3\.\s*no\b/i] }, + ], + }, + ], + }, + { + id: 'legacy_no_prompt_blocker', + state: 'blocked', + priority: 300, + region: 'whole_recent', + any: [ + { contains: ['do you want to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, + { contains: ['would you like to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, + { contains: ['waiting for permission'] }, + { contains: ['do you want to allow this connection?'] }, + { contains: ['tab to amend'] }, + { contains: ['ctrl+e to explain'] }, + { contains: ['do you want to proceed?', 'esc to cancel'] }, + { contains: ['review your answers'] }, + { contains: ['skip interview and plan immediately'] }, + ], + not: [{ regex: [/^\s*❯\s*$/m] }], + }, + { + id: 'osc_title_idle', + state: 'idle', + priority: 250, + region: 'osc_title', + visibleIdle: true, + regex: [/^\u{2733} /u], + }, + { + id: 'osc_progress_idle', + state: 'idle', + priority: 250, + region: 'osc_progress', + regex: [/^4;0/], + }, + ], +}; + +export const CODEX_MANIFEST: AgentManifest = { + id: 'codex', + rules: [ + { + id: 'osc_title_blocked', + state: 'blocked', + priority: 1100, + region: 'osc_title', + visibleBlocker: true, + contains: ['Action Required'], + }, + { + id: 'osc_title_working', + state: 'working', + priority: 1050, + region: 'osc_title', + visibleWorking: true, + regex: [CODEX_SPINNER], + }, + { + id: 'transcript_viewer', + state: 'unknown', + priority: 1000, + region: 'after_last_prompt_marker', + skipStateUpdate: true, + contains: ['↑/↓ to scroll', 'pgup/pgdn to', 'home/end to jump', 'q to quit'], + any: [{ contains: ['esc to edit prev'] }, { contains: ['esc/← to edit prev'] }], + }, + { + id: 'live_strong_blocker', + state: 'blocked', + priority: 900, + region: 'after_last_prompt_marker', + visibleBlocker: true, + any: [ + { contains: ['press enter to confirm or esc to cancel'] }, + { contains: ['enter to submit answer'] }, + { contains: ['enter to submit all'] }, + { contains: ['allow command?'] }, + ], + }, + { + id: 'weak_blocker', + state: 'blocked', + priority: 600, + region: 'whole_recent', + any: [ + { contains: ['[y/n]'] }, + { contains: ['yes (y)'] }, + { contains: ['do you want to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, + { contains: ['would you like to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, + ], + }, + { + id: 'screen_working_fallback', + state: 'working', + priority: 500, + region: 'bottom_non_empty_lines(3)', + visibleWorking: true, + lineRegex: [/^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$/], + not: [{ contains: ['■ Conversation interrupted'] }], + }, + { + id: 'osc_title_idle', + state: 'idle', + priority: 100, + region: 'osc_title', + visibleIdle: true, + regex: [/\S/], + not: [{ regex: [CODEX_SPINNER] }, { contains: ['Action Required'] }], + }, + ], +}; + +/** + * Cross-agent fallback for CLI agents without a bespoke manifest. Detects the + * common permission-prompt shapes; working/idle otherwise come from PTY activity + * and the idle fallback. + */ +export const GENERIC_MANIFEST: AgentManifest = { + id: 'generic', + rules: [ + { + id: 'generic_permission_prompt', + state: 'blocked', + priority: 600, + region: 'whole_recent', + any: [ + { contains: ['do you want to proceed?'] }, + { contains: ['[y/n]'] }, + { contains: ['(y/n)'] }, + { contains: ['press enter to confirm'] }, + { contains: ['allow command?'] }, + { contains: ['waiting for permission'] }, + { contains: ['do you want to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, + { contains: ['would you like to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, + ], + not: [{ regex: [/^\s*❯\s*$/m] }], + }, + ], +}; + +const MANIFESTS_BY_AGENT: Record = { + claude: CLAUDE_MANIFEST, + codex: CODEX_MANIFEST, +}; + +/** + * Resolve the manifest for a panel's agent type. Known agents get their bespoke + * manifest; any other CLI agent id gets the generic fallback. Returns null when + * there is no agent (plain shell) so the caller can skip detection entirely. + */ +export function getManifestForAgent(agentType: string | undefined | null): AgentManifest | null { + if (!agentType) return null; + return MANIFESTS_BY_AGENT[agentType] ?? GENERIC_MANIFEST; +} diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 475480d1..e4e27c44 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -18,6 +18,10 @@ import { onPtyBytes as flowControlOnPtyBytes, } from '../ptyHost/flowControl'; import { TerminalStateEmulator } from './terminalStateEmulator'; +import { AgentStatusMonitor } from './agentStatus/agentStatusMonitor'; +import { detectAgentState } from './agentStatus/manifestEngine'; +import { getManifestForAgent } from './agentStatus/manifests'; +import type { AgentState, PanelAgentStatusEvent } from '../../../shared/types/agentStatus'; const OUTPUT_BATCH_INTERVAL = 32; // ms (~30fps) — wider window reduces TUI flicker const OUTPUT_BATCH_INTERVAL_HIDDEN = 250; // ms — background / hidden cadence to cut IPC wake-up cost @@ -25,6 +29,7 @@ const OUTPUT_BATCH_SIZE = 131072; // 128KB — timer-based flush preferred; size const OUTPUT_BATCH_SIZE_HIDDEN = 80_000; // 80KB — cap hidden flush size to avoid foreground backpressure churn const MAX_CONCURRENT_SPAWNS = 3; const IDLE_THRESHOLD_MS = 30_000; // 30s — mark panel idle after no PTY output +const AGENT_STATUS_POLL_MS = 500; // cadence for re-deriving blocked/working/done from the live screen const MAX_SCROLLBACK_BUFFER_SIZE = 500_000; // 500KB of normal shell history const MAX_ALTERNATE_SCREEN_BUFFER_SIZE = 100_000; // 100KB of recent TUI redraw state const MIN_PTY_COLS = 20; @@ -195,6 +200,8 @@ interface TerminalProcess { isAlternateScreen: boolean; activityStatus: 'active' | 'idle'; idleTimer: ReturnType | null; + /** CLI agent driving this panel, when any — selects the status-detection manifest. */ + agentType?: CliAgentType; // DEC Mode 2026 synchronized-output block tracking — persists across chunks inSyncBlock: boolean; codexAgentSessionId?: string; @@ -212,6 +219,11 @@ export class TerminalPanelManager { private activeSpawns = 0; private spawnQueue: Array<{ resolve: () => void; priority: number }> = []; + // At-a-glance agent status (blocked/working/done) for AI/CLI panels. + private readonly agentStatusMonitor = new AgentStatusMonitor(); + private agentStatusPollTimer: ReturnType | null = null; + private agentStatusPolling = false; + private getCliAgentType(command?: string): CliAgentType | undefined { const lower = command?.toLowerCase() ?? ''; if (lower.includes('claude')) return 'claude'; @@ -939,12 +951,16 @@ export class TerminalPanelManager { activityStatus: 'idle', idleTimer: null, inSyncBlock: false, + agentType: this.resolveTerminalAgentType(panel.state.customState as TerminalPanelState | undefined), codexResumeOutputBuffer: '' }; // Store in map (ptyHost path: pid is already populated on the shim). this.terminals.set(panel.id, terminalProcess); + // Begin at-a-glance status detection for AI/CLI agent panels. + this.registerAgentStatusPanel(terminalProcess); + // Tell the renderer which `ptyId` to subscribe to for this panel so // `TerminalPanel.tsx` can use `electronAPI.ptyHost.onData(ptyId, ...)` // under the flag. Flag-off path skips this: the renderer keeps using @@ -1139,6 +1155,9 @@ export class TerminalPanelManager { this.emitActivityStatus(terminal); }, IDLE_THRESHOLD_MS); + // Feed PTY activity to the agent-status monitor (the "working" authority). + this.agentStatusMonitor.noteActivity(terminal.panelId, outputAt.getTime()); + // Detect alternate screen buffer enter/exit for universal TUI detection // (works on WSL where pty.process reports wsl.exe instead of the Linux foreground app) // \x1b[?1049h = enter alternate screen, \x1b[?1049l = leave alternate screen @@ -1235,6 +1254,13 @@ export class TerminalPanelManager { this.emitActivityStatus(terminal); } + // A finished agent is "done": settle its status to idle and stop tracking. + if (this.agentStatusMonitor.isTracked(terminal.panelId)) { + this.emitAgentStatus(terminal, 'idle', 'exit'); + this.agentStatusMonitor.unregister(terminal.panelId); + this.maybeStopAgentStatusPoll(); + } + // Emit exit event panelManager.emitPanelEvent( terminal.panelId, @@ -1583,6 +1609,77 @@ export class TerminalPanelManager { }); } + // ---- At-a-glance agent status (blocked / working / done) ---------------- + + /** Resolve the CLI agent driving a panel from its custom state / command. */ + private resolveTerminalAgentType( + customState: TerminalPanelState | undefined, + ): CliAgentType | undefined { + return customState?.agentType ?? this.getCliAgentType(customState?.initialCommand); + } + + /** Start status detection for an AI/CLI agent panel (no-op for plain shells). */ + private registerAgentStatusPanel(terminal: TerminalProcess): void { + if (!getManifestForAgent(terminal.agentType)) return; + this.agentStatusMonitor.register(terminal.panelId, Date.now()); + this.ensureAgentStatusPoll(); + } + + private emitAgentStatus(terminal: TerminalProcess, state: AgentState, reason: string | null): void { + const payload: PanelAgentStatusEvent = { + panelId: terminal.panelId, + sessionId: terminal.sessionId, + state, + reason, + }; + this.sendRendererEvent('panel:agentStatus', payload); + } + + private ensureAgentStatusPoll(): void { + if (this.agentStatusPollTimer) return; + this.agentStatusPollTimer = setInterval(() => { + void this.pollAgentStatus(); + }, AGENT_STATUS_POLL_MS); + } + + private maybeStopAgentStatusPoll(): void { + if (this.agentStatusPollTimer && this.agentStatusMonitor.size === 0) { + clearInterval(this.agentStatusPollTimer); + this.agentStatusPollTimer = null; + } + } + + /** + * Re-derive blocked/working/done for every tracked agent panel from its live + * screen + OSC title, and emit `panel:agentStatus` on any change. Runs on a + * short interval; a guard prevents overlapping passes. + */ + private async pollAgentStatus(): Promise { + if (this.agentStatusPolling) return; + this.agentStatusPolling = true; + try { + for (const terminal of this.terminals.values()) { + if (!this.agentStatusMonitor.isTracked(terminal.panelId)) continue; + const manifest = getManifestForAgent(terminal.agentType); + const emulator = terminal.screenEmulator; + if (!manifest || !emulator) continue; + + await emulator.waitForIdle(); + const detection = detectAgentState(manifest, { + screen: emulator.getScreenText(), + oscTitle: emulator.getOscTitle(), + oscProgress: emulator.getOscProgress(), + }); + const next = this.agentStatusMonitor.update(terminal.panelId, detection, Date.now()); + if (next) this.emitAgentStatus(terminal, next, detection.matchedRuleId); + } + } catch (error) { + console.error('[TerminalPanelManager] agent status poll failed:', error); + } finally { + this.agentStatusPolling = false; + } + } + destroyTerminal(panelId: string): void { const terminal = this.terminals.get(panelId); if (!terminal) { @@ -1624,6 +1721,8 @@ export class TerminalPanelManager { this.terminals.delete(panelId); this.visibleViewersByPanel.delete(panelId); this.serializedBuffers.delete(panelId); + this.agentStatusMonitor.unregister(panelId); + this.maybeStopAgentStatusPoll(); } /** diff --git a/main/src/services/terminalStateEmulator.test.ts b/main/src/services/terminalStateEmulator.test.ts index 0a39e1b8..31a99967 100644 --- a/main/src/services/terminalStateEmulator.test.ts +++ b/main/src/services/terminalStateEmulator.test.ts @@ -59,4 +59,29 @@ describe('TerminalStateEmulator', () => { expect(serialized).toContain('\x1b[?2004h'); emulator.dispose(); }); + + it('captures the OSC window title and updates it as it changes', async () => { + const emulator = new TerminalStateEmulator(20, 5); + expect(emulator.getOscTitle()).toBe(''); + + emulator.write('\x1b]2;⠙ Claude\x07body'); + await emulator.waitForIdle(); + expect(emulator.getOscTitle()).toBe('⠙ Claude'); + + emulator.write('\x1b]2;✳ Ready\x07'); + await emulator.waitForIdle(); + expect(emulator.getOscTitle()).toBe('✳ Ready'); + + emulator.dispose(); + // Preserved after dispose, like screen text. + expect(emulator.getOscTitle()).toBe('✳ Ready'); + }); + + it('captures OSC title set via the OSC 0 form', async () => { + const emulator = new TerminalStateEmulator(20, 5); + emulator.write('\x1b]0;Action Required · Codex\x07'); + await emulator.waitForIdle(); + expect(emulator.getOscTitle()).toBe('Action Required · Codex'); + emulator.dispose(); + }); }); diff --git a/main/src/services/terminalStateEmulator.ts b/main/src/services/terminalStateEmulator.ts index de823ce0..b029dff5 100644 --- a/main/src/services/terminalStateEmulator.ts +++ b/main/src/services/terminalStateEmulator.ts @@ -17,6 +17,8 @@ export class TerminalStateEmulator { private finalIsAlternateScreen = false; private finalSerializedBuffer = ''; private finalScreenText = ''; + private currentTitle = ''; + private currentProgress = ''; constructor(cols: number, rows: number) { this.terminal = new Terminal({ @@ -26,6 +28,16 @@ export class TerminalStateEmulator { allowProposedApi: true, }); this.terminal.loadAddon(this.serializeAddon); + // Capture OSC window/icon title (OSC 0 / OSC 2) — agents encode live status + // (spinner, "Action Required") into it, which the status detector reads. + this.terminal.onTitleChange((title) => { + this.currentTitle = title; + }); + // Capture OSC 9;4 progress payloads (e.g. "4;0") where terminals emit them. + this.terminal.parser.registerOscHandler(9, (data) => { + this.currentProgress = data; + return false; // allow other handlers to also process + }); } write(data: string): void { @@ -84,6 +96,16 @@ export class TerminalStateEmulator { return lines.join('\n'); } + /** Latest OSC window/icon title, preserved after dispose. */ + getOscTitle(): string { + return this.currentTitle; + } + + /** Latest OSC 9;4 progress payload (e.g. "4;0"), preserved after dispose. */ + getOscProgress(): string { + return this.currentProgress; + } + clearScrollback(): void { this.terminal.clear(); } diff --git a/shared/types/agentStatus.ts b/shared/types/agentStatus.ts new file mode 100644 index 00000000..c578a7ba --- /dev/null +++ b/shared/types/agentStatus.ts @@ -0,0 +1,52 @@ +/** + * At-a-glance agent status types. + * + * The main process detects a raw {@link AgentState} per agent pane from its live + * terminal screen + OSC title. The renderer maps that to an + * {@link AgentDisplayStatus} for the sidebar, session list, and pane tabs, where + * a finished-but-unseen pane reads as `done` and a finished-and-seen pane as + * `idle`. + */ + +/** Raw state derived by the detection engine in the main process. */ +export type AgentState = 'blocked' | 'working' | 'idle' | 'unknown'; + +/** Status rendered in the UI. `done` = idle & not yet seen by the user. */ +export type AgentDisplayStatus = 'blocked' | 'working' | 'done' | 'idle' | 'unknown'; + +/** Snapshot fed to the detection engine for a single pane. */ +export interface AgentDetectionInput { + /** Plain-text of the terminal's visible viewport (bottom of buffer). */ + screen: string; + /** Current OSC window/icon title, or empty string when unavailable. */ + oscTitle: string; + /** Current OSC progress payload (e.g. `4;0`), or empty string. */ + oscProgress: string; +} + +/** Result of evaluating a manifest against an {@link AgentDetectionInput}. */ +export interface AgentDetectionResult { + state: AgentState; + /** The matched screen visibly shows live chrome needing human input. */ + visibleBlocker: boolean; + /** The matched screen visibly shows live working chrome. */ + visibleWorking: boolean; + /** The matched screen visibly shows live idle chrome. */ + visibleIdle: boolean; + /** + * The matched screen is an agent-owned viewer (transcript/history) rather than + * the live prompt state — callers should hold the previously known state. + */ + skipStateUpdate: boolean; + /** Id of the winning rule, or null when the idle fallback was used. */ + matchedRuleId: string | null; +} + +/** Payload of the `panel:agentStatus` IPC event. */ +export interface PanelAgentStatusEvent { + panelId: string; + sessionId: string; + state: AgentState; + /** Winning rule id or a short reason string, for debugging. */ + reason: string | null; +} From fafe19cdd7700c1196d61e073c3b4b6d1d24806f Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Wed, 5 Aug 2026 17:12:52 -0700 Subject: [PATCH 2/4] feat: unify agent status into the single status system - every terminal panel now runs status detection: bespoke manifests for Claude/Codex, a generic tier (PTY activity + common permission-prompt shapes) for other CLI agents and plain shells - retire the legacy activity system from all UI: session rows, pane tabs, project dots, and the label shimmer read the agent-status model only - rewire away-notifications to the same model: immediate "needs your input" on blocked, debounced "finished" on working -> idle - working state uses the theme's status-info token across dot, spinner, and accent bar; label shimmer pacing locked to the sweep cycle (2.8s) - status indicators render in a fixed footprint (no layout shift between dot and spinner); tab titles truncate so squeezed tabs don't crush them --- .../src/components/ProjectSessionList.tsx | 32 ++-- .../src/components/SessionStatusBadge.tsx | 19 +-- .../components/panels/PanelTabStatusDot.tsx | 19 +-- .../src/components/panels/PanelTabStrip.tsx | 9 +- frontend/src/components/ui/AgentStatusDot.tsx | 79 +++++++-- .../src/components/ui/StatusAccentBar.tsx | 4 +- .../components/ui/agentStatusVisual.test.ts | 2 +- .../src/components/ui/agentStatusVisual.ts | 5 +- frontend/src/hooks/useNotifications.ts | 161 +++++++++++------- frontend/src/index.css | 6 +- .../services/agentStatus/manifests.test.ts | 6 +- main/src/services/agentStatus/manifests.ts | 15 +- main/src/services/terminalPanelManager.ts | 9 +- 13 files changed, 219 insertions(+), 147 deletions(-) diff --git a/frontend/src/components/ProjectSessionList.tsx b/frontend/src/components/ProjectSessionList.tsx index 2a97373d..c42cc60c 100644 --- a/frontend/src/components/ProjectSessionList.tsx +++ b/frontend/src/components/ProjectSessionList.tsx @@ -9,9 +9,10 @@ import { AddProjectDialog } from './AddProjectDialog'; import { Dropdown } from './ui/Dropdown'; import { Tooltip } from './ui/Tooltip'; import { StatusAccentBar } from './ui/StatusAccentBar'; -import { AgentStatusDot } from './ui/AgentStatusDot'; +import { AgentActivityDot, AgentStatusDot } from './ui/AgentStatusDot'; import type { DropdownItem } from './ui/Dropdown'; import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus'; +import { rollupAgentState, rollupSessionAgentState } from '../utils/agentStatus'; import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; import { API } from '../utils/api'; import { cn } from '../utils/cn'; @@ -81,8 +82,8 @@ export function ProjectSessionList({ const expandedProjects = useNavigationStore(s => s.expandedProjects); const toggleProjectExpanded = useNavigationStore(s => s.toggleProjectExpanded); const expandProject = useNavigationStore(s => s.expandProject); - const panelPanels = usePanelStore(s => s.panels); - const panelActivityStatus = usePanelStore(s => s.activityStatus); + const agentStatusByPanel = usePanelStore(s => s.agentStatus); + const agentPanelSessions = usePanelStore(s => s.agentStatusSession); // Load projects const loadProjects = useCallback(async () => { @@ -410,10 +411,10 @@ export function ProjectSessionList({ const isExpanded = expandedProjects.has(project.id); const projectSessions = sessionsByProject.get(project.id) || []; - const projectActivity = projectSessions.some(s => { - const sessionPanels = panelPanels[s.id] || []; - return sessionPanels.some(p => panelActivityStatus[p.id] === 'active'); - }) ? 'active' : 'idle'; + // Agent status rolled up across the project's sessions (blocked > working > idle). + const projectAgentState = rollupAgentState( + projectSessions.map(s => rollupSessionAgentState(agentStatusByPanel, agentPanelSessions, s.id)) + ); const projectMenuItems: DropdownItem[] = [ { @@ -467,12 +468,11 @@ export function ProjectSessionList({
{project.name} - + {projectAgentState === 'unknown' ? ( + + ) : ( + + )}
(session.gitStatus); const initialGitStatusRequestRef = useRef(null); - const sessionActivity = usePanelStore(s => { - const sessionPanels = s.panels[session.id] || []; - return sessionPanels.some(p => s.activityStatus[p.id] === 'active') ? 'active' : 'idle'; - }); const hasUnviewedCompletedActivity = usePanelStore(s => Boolean(s.unviewedCompletedActivity[session.id])); const agentDisplayStatus = useSessionAgentDisplayStatus(session.id); @@ -728,7 +724,7 @@ function SessionRow({ const adds = (gs?.commitAdditions ?? 0) + (gs?.additions ?? 0); const dels = (gs?.commitDeletions ?? 0) + (gs?.deletions ?? 0); const hasDiff = adds > 0 || dels > 0; - const showActivity = sessionActivity === 'active'; + const showActivity = agentDisplayStatus === 'working'; const accessibleName = displayName || gs?.prTitle || session.name || 'Untitled'; return ( diff --git a/frontend/src/components/SessionStatusBadge.tsx b/frontend/src/components/SessionStatusBadge.tsx index 5589048d..5599737f 100644 --- a/frontend/src/components/SessionStatusBadge.tsx +++ b/frontend/src/components/SessionStatusBadge.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { usePanelStore } from '../stores/panelStore'; import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus'; -import { AgentStatusDot } from './ui/AgentStatusDot'; +import { AgentActivityDot, AgentStatusDot } from './ui/AgentStatusDot'; interface SessionStatusBadgeProps { sessionId: string; @@ -9,22 +8,16 @@ interface SessionStatusBadgeProps { } /** - * Session dot for the sidebar / session list. Shows the herd-of-agents status - * (blocked / working / done / idle) when the session has AI/CLI panels; for - * sessions with only plain shells it falls back to the legacy active/idle dot. + * Session dot for the sidebar / session list: the herd-of-agents status + * (blocked / working / done / idle) rolled up over the session's terminal + * panels. `unknown` means no terminal panel has reported yet (or the session + * has none); an inert placeholder holds the dot's footprint. */ export const SessionStatusBadge: React.FC = ({ sessionId, size = 'md' }) => { const displayStatus = useSessionAgentDisplayStatus(sessionId); - const isActive = usePanelStore((s) => s.getSessionActivityStatus(sessionId) === 'active'); if (displayStatus === 'unknown') { - // No agent panels — preserve the original binary activity indicator. - const color = isActive - ? 'bg-status-warning opacity-100 duration-150' - : 'bg-text-muted/20 opacity-40 duration-[3s]'; - return ( -
- ); + return ; } return ; diff --git a/frontend/src/components/panels/PanelTabStatusDot.tsx b/frontend/src/components/panels/PanelTabStatusDot.tsx index 16a56f5b..3894d3d0 100644 --- a/frontend/src/components/panels/PanelTabStatusDot.tsx +++ b/frontend/src/components/panels/PanelTabStatusDot.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { cn } from '../../utils/cn'; -import { usePanelStore } from '../../stores/panelStore'; import { usePanelAgentDisplayStatus } from '../../hooks/useAgentStatus'; -import { AgentStatusDot } from '../ui/AgentStatusDot'; +import { AgentActivityDot, AgentStatusDot } from '../ui/AgentStatusDot'; interface PanelTabStatusDotProps { panelId: string; @@ -11,22 +9,15 @@ interface PanelTabStatusDotProps { /** * Per-tab status dot: the agent status (blocked / working / done / idle) for - * AI/CLI panels, falling back to the legacy active/idle activity dot for plain - * terminal panels. + * any terminal panel — bespoke detection for known agents, the generic tier + * otherwise. `unknown` only occurs before the first status emission, so it + * renders an inert placeholder that just holds the dot's footprint. */ export const PanelTabStatusDot: React.FC = ({ panelId, sessionId }) => { const displayStatus = usePanelAgentDisplayStatus(panelId, sessionId); - const isActive = usePanelStore((s) => s.activityStatus[panelId] === 'active'); if (displayStatus === 'unknown') { - return ( - - ); + return ; } return ; diff --git a/frontend/src/components/panels/PanelTabStrip.tsx b/frontend/src/components/panels/PanelTabStrip.tsx index 10178402..0166c2ac 100644 --- a/frontend/src/components/panels/PanelTabStrip.tsx +++ b/frontend/src/components/panels/PanelTabStrip.tsx @@ -447,10 +447,13 @@ export const PanelTabStrip: React.FC = React.memo(({ {panel.type === 'terminal' && ( )} - {getPanelIcon(panel.type, panel, compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} + {getPanelIcon(panel.type, panel, compact ? 'w-3.5 h-3.5 flex-shrink-0' : 'w-4 h-4 flex-shrink-0')} {/* Bold marks the primary group's strip: the group the top - bar's tool tabs and the un-split gesture belong to */} - {displayTitle} + bar's tool tabs and the un-split gesture belong to. + The title is the only shrinkable element in the tab, so + squeezed tabs truncate the text instead of crushing the + status dot / icon or spilling under the close button. */} + {displayTitle} )} diff --git a/frontend/src/components/ui/AgentStatusDot.tsx b/frontend/src/components/ui/AgentStatusDot.tsx index c56d4084..bd9cff3e 100644 --- a/frontend/src/components/ui/AgentStatusDot.tsx +++ b/frontend/src/components/ui/AgentStatusDot.tsx @@ -19,11 +19,54 @@ const spinnerSizeClasses = { md: 'w-3.5 h-3.5 border-2', }; +// Both variants render inside a container sized to the larger spinner, so the +// footprint stays constant and status flips cause no layout shift. +const containerSizeClasses = { + sm: 'w-3 h-3', + md: 'w-3.5 h-3.5', +}; + /** - * At-a-glance agent status indicator. Working renders as an amber spinner; blocked + * At-a-glance agent status indicator. Working renders as a blue spinner; blocked * (red), done (blue), and idle (green) render as a dot — the "dot + spinner" * variation. Renders nothing for `unknown` so non-agent panels show no badge. */ +interface AgentActivityDotProps { + active: boolean; + size?: 'sm' | 'md'; + /** Color of the active dot; idle is always the muted dot. */ + activeColorClass?: string; + /** Pulse while active. */ + pulse?: boolean; + className?: string; +} + +/** + * Legacy binary activity dot (active/idle) for panes without an agent, rendered + * at the same dot size and fixed footprint as AgentStatusDot so both indicator + * systems look identical and swap without layout shift. + */ +export const AgentActivityDot: React.FC = ({ + active, + size = 'md', + activeColorClass = 'bg-status-info', + pulse = false, + className, +}) => ( + + + +); + export const AgentStatusDot: React.FC = ({ status, size = 'md', className }) => { const visual = agentStatusVisual(status); if (!visual) return null; @@ -32,30 +75,36 @@ export const AgentStatusDot: React.FC = ({ status, size = ' // Amber ring spinner conveys active work more clearly than a pulsing dot. return ( + > + + ); } return ( + > + + ); }; diff --git a/frontend/src/components/ui/StatusAccentBar.tsx b/frontend/src/components/ui/StatusAccentBar.tsx index a5f64232..64a9a089 100644 --- a/frontend/src/components/ui/StatusAccentBar.tsx +++ b/frontend/src/components/ui/StatusAccentBar.tsx @@ -11,14 +11,14 @@ interface StatusAccentBarProps { const barColor: Record, string> = { blocked: 'bg-status-error', - working: 'bg-status-warning', + working: 'bg-status-info', done: 'bg-status-info', idle: 'bg-status-success', }; /** * The always-present left accent bar on a session row. It follows the at-a-glance - * agent status: red = blocked, amber (with an up/down loading sweep) = working, + * agent status: red = blocked, blue with an up/down loading sweep = working, * blue = done, green = idle. For rows with no tracked agent (`unknown`) it shows * the selection accent when active and nothing otherwise. */ diff --git a/frontend/src/components/ui/agentStatusVisual.test.ts b/frontend/src/components/ui/agentStatusVisual.test.ts index 7eaa2269..6a5a5dd3 100644 --- a/frontend/src/components/ui/agentStatusVisual.test.ts +++ b/frontend/src/components/ui/agentStatusVisual.test.ts @@ -4,7 +4,7 @@ import { agentStatusVisual } from './agentStatusVisual'; describe('agentStatusVisual', () => { it('maps each status to its token, label, and animation', () => { expect(agentStatusVisual('blocked')).toEqual({ colorClass: 'bg-status-error', label: 'blocked', animate: true }); - expect(agentStatusVisual('working')).toEqual({ colorClass: 'bg-status-warning', label: 'working', animate: true }); + expect(agentStatusVisual('working')).toEqual({ colorClass: 'bg-status-info', label: 'working', animate: true }); expect(agentStatusVisual('done')).toEqual({ colorClass: 'bg-status-info', label: 'done', animate: false }); expect(agentStatusVisual('idle')).toEqual({ colorClass: 'bg-status-success', label: 'idle', animate: false }); }); diff --git a/frontend/src/components/ui/agentStatusVisual.ts b/frontend/src/components/ui/agentStatusVisual.ts index 64c28279..63f09edc 100644 --- a/frontend/src/components/ui/agentStatusVisual.ts +++ b/frontend/src/components/ui/agentStatusVisual.ts @@ -11,7 +11,8 @@ export interface AgentStatusVisual { /** * Single source of truth for how an {@link AgentDisplayStatus} looks: blocked is - * red and pulses, working is amber and pulses, a freshly finished agent is a blue + * red and pulses, working is the info blue (matching the label shimmer) and + * pulses, a freshly finished agent is a blue * "done" cue, a seen-idle agent is calm green. `unknown` (no agent / plain shell) * returns null so callers render no badge. */ @@ -20,7 +21,7 @@ export function agentStatusVisual(status: AgentDisplayStatus): AgentStatusVisual case 'blocked': return { colorClass: 'bg-status-error', label: 'blocked', animate: true }; case 'working': - return { colorClass: 'bg-status-warning', label: 'working', animate: true }; + return { colorClass: 'bg-status-info', label: 'working', animate: true }; case 'done': return { colorClass: 'bg-status-info', label: 'done', animate: false }; case 'idle': diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 88ee8ae6..afbaa0da 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -4,6 +4,7 @@ import { usePanelStore } from '../stores/panelStore'; import { API } from '../utils/api'; import { useConfigStore } from '../stores/configStore'; import { ToolPanel } from '../../../shared/types/panels'; +import type { AgentState } from '../../../shared/types/agentStatus'; // Extend window interface for webkit audio context compatibility declare global { @@ -17,10 +18,11 @@ interface NotificationSettings { enabled: boolean; } -// Extra delay on top of the 30s PTY idle threshold before firing a "finished" -// notification. Guards against false positives from mid-task pauses: network -// waits, slow tool calls, shells sitting between commands. Total silent time -// before a notification fires is roughly 30s (dot flip) + 60s = 90s. +// How long a panel must stay idle after a working -> idle agent-status flip +// before a "finished" notification fires. The agent-status monitor settles to +// idle within ~1.3s of output stopping, so this debounce is what guards +// against mid-task pauses: network waits, slow tool calls, quiet builds. +// Re-activation (working or blocked) cancels the pending notification. const NOTIFICATION_DEBOUNCE_MS = 60_000; export function useNotifications() { @@ -65,12 +67,12 @@ export function useNotifications() { return unsubscribe; }, []); - // Track previous activityStatus per panelId to detect active -> idle transitions. - const prevActivityRef = useRef>({}); + // Track previous agentStatus per panelId to detect transitions. + const prevAgentStatusRef = useRef>({}); - // Pending notification timers per panelId. A panel must stay idle for - // NOTIFICATION_DEBOUNCE_MS after the 5s dot flip before we fire, so we - // don't ping on mid-task pauses (network waits, slow tool calls, shells + // Pending "finished" timers per panelId. A panel must stay idle for + // NOTIFICATION_DEBOUNCE_MS after the working -> idle flip before we fire, so + // we don't ping on mid-task pauses (network waits, slow tool calls, shells // sitting at a prompt between commands). Re-activation cancels the timer. const pendingIdleTimersRef = useRef>>(new Map()); @@ -163,6 +165,34 @@ export function useNotifications() { }); }, [playNotificationSound, requestPermission]); + /** Resolve a panel to its session + display names, or null when unknown. */ + function findPanelContext(panelId: string) { + const panelStoreState = usePanelStore.getState(); + let foundSessionId: string | undefined; + let foundPanel: ToolPanel | undefined; + for (const [sessionId, panels] of Object.entries(panelStoreState.panels)) { + const panel = panels.find((p) => p.id === panelId); + if (panel) { + foundSessionId = sessionId; + foundPanel = panel; + break; + } + } + if (!foundSessionId || !foundPanel) return null; + + const session = useSessionStore.getState().sessions.find((s) => s.id === foundSessionId); + if (!session) return null; + + const projectName = session.projectId + ? projectNamesRef.current.get(session.projectId) ?? '' + : ''; + return { + session, + panelName: foundPanel.title || 'Terminal', + body: projectName ? `${session.name} · ${projectName}` : session.name, + }; + } + function maybeNotifyPanelIdle(panelId: string, scheduledLastActivityAt?: string) { const currentSettings = settingsRef.current; if (!currentSettings.enabled) return; @@ -174,9 +204,9 @@ export function useNotifications() { const panelStoreState = usePanelStore.getState(); // Re-check idle at fire time. The debounced timer may fire right as the - // panel re-activates; without this check we'd ping "finished" for a - // panel that is actively running again. - if (panelStoreState.activityStatus[panelId] !== 'idle') return; + // panel re-activates (working) or hits a prompt (blocked); without this + // check we'd ping "finished" for a panel that isn't finished. + if (panelStoreState.agentStatus[panelId] !== 'idle') return; // Re-check that no PTY output arrived after the idle transition that // scheduled this timer. This catches stale timers around rapid quiet/resume @@ -188,86 +218,87 @@ export function useNotifications() { return; } - let foundSessionId: string | undefined; - let foundPanel: ToolPanel | undefined; - for (const [sessionId, panels] of Object.entries(panelStoreState.panels)) { - const panel = panels.find((p) => p.id === panelId); - if (panel) { - foundSessionId = sessionId; - foundPanel = panel; - break; - } - } - if (!foundSessionId || !foundPanel) return; + const context = findPanelContext(panelId); + if (!context) return; - const sessionStoreState = useSessionStore.getState(); - const session = sessionStoreState.sessions.find((s) => s.id === foundSessionId); - if (!session) return; + showNotification( + `${context.panelName} finished`, + context.body, + undefined, + 'panel_idle', + `idle:${panelId}:${Date.now()}`, + ); + } - // A panel going idle while the session is in 'waiting' state means Claude - // is blocked on user input, not finished. Suppress the "finished" ping. - if (session.status === 'waiting') return; + // Fires as soon as an agent flips to blocked: it is waiting on the human, so + // there is nothing to debounce — the sooner the user knows, the better. + function maybeNotifyPanelBlocked(panelId: string) { + const currentSettings = settingsRef.current; + if (!currentSettings.enabled) return; + if (windowFocusedRef.current) return; - const projectName = session.projectId - ? projectNamesRef.current.get(session.projectId) ?? '' - : ''; - const panelName = foundPanel.title || 'Terminal'; + const context = findPanelContext(panelId); + if (!context) return; showNotification( - `${panelName} finished`, - projectName ? `${session.name} · ${projectName}` : session.name, + `${context.panelName} needs your input`, + context.body, undefined, - 'panel_idle', - `idle:${panelId}:${Date.now()}`, + 'panel_blocked', + `blocked:${panelId}:${Date.now()}`, ); } - // Subscribe to panelStore.activityStatus and schedule notifications on - // active -> idle transitions, firing only after the panel has stayed idle - // for NOTIFICATION_DEBOUNCE_MS. Re-activation cancels the pending timer, - // so mid-task pauses never produce false "finished" pings. - // Uses the unary subscribe form since panelStore does not use the - // subscribeWithSelector middleware. + // Subscribe to panelStore.agentStatus (the unified per-panel agent state) and + // notify on its transitions: -> blocked fires immediately ("needs your + // input"); working -> idle schedules a debounced "finished" ping that any + // re-activation cancels. Uses the unary subscribe form since panelStore does + // not use the subscribeWithSelector middleware. useEffect(() => { const pending = pendingIdleTimersRef.current; - // Seed from current store state so panels already active at mount time + const cancelPending = (panelId: string) => { + const existing = pending.get(panelId); + if (existing) { + clearTimeout(existing); + pending.delete(panelId); + } + }; + // Seed from current store state so panels already tracked at mount time // (e.g. restored terminals, agents still running during app startup) are - // correctly detected on their first idle transition instead of being - // dismissed as `undefined -> idle`. - prevActivityRef.current = { ...usePanelStore.getState().activityStatus }; + // detected on their next transition — and an agent already sitting blocked + // when the app opens doesn't re-ping. + prevAgentStatusRef.current = { ...usePanelStore.getState().agentStatus }; const unsubscribe = usePanelStore.subscribe((state) => { - const activityStatus = state.activityStatus; - const prev = prevActivityRef.current; - for (const [panelId, status] of Object.entries(activityStatus)) { + const agentStatus = state.agentStatus; + const prev = prevAgentStatusRef.current; + for (const [panelId, status] of Object.entries(agentStatus)) { const prevStatus = prev[panelId]; - if (prevStatus === 'active' && status === 'idle') { + if (prevStatus === status) continue; + if (status === 'blocked') { + // Waiting on the human supersedes any pending "finished" ping. + cancelPending(panelId); + maybeNotifyPanelBlocked(panelId); + } else if (prevStatus === 'working' && status === 'idle') { // Schedule a debounced notification. Clear any stale timer first. - const existing = pending.get(panelId); - if (existing) clearTimeout(existing); + cancelPending(panelId); const scheduledLastActivityAt = state.lastActivityAt[panelId]; const timer = setTimeout(() => { pending.delete(panelId); maybeNotifyPanelIdle(panelId, scheduledLastActivityAt); }, NOTIFICATION_DEBOUNCE_MS); pending.set(panelId, timer); - } else if (prevStatus === 'idle' && status === 'active') { + } else if (status === 'working') { // Panel woke up before the debounce fired: cancel the pending notification. - const existing = pending.get(panelId); - if (existing) { - clearTimeout(existing); - pending.delete(panelId); - } + cancelPending(panelId); } } // Clean up timers for panels that have been removed from the store. for (const panelId of pending.keys()) { - if (!(panelId in activityStatus)) { - const existing = pending.get(panelId); - if (existing) clearTimeout(existing); - pending.delete(panelId); + if (!(panelId in agentStatus)) { + cancelPending(panelId); } } - prevActivityRef.current = { ...activityStatus }; + prevAgentStatusRef.current = { ...agentStatus }; }); return () => { unsubscribe(); @@ -275,7 +306,7 @@ export function useNotifications() { for (const timer of pending.values()) clearTimeout(timer); pending.clear(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- subscription must be created once; maybeNotifyPanelIdle reads live state via refs + // eslint-disable-next-line react-hooks/exhaustive-deps -- subscription must be created once; the notify helpers read live state via refs }, []); useEffect(() => { diff --git a/frontend/src/index.css b/frontend/src/index.css index 443b58c2..328a51a8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -471,7 +471,11 @@ body.light-rounded { } .animate-sidebar-active-label { - animation: sidebar-active-label-breathe 2.4s ease-in-out infinite; + /* 2.8s = one full cycle of the accent bar's status-working sweep (1.4s + alternate), so the label's color peak lands on the sweep's bottom bounce + and the two stay in phase — both animations start on the same render when + a session flips to working. */ + animation: sidebar-active-label-breathe 2.8s ease-in-out infinite; } /* Terminal specific styling */ diff --git a/main/src/services/agentStatus/manifests.test.ts b/main/src/services/agentStatus/manifests.test.ts index 01a33e5c..7a396424 100644 --- a/main/src/services/agentStatus/manifests.test.ts +++ b/main/src/services/agentStatus/manifests.test.ts @@ -10,12 +10,12 @@ import { const screen = (s: string, oscTitle = '', oscProgress = '') => ({ screen: s, oscTitle, oscProgress }); describe('getManifestForAgent', () => { - it('resolves bespoke manifests and generic fallback', () => { + it('resolves bespoke manifests, with the generic fallback covering everything else', () => { expect(getManifestForAgent('claude')).toBe(CLAUDE_MANIFEST); expect(getManifestForAgent('codex')).toBe(CODEX_MANIFEST); expect(getManifestForAgent('aider')).toBe(GENERIC_MANIFEST); - expect(getManifestForAgent(undefined)).toBeNull(); - expect(getManifestForAgent(null)).toBeNull(); + expect(getManifestForAgent(undefined)).toBe(GENERIC_MANIFEST); + expect(getManifestForAgent(null)).toBe(GENERIC_MANIFEST); }); }); diff --git a/main/src/services/agentStatus/manifests.ts b/main/src/services/agentStatus/manifests.ts index 7239db9a..e68399e3 100644 --- a/main/src/services/agentStatus/manifests.ts +++ b/main/src/services/agentStatus/manifests.ts @@ -252,9 +252,10 @@ export const CODEX_MANIFEST: AgentManifest = { }; /** - * Cross-agent fallback for CLI agents without a bespoke manifest. Detects the - * common permission-prompt shapes; working/idle otherwise come from PTY activity - * and the idle fallback. + * Universal fallback for any terminal panel without a bespoke manifest — other + * CLI agents (opencode, aider, ...) and plain shells alike. Detects the common + * permission-prompt shapes; working/idle otherwise come from PTY activity and + * the idle fallback, which is the old activity system's signal generalized. */ export const GENERIC_MANIFEST: AgentManifest = { id: 'generic', @@ -286,10 +287,10 @@ const MANIFESTS_BY_AGENT: Record = { /** * Resolve the manifest for a panel's agent type. Known agents get their bespoke - * manifest; any other CLI agent id gets the generic fallback. Returns null when - * there is no agent (plain shell) so the caller can skip detection entirely. + * manifest; everything else — unrecognized CLI agents and plain shells — gets + * the generic fallback, so every terminal panel is covered by one status system. */ -export function getManifestForAgent(agentType: string | undefined | null): AgentManifest | null { - if (!agentType) return null; +export function getManifestForAgent(agentType: string | undefined | null): AgentManifest { + if (!agentType) return GENERIC_MANIFEST; return MANIFESTS_BY_AGENT[agentType] ?? GENERIC_MANIFEST; } diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index e4e27c44..c844a132 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1618,9 +1618,12 @@ export class TerminalPanelManager { return customState?.agentType ?? this.getCliAgentType(customState?.initialCommand); } - /** Start status detection for an AI/CLI agent panel (no-op for plain shells). */ + /** + * Start status detection for a terminal panel. Every panel is tracked: known + * agents get their bespoke manifest, everything else (other CLI agents, plain + * shells) gets the generic one, so one status system covers all terminals. + */ private registerAgentStatusPanel(terminal: TerminalProcess): void { - if (!getManifestForAgent(terminal.agentType)) return; this.agentStatusMonitor.register(terminal.panelId, Date.now()); this.ensureAgentStatusPoll(); } @@ -1662,7 +1665,7 @@ export class TerminalPanelManager { if (!this.agentStatusMonitor.isTracked(terminal.panelId)) continue; const manifest = getManifestForAgent(terminal.agentType); const emulator = terminal.screenEmulator; - if (!manifest || !emulator) continue; + if (!emulator) continue; await emulator.waitForIdle(); const detection = detectAgentState(manifest, { From aae7b469462c00ec4a192f0d326399e0d4032667 Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Wed, 5 Aug 2026 17:24:01 -0700 Subject: [PATCH 3/4] fix: match claude/codex agent commands as tokens, not substrings A command whose script path or cwd merely contains the word (e.g. bash /tmp/claude-501/demo.sh) was classified as that agent, selecting the wrong detection manifest and wrongly injecting Claude resume flags. Match the agent name as a standalone token instead. --- main/src/services/terminalPanelManager.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index c844a132..06e882d3 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -224,10 +224,16 @@ export class TerminalPanelManager { private agentStatusPollTimer: ReturnType | null = null; private agentStatusPolling = false; + /** + * Detect the CLI agent from a launch command. Matches "claude"/"codex" as a + * standalone token (start/space/slash-delimited), not a substring — a command + * whose cwd or script path merely contains the word (e.g. /tmp/claude-501/x.sh) + * must not be classified as that agent. + */ private getCliAgentType(command?: string): CliAgentType | undefined { const lower = command?.toLowerCase() ?? ''; - if (lower.includes('claude')) return 'claude'; - if (lower.includes('codex')) return 'codex'; + if (/(^|[\s/])claude($|\s)/.test(lower)) return 'claude'; + if (/(^|[\s/])codex($|\s)/.test(lower)) return 'codex'; return undefined; } From bb31359fc11fa47dc021782201b0cc2daf790a1c Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Wed, 5 Aug 2026 17:37:08 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20live-prompt=20blocked=20detection,=20immediate=20do?= =?UTF-8?q?ne,=20project-level=20done,=20a11y=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generic manifest: blocked now requires a live prompt on the last non-empty line, so answered/scrolled-past [y/n] text in scrollback no longer sticks a pane as blocked - mark unseen completion from the unified working -> idle transition, so a background agent finishing reads done (blue) immediately instead of after the legacy 30s activity flip - project dot rolls up display statuses (blocked > working > done > idle), keeping all-done-unseen visible as blue at the project level - TerminalLoadingSkeleton: role=status so its aria-label is permitted (fixes the axe aria-prohibited-attr failure in CI) --- frontend/src/App.tsx | 20 +++++++++++-- .../src/components/ProjectSessionList.tsx | 14 +++++++--- .../src/components/panels/TerminalPanel.tsx | 2 +- frontend/src/utils/agentStatus.test.ts | 17 ++++++++++- frontend/src/utils/agentStatus.ts | 15 ++++++++++ .../services/agentStatus/manifests.test.ts | 12 +++++++- main/src/services/agentStatus/manifests.ts | 28 +++++++++---------- 7 files changed, 85 insertions(+), 23 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 52bc6e0f..600bd64e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { DiscordPopup } from './components/DiscordPopup'; import { ResumeSessionsDialog } from './components/ResumeSessionsDialog'; import { useErrorStore } from './stores/errorStore'; import { useSessionStore } from './stores/sessionStore'; +import { rollupSessionAgentState } from './utils/agentStatus'; import { useConfigStore } from './stores/configStore'; import { usePanelStore } from './stores/panelStore'; import { API } from './utils/api'; @@ -174,10 +175,25 @@ function App() { return () => unsubscribe?.(); }, []); - // Global agent status listener (blocked / working / done) for AI/CLI panels. + // Global agent status listener (blocked / working / done) for terminal panels. useEffect(() => { const unsubscribe = window.electronAPI?.events?.onPanelAgentStatus?.((data) => { - usePanelStore.getState().setAgentStatus(data.panelId, data.sessionId, data.state); + const store = usePanelStore.getState(); + const prevState = store.agentStatus[data.panelId]; + store.setAgentStatus(data.panelId, data.sessionId, data.state); + + // A background agent finishing should read as done (blue) right away — + // mark unseen completion from the unified working -> idle transition + // instead of waiting for the legacy 30s activity flip. + if (prevState === 'working' && data.state === 'idle') { + const next = usePanelStore.getState(); + const activeSessionId = useSessionStore.getState().activeSessionId; + const sessionSettled = + rollupSessionAgentState(next.agentStatus, next.agentStatusSession, data.sessionId) === 'idle'; + if (sessionSettled && activeSessionId !== data.sessionId) { + next.markUnviewedCompletedActivity(data.sessionId); + } + } }); return () => unsubscribe?.(); }, []); diff --git a/frontend/src/components/ProjectSessionList.tsx b/frontend/src/components/ProjectSessionList.tsx index c42cc60c..9d359d8f 100644 --- a/frontend/src/components/ProjectSessionList.tsx +++ b/frontend/src/components/ProjectSessionList.tsx @@ -12,7 +12,7 @@ import { StatusAccentBar } from './ui/StatusAccentBar'; import { AgentActivityDot, AgentStatusDot } from './ui/AgentStatusDot'; import type { DropdownItem } from './ui/Dropdown'; import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus'; -import { rollupAgentState, rollupSessionAgentState } from '../utils/agentStatus'; +import { rollupAgentDisplayStatus, rollupSessionAgentState, toAgentDisplayStatus } from '../utils/agentStatus'; import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; import { API } from '../utils/api'; import { cn } from '../utils/cn'; @@ -84,6 +84,7 @@ export function ProjectSessionList({ const expandProject = useNavigationStore(s => s.expandProject); const agentStatusByPanel = usePanelStore(s => s.agentStatus); const agentPanelSessions = usePanelStore(s => s.agentStatusSession); + const unviewedBySession = usePanelStore(s => s.unviewedCompletedActivity); // Load projects const loadProjects = useCallback(async () => { @@ -411,9 +412,14 @@ export function ProjectSessionList({ const isExpanded = expandedProjects.has(project.id); const projectSessions = sessionsByProject.get(project.id) || []; - // Agent status rolled up across the project's sessions (blocked > working > idle). - const projectAgentState = rollupAgentState( - projectSessions.map(s => rollupSessionAgentState(agentStatusByPanel, agentPanelSessions, s.id)) + // Display status rolled up across the project's sessions + // (blocked > working > done > idle), so unseen completion shows blue + // at the project level too. + const projectAgentState = rollupAgentDisplayStatus( + projectSessions.map(s => toAgentDisplayStatus( + rollupSessionAgentState(agentStatusByPanel, agentPanelSessions, s.id), + Boolean(unviewedBySession[s.id]), + )) ); const projectMenuItems: DropdownItem[] = [ diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index 614cd4d8..3d77fc26 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -41,7 +41,7 @@ const SKELETON_TRANSCRIPT_WIDTHS = ['w-2/3', 'w-1/2', 'w-5/6', 'w-1/3', 'w-3/4', // lines, and a prompt box, swept by a single shimmer so it reads as one // cohesive loading surface. Shown while initializing, refreshing, and CLI startup. const TerminalLoadingSkeleton: React.FC = () => ( -
+
diff --git a/frontend/src/utils/agentStatus.test.ts b/frontend/src/utils/agentStatus.test.ts index e8a57d55..4f5b86e3 100644 --- a/frontend/src/utils/agentStatus.test.ts +++ b/frontend/src/utils/agentStatus.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { rollupAgentState, rollupSessionAgentState, toAgentDisplayStatus } from './agentStatus'; +import { rollupAgentDisplayStatus, rollupAgentState, rollupSessionAgentState, toAgentDisplayStatus } from './agentStatus'; describe('rollupSessionAgentState', () => { const agentStatus = { p1: 'working', p2: 'idle', p3: 'blocked' } as const; @@ -50,3 +50,18 @@ describe('toAgentDisplayStatus', () => { expect(toAgentDisplayStatus('unknown', false)).toBe('unknown'); }); }); + +describe('rollupAgentDisplayStatus', () => { + it('applies blocked > working > done > idle precedence', () => { + expect(rollupAgentDisplayStatus(['idle', 'blocked', 'working'])).toBe('blocked'); + expect(rollupAgentDisplayStatus(['idle', 'working', 'done'])).toBe('working'); + expect(rollupAgentDisplayStatus(['idle', 'done'])).toBe('done'); + expect(rollupAgentDisplayStatus(['idle', 'idle'])).toBe('idle'); + }); + + it('keeps all-done groups visible as done, and empty groups unknown', () => { + expect(rollupAgentDisplayStatus(['done', 'done'])).toBe('done'); + expect(rollupAgentDisplayStatus([])).toBe('unknown'); + expect(rollupAgentDisplayStatus(['unknown'])).toBe('unknown'); + }); +}); diff --git a/frontend/src/utils/agentStatus.ts b/frontend/src/utils/agentStatus.ts index 5e2497fe..00c3c1e3 100644 --- a/frontend/src/utils/agentStatus.ts +++ b/frontend/src/utils/agentStatus.ts @@ -34,6 +34,21 @@ export function rollupSessionAgentState( return rollupAgentState(states); } +const DISPLAY_PRECEDENCE: readonly AgentDisplayStatus[] = ['blocked', 'working', 'done', 'idle']; + +/** + * Roll several {@link AgentDisplayStatus}es up into one, with precedence + * blocked > working > done > idle. Unlike {@link rollupAgentState} this keeps + * unseen completion visible: a group whose members are all freshly finished + * reads as `done`, not `idle`. Used for the project-level dot. + */ +export function rollupAgentDisplayStatus(statuses: AgentDisplayStatus[]): AgentDisplayStatus { + for (const status of DISPLAY_PRECEDENCE) { + if (statuses.includes(status)) return status; + } + return 'unknown'; +} + /** * Map a raw {@link AgentState} to the status shown in the UI. A finished agent * the user hasn't looked at yet reads as `done`; once seen it is plain `idle`. diff --git a/main/src/services/agentStatus/manifests.test.ts b/main/src/services/agentStatus/manifests.test.ts index 7a396424..a45b9622 100644 --- a/main/src/services/agentStatus/manifests.test.ts +++ b/main/src/services/agentStatus/manifests.test.ts @@ -110,8 +110,18 @@ describe('CODEX_MANIFEST', () => { }); describe('GENERIC_MANIFEST', () => { - it('detects a y/n prompt as blocked and a bare prompt as idle', () => { + it('detects a live prompt on the last line as blocked', () => { expect(detectAgentState(GENERIC_MANIFEST, screen('Overwrite file? (y/n)')).state).toBe('blocked'); + expect(detectAgentState(GENERIC_MANIFEST, screen('Do you want to proceed? [y/n] ')).state).toBe('blocked'); + expect( + detectAgentState(GENERIC_MANIFEST, screen('Apply migration to database?\nDo you want to proceed? [y/n] ')).state, + ).toBe('blocked'); + expect(detectAgentState(GENERIC_MANIFEST, screen('Continue? [Y/n]:')).state).toBe('blocked'); + }); + + it('does not stay blocked once the prompt is answered or scrolled past', () => { + expect(detectAgentState(GENERIC_MANIFEST, screen('Do you want to proceed? [y/n] y\nok: y')).state).toBe('idle'); + expect(detectAgentState(GENERIC_MANIFEST, screen('Overwrite file? (y/n)\n$ ')).state).toBe('idle'); expect(detectAgentState(GENERIC_MANIFEST, screen('$ ')).state).toBe('idle'); }); }); diff --git a/main/src/services/agentStatus/manifests.ts b/main/src/services/agentStatus/manifests.ts index e68399e3..848126bb 100644 --- a/main/src/services/agentStatus/manifests.ts +++ b/main/src/services/agentStatus/manifests.ts @@ -253,29 +253,29 @@ export const CODEX_MANIFEST: AgentManifest = { /** * Universal fallback for any terminal panel without a bespoke manifest — other - * CLI agents (opencode, aider, ...) and plain shells alike. Detects the common - * permission-prompt shapes; working/idle otherwise come from PTY activity and - * the idle fallback, which is the old activity system's signal generalized. + * CLI agents (opencode, aider, ...) and plain shells alike. Blocked requires a + * LIVE prompt: the last non-empty line must itself end in a prompt shape, so a + * finished command whose scrollback still shows "[y/n]" text (answered prompts, + * an apt run above a fresh shell prompt) is not classified blocked. Working and + * idle come from PTY activity and the idle fallback. */ export const GENERIC_MANIFEST: AgentManifest = { id: 'generic', rules: [ { - id: 'generic_permission_prompt', + id: 'generic_live_prompt', state: 'blocked', priority: 600, - region: 'whole_recent', + region: 'bottom_non_empty_lines(1)', + visibleBlocker: true, any: [ - { contains: ['do you want to proceed?'] }, - { contains: ['[y/n]'] }, - { contains: ['(y/n)'] }, - { contains: ['press enter to confirm'] }, - { contains: ['allow command?'] }, - { contains: ['waiting for permission'] }, - { contains: ['do you want to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, - { contains: ['would you like to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, + { lineRegex: [/\[y\/n[^\]]*\]\s*:?\s*$/i] }, + { lineRegex: [/\(y\/n[^)]*\)\s*:?\s*$/i] }, + { lineRegex: [/press enter to confirm\.?\s*$/i] }, + { lineRegex: [/(?:do you want|would you like)\b[^?]*\?\s*$/i] }, + { lineRegex: [/allow command\?\s*$/i] }, + { lineRegex: [/waiting for permission\.{0,3}\s*$/i] }, ], - not: [{ regex: [/^\s*❯\s*$/m] }], }, ], };