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 afbdf5f9..792ca095 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'; @@ -175,6 +176,29 @@ function App() { return () => unsubscribe?.(); }, []); + // Global agent status listener (blocked / working / done) for terminal panels. + useEffect(() => { + const unsubscribe = window.electronAPI?.events?.onPanelAgentStatus?.((data) => { + 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?.(); + }, []); + 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..9d359d8f 100644 --- a/frontend/src/components/ProjectSessionList.tsx +++ b/frontend/src/components/ProjectSessionList.tsx @@ -8,7 +8,12 @@ import { CreateSessionDialog } from './CreateSessionDialog'; import { AddProjectDialog } from './AddProjectDialog'; import { Dropdown } from './ui/Dropdown'; import { Tooltip } from './ui/Tooltip'; +import { StatusAccentBar } from './ui/StatusAccentBar'; +import { AgentActivityDot, AgentStatusDot } from './ui/AgentStatusDot'; import type { DropdownItem } from './ui/Dropdown'; +import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus'; +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'; import type { Session, GitStatus } from '../types/session'; @@ -69,6 +74,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 @@ -76,8 +82,9 @@ 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); + const unviewedBySession = usePanelStore(s => s.unviewedCompletedActivity); // Load projects const loadProjects = useCallback(async () => { @@ -322,6 +329,7 @@ export function ProjectSessionList({ > Pane Chat + {showRemoteDesktopLink && onRemoteDesktopClick && ( @@ -404,10 +412,15 @@ 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'; + // 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[] = [ { @@ -461,12 +474,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); // Queue the initial refresh even when cached status is available, so cached // PR state is corrected by the background git/PR refresh path. @@ -721,19 +730,19 @@ 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 (
+ {/* 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..5599737f --- /dev/null +++ b/frontend/src/components/SessionStatusBadge.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus'; +import { AgentActivityDot, AgentStatusDot } from './ui/AgentStatusDot'; + +interface SessionStatusBadgeProps { + sessionId: string; + size?: 'sm' | 'md'; +} + +/** + * 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); + + if (displayStatus === 'unknown') { + 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..3894d3d0 --- /dev/null +++ b/frontend/src/components/panels/PanelTabStatusDot.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { usePanelAgentDisplayStatus } from '../../hooks/useAgentStatus'; +import { AgentActivityDot, AgentStatusDot } from '../ui/AgentStatusDot'; + +interface PanelTabStatusDotProps { + panelId: string; + sessionId: string; +} + +/** + * Per-tab status dot: the agent status (blocked / working / done / idle) for + * 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); + + if (displayStatus === 'unknown') { + return ; + } + + return ; +}; diff --git a/frontend/src/components/panels/PanelTabStrip.tsx b/frontend/src/components/panels/PanelTabStrip.tsx index 7db7d681..0166c2ac 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,17 +445,15 @@ 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')} + {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 new file mode 100644 index 00000000..bd9cff3e --- /dev/null +++ b/frontend/src/components/ui/AgentStatusDot.tsx @@ -0,0 +1,110 @@ +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', +}; + +// 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 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; + + 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..64a9a089 --- /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-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, 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. + */ +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..6a5a5dd3 --- /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-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 }); + }); + + 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..63f09edc --- /dev/null +++ b/frontend/src/components/ui/agentStatusVisual.ts @@ -0,0 +1,32 @@ +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 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. + */ +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-info', 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/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/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..4f5b86e3 --- /dev/null +++ b/frontend/src/utils/agentStatus.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { rollupAgentDisplayStatus, 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'); + }); +}); + +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 new file mode 100644 index 00000000..00c3c1e3 --- /dev/null +++ b/frontend/src/utils/agentStatus.ts @@ -0,0 +1,63 @@ +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); +} + +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`. + */ +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..a45b9622 --- /dev/null +++ b/main/src/services/agentStatus/manifests.test.ts @@ -0,0 +1,127 @@ +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, 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)).toBe(GENERIC_MANIFEST); + expect(getManifestForAgent(null)).toBe(GENERIC_MANIFEST); + }); +}); + +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 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 new file mode 100644 index 00000000..848126bb --- /dev/null +++ b/main/src/services/agentStatus/manifests.ts @@ -0,0 +1,296 @@ +/** + * 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'] }], + }, + ], +}; + +/** + * Universal fallback for any terminal panel without a bespoke manifest — other + * 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_live_prompt', + state: 'blocked', + priority: 600, + region: 'bottom_non_empty_lines(1)', + visibleBlocker: true, + any: [ + { 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] }, + ], + }, + ], +}; + +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; 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 { + 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 427cdb65..80d2025c 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; @@ -198,6 +203,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; @@ -215,10 +222,21 @@ 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; + + /** + * 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; } @@ -968,12 +986,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 @@ -1144,6 +1166,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 @@ -1240,6 +1265,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, @@ -1605,6 +1637,80 @@ 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 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 { + 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 (!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) { @@ -1646,6 +1752,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 2ec10c6d..04efc6fb 100644 --- a/main/src/services/terminalStateEmulator.test.ts +++ b/main/src/services/terminalStateEmulator.test.ts @@ -77,4 +77,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 6c775bfc..d4a3cbb7 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 { @@ -91,6 +103,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; +}