import { feature } from 'bun:bundle' import figures from 'figures' import React, { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState, } from 'react' import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js' import { useTerminalSize } from 'src/hooks/useTerminalSize.js' import { useAppState, useSetAppState } from 'src/state/AppState.js' import { enterTeammateView, exitTeammateView, } from 'src/state/teammateViewHelpers.js' import type { ToolUseContext } from 'src/Tool.js' import { DreamTask, type DreamTaskState, } from 'src/tasks/DreamTask/DreamTask.js' import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js' import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js' import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js' import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js' // Type import is erased at build time — safe even though module is ant-gated. import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js' import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js' import { RemoteAgentTask, type RemoteAgentTaskState, } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' import { type BackgroundTaskState, isBackgroundTask, type TaskState, } from 'src/tasks/types.js' import type { DeepImmutable } from 'src/types/utils.js' import { intersperse } from 'src/utils/array.js' import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js' import { stopUltraplan } from '../../commands/ultraplan.js' import type { CommandResultDisplay } from '../../commands.js' import { useRegisterOverlay } from '../../context/overlayContext.js' import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' import { Box, Text } from '../../ink.js' import { useKeybindings } from '../../keybindings/useKeybinding.js' import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' import { count } from '../../utils/array.js' import { Byline } from '../design-system/Byline.js' import { Dialog } from '../design-system/Dialog.js' import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js' import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js' import { DreamDetailDialog } from './DreamDetailDialog.js' import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js' import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js' import { ShellDetailDialog } from './ShellDetailDialog.js' type ViewState = { mode: 'list' } | { mode: 'detail'; itemId: string } type Props = { onDone: ( result?: string, options?: { display?: CommandResultDisplay }, ) => void toolUseContext: ToolUseContext initialDetailTaskId?: string } type ListItem = | { id: string type: 'local_bash' label: string status: string task: DeepImmutable } | { id: string type: 'remote_agent' label: string status: string task: DeepImmutable } | { id: string type: 'local_agent' label: string status: string task: DeepImmutable } | { id: string type: 'in_process_teammate' label: string status: string task: DeepImmutable } | { id: string type: 'local_workflow' label: string status: string task: DeepImmutable } | { id: string type: 'monitor_mcp' label: string status: string task: DeepImmutable } | { id: string type: 'dream' label: string status: string task: DeepImmutable } | { id: string type: 'leader' label: string status: 'running' } // WORKFLOW_SCRIPTS is ant-only (build_flags.yaml). Static imports would leak // ~1.3K lines into external builds. Gate with feature() + require so the // bundler can dead-code-eliminate the branch. /* eslint-disable @typescript-eslint/no-require-imports */ const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') ? ( require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js') ).WorkflowDetailDialog : null const workflowTaskModule = feature('WORKFLOW_SCRIPTS') ? (require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js')) : null const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null // Relative path, not `src/...` path-mapping — Bun's DCE can statically // resolve + eliminate `./` requires, but path-mapped strings stay opaque // and survive as dead literals in the bundle. Matches tasks.ts pattern. const monitorMcpModule = feature('MONITOR_TOOL') ? (require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js')) : null const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null const MonitorMcpDetailDialog = feature('MONITOR_TOOL') ? ( require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js') ).MonitorMcpDetailDialog : null /* eslint-enable @typescript-eslint/no-require-imports */ // Helper to get filtered background tasks (excludes foregrounded local_agent) function getSelectableBackgroundTasks( tasks: Record | undefined, foregroundedTaskId: string | undefined, ): TaskState[] { const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask) return backgroundTasks.filter( task => !(task.type === 'local_agent' && task.id === foregroundedTaskId), ) } export function BackgroundTasksDialog({ onDone, toolUseContext, initialDetailTaskId, }: Props): React.ReactNode { const tasks = useAppState(s => s.tasks) const foregroundedTaskId = useAppState(s => s.foregroundedTaskId) const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates' const setAppState = useSetAppState() const killAgentsShortcut = useShortcutDisplay( 'chat:killAgents', 'Chat', 'ctrl+x ctrl+k', ) const typedTasks = tasks as Record | undefined // Track if we skipped list view on mount (for back button behavior) const skippedListOnMount = useRef(false) // Compute initial view state - skip list if caller provided a specific task, // or if there's exactly one task const [viewState, setViewState] = useState(() => { if (initialDetailTaskId) { skippedListOnMount.current = true return { mode: 'detail', itemId: initialDetailTaskId } } const allItems = getSelectableBackgroundTasks( typedTasks, foregroundedTaskId, ) if (allItems.length === 1) { skippedListOnMount.current = true return { mode: 'detail', itemId: allItems[0]!.id } } return { mode: 'list' } }) const [selectedIndex, setSelectedIndex] = useState(0) // Register as modal overlay so parent Chat keybindings (up/down for history) // are deactivated while this dialog is open useRegisterOverlay('background-tasks-dialog') // Memoize the sorted and categorized items together to ensure stable references const { bashTasks, remoteSessions, agentTasks, teammateTasks, workflowTasks, mcpMonitors, dreamTasks, allSelectableItems, } = useMemo(() => { // Filter to only show running/pending background tasks, matching the status bar count const backgroundTasks = Object.values(typedTasks ?? {}).filter( isBackgroundTask, ) const allItems = backgroundTasks.map(toListItem) const sorted = allItems.sort((a, b) => { const aStatus = a.status const bStatus = b.status if (aStatus === 'running' && bStatus !== 'running') return -1 if (aStatus !== 'running' && bStatus === 'running') return 1 const aTime = 'task' in a ? a.task.startTime : 0 const bTime = 'task' in b ? b.task.startTime : 0 return bTime - aTime }) const bash = sorted.filter(item => item.type === 'local_bash') const remote = sorted.filter(item => item.type === 'remote_agent') // Exclude foregrounded task - it's being viewed in the main UI, not a background task const agent = sorted.filter( item => item.type === 'local_agent' && item.id !== foregroundedTaskId, ) const workflows = sorted.filter(item => item.type === 'local_workflow') const monitorMcp = sorted.filter(item => item.type === 'monitor_mcp') const dreamTasks = sorted.filter(item => item.type === 'dream') // In spinner-tree mode, exclude teammates from the dialog (they appear in the tree) const teammates = showSpinnerTree ? [] : sorted.filter(item => item.type === 'in_process_teammate') // Add leader entry when there are teammates, so users can foreground back to leader const leaderItem: ListItem[] = teammates.length > 0 ? [ { id: '__leader__', type: 'leader', label: `@${TEAM_LEAD_NAME}`, status: 'running', }, ] : [] return { bashTasks: bash, remoteSessions: remote, agentTasks: agent, workflowTasks: workflows, mcpMonitors: monitorMcp, dreamTasks, teammateTasks: [...leaderItem, ...teammates], // Order MUST match JSX render order (teammates \u2192 bash \u2192 monitorMcp \u2192 // remote \u2192 agent \u2192 workflows \u2192 dream) so \u2193/\u2191 navigation moves the cursor // visually downward. allSelectableItems: [ ...leaderItem, ...teammates, ...bash, ...monitorMcp, ...remote, ...agent, ...workflows, ...dreamTasks, ], } }, [typedTasks, foregroundedTaskId, showSpinnerTree]) const currentSelection = allSelectableItems[selectedIndex] ?? null // Use configurable keybindings for standard navigation and confirm/cancel. // confirm:no is handled by Dialog's onCancel prop. useKeybindings( { 'confirm:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), 'confirm:next': () => setSelectedIndex(prev => Math.min(allSelectableItems.length - 1, prev + 1), ), 'confirm:yes': () => { const current = allSelectableItems[selectedIndex] if (current) { if (current.type === 'leader') { exitTeammateView(setAppState) onDone('Viewing leader', { display: 'system' }) } else { setViewState({ mode: 'detail', itemId: current.id }) } } }, }, { context: 'Confirmation', isActive: viewState.mode === 'list' }, ) // Component-specific shortcuts (x=stop, f=foreground, right=zoom) shown in UI. // These are task-type and status dependent, not standard dialog keybindings. const handleKeyDown = (e: KeyboardEvent) => { // Only handle input when in list mode if (viewState.mode !== 'list') return if (e.key === 'left') { e.preventDefault() onDone('Background tasks dialog dismissed', { display: 'system' }) return } // Compute current selection at the time of the key press const currentSelection = allSelectableItems[selectedIndex] if (!currentSelection) return // everything below requires a selection if (e.key === 'x') { e.preventDefault() if ( currentSelection.type === 'local_bash' && currentSelection.status === 'running' ) { void killShellTask(currentSelection.id) } else if ( currentSelection.type === 'local_agent' && currentSelection.status === 'running' ) { void killAgentTask(currentSelection.id) } else if ( currentSelection.type === 'in_process_teammate' && currentSelection.status === 'running' ) { void killTeammateTask(currentSelection.id) } else if ( currentSelection.type === 'local_workflow' && currentSelection.status === 'running' && killWorkflowTask ) { killWorkflowTask(currentSelection.id, setAppState) } else if ( currentSelection.type === 'monitor_mcp' && currentSelection.status === 'running' && killMonitorMcp ) { killMonitorMcp(currentSelection.id, setAppState) } else if ( currentSelection.type === 'dream' && currentSelection.status === 'running' ) { void killDreamTask(currentSelection.id) } else if ( currentSelection.type === 'remote_agent' && currentSelection.status === 'running' ) { if (currentSelection.task.isUltraplan) { void stopUltraplan( currentSelection.id, currentSelection.task.sessionId, setAppState, ) } else { void killRemoteAgentTask(currentSelection.id) } } } if (e.key === 'f') { if ( currentSelection.type === 'in_process_teammate' && currentSelection.status === 'running' ) { e.preventDefault() enterTeammateView(currentSelection.id, setAppState) onDone('Viewing teammate', { display: 'system' }) } else if (currentSelection.type === 'leader') { e.preventDefault() exitTeammateView(setAppState) onDone('Viewing leader', { display: 'system' }) } } } async function killShellTask(taskId: string): Promise { await LocalShellTask.kill(taskId, setAppState) } async function killAgentTask(taskId: string): Promise { await LocalAgentTask.kill(taskId, setAppState) } async function killTeammateTask(taskId: string): Promise { await InProcessTeammateTask.kill(taskId, setAppState) } async function killDreamTask(taskId: string): Promise { await DreamTask.kill(taskId, setAppState) } async function killRemoteAgentTask(taskId: string): Promise { await RemoteAgentTask.kill(taskId, setAppState) } // Wrap onDone in useEffectEvent to get a stable reference that always calls // the current onDone callback without causing the effect to re-fire. const onDoneEvent = useEffectEvent(onDone) useEffect(() => { if (viewState.mode !== 'list') { const task = (typedTasks ?? {})[viewState.itemId] // Workflow tasks get a grace: their detail view stays open through // completion so the user sees the final state before eviction. if ( !task || (task.type !== 'local_workflow' && !isBackgroundTask(task)) ) { // Task was removed or is no longer a background task (e.g. killed). // If we skipped the list on mount, close the dialog entirely. if (skippedListOnMount.current) { onDoneEvent('Background tasks dialog dismissed', { display: 'system', }) } else { setViewState({ mode: 'list' }) } } } const totalItems = allSelectableItems.length if (selectedIndex >= totalItems && totalItems > 0) { setSelectedIndex(totalItems - 1) } }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]) // Helper to go back to list view (or close dialog if we skipped list on // mount AND there's still only ≤1 item). Checking current count prevents // the stale-state trap: if you opened with 1 task (auto-skipped to detail), // then a second task started, 'back' should show the list — not close. const goBackToList = () => { if (skippedListOnMount.current && allSelectableItems.length <= 1) { onDone('Background tasks dialog dismissed', { display: 'system' }) } else { skippedListOnMount.current = false setViewState({ mode: 'list' }) } } // If an item is selected, show the appropriate view if (viewState.mode !== 'list' && typedTasks) { const task = typedTasks[viewState.itemId] if (!task) { return null } // Detail mode - show appropriate detail dialog switch (task.type) { case 'local_bash': return ( void killShellTask(task.id)} onBack={goBackToList} key={`shell-${task.id}`} /> ) case 'local_agent': return ( void killAgentTask(task.id)} onBack={goBackToList} key={`agent-${task.id}`} /> ) case 'remote_agent': return ( void stopUltraplan(task.id, task.sessionId, setAppState) : () => void killRemoteAgentTask(task.id) } key={`session-${task.id}`} /> ) case 'in_process_teammate': return ( void killTeammateTask(task.id) : undefined } onBack={goBackToList} onForeground={ task.status === 'running' ? () => { enterTeammateView(task.id, setAppState) onDone('Viewing teammate', { display: 'system' }) } : undefined } key={`teammate-${task.id}`} /> ) case 'local_workflow': if (!WorkflowDetailDialog) return null return ( killWorkflowTask(task.id, setAppState) : undefined } onSkipAgent={ task.status === 'running' && skipWorkflowAgent ? agentId => skipWorkflowAgent(task.id, agentId, setAppState) : undefined } onRetryAgent={ task.status === 'running' && retryWorkflowAgent ? agentId => retryWorkflowAgent(task.id, agentId, setAppState) : undefined } onBack={goBackToList} key={`workflow-${task.id}`} /> ) case 'monitor_mcp': if (!MonitorMcpDetailDialog) return null return ( killMonitorMcp(task.id, setAppState) : undefined } onBack={goBackToList} key={`monitor-mcp-${task.id}`} /> ) case 'dream': return ( onDone('Background tasks dialog dismissed', { display: 'system', }) } onBack={goBackToList} onKill={ task.status === 'running' ? () => void killDreamTask(task.id) : undefined } key={`dream-${task.id}`} /> ) } } const runningBashCount = count(bashTasks, _ => _.status === 'running') const runningAgentCount = count( remoteSessions, _ => _.status === 'running' || _.status === 'pending', ) + count(agentTasks, _ => _.status === 'running') const runningTeammateCount = count(teammateTasks, _ => _.status === 'running') const subtitle = intersperse( [ ...(runningTeammateCount > 0 ? [ {runningTeammateCount}{' '} {runningTeammateCount !== 1 ? 'agents' : 'agent'} , ] : []), ...(runningBashCount > 0 ? [ {runningBashCount}{' '} {runningBashCount !== 1 ? 'active shells' : 'active shell'} , ] : []), ...(runningAgentCount > 0 ? [ {runningAgentCount}{' '} {runningAgentCount !== 1 ? 'active agents' : 'active agent'} , ] : []), ], index => · , ) const actions = [ , , ...(currentSelection?.type === 'in_process_teammate' && currentSelection.status === 'running' ? [ , ] : []), ...((currentSelection?.type === 'local_bash' || currentSelection?.type === 'local_agent' || currentSelection?.type === 'in_process_teammate' || currentSelection?.type === 'local_workflow' || currentSelection?.type === 'monitor_mcp' || currentSelection?.type === 'dream' || currentSelection?.type === 'remote_agent') && currentSelection.status === 'running' ? [] : []), ...(agentTasks.some(t => t.status === 'running') ? [ , ] : []), , ] const handleCancel = () => onDone('Background tasks dialog dismissed', { display: 'system' }) function renderInputGuide(exitState: ExitState): React.ReactNode { if (exitState.pending) { return Press {exitState.keyName} again to exit } return {actions} } return ( {subtitle}} onCancel={handleCancel} color="background" inputGuide={renderInputGuide} > {allSelectableItems.length === 0 ? ( No tasks currently running ) : ( {teammateTasks.length > 0 && ( {(bashTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && ( {' '}Agents ( {count(teammateTasks, i => i.type !== 'leader')}) )} )} {bashTasks.length > 0 && ( 0 ? 1 : 0} > {(teammateTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && ( {' '}Shells ({bashTasks.length}) )} {bashTasks.map(item => ( ))} )} {mcpMonitors.length > 0 && ( 0 || bashTasks.length > 0 ? 1 : 0 } > {' '}Monitors ({mcpMonitors.length}) {mcpMonitors.map(item => ( ))} )} {remoteSessions.length > 0 && ( 0 || bashTasks.length > 0 || mcpMonitors.length > 0 ? 1 : 0 } > {' '}Remote agents ({remoteSessions.length} ) {remoteSessions.map(item => ( ))} )} {agentTasks.length > 0 && ( 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 ? 1 : 0 } > {' '}Local agents ({agentTasks.length}) {agentTasks.map(item => ( ))} )} {workflowTasks.length > 0 && ( 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0 ? 1 : 0 } > {' '}Workflows ({workflowTasks.length}) {workflowTasks.map(item => ( ))} )} {dreamTasks.length > 0 && ( 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0 || workflowTasks.length > 0 ? 1 : 0 } > {dreamTasks.map(item => ( ))} )} )} ) } function toListItem(task: BackgroundTaskState): ListItem { switch (task.type) { case 'local_bash': return { id: task.id, type: 'local_bash', label: task.kind === 'monitor' ? task.description : task.command, status: task.status, task, } case 'remote_agent': return { id: task.id, type: 'remote_agent', label: task.title, status: task.status, task, } case 'local_agent': return { id: task.id, type: 'local_agent', label: task.description, status: task.status, task, } case 'in_process_teammate': return { id: task.id, type: 'in_process_teammate', label: `@${task.identity.agentName}`, status: task.status, task, } case 'local_workflow': return { id: task.id, type: 'local_workflow', label: task.summary ?? task.description, status: task.status, task, } case 'monitor_mcp': return { id: task.id, type: 'monitor_mcp', label: task.description, status: task.status, task, } case 'dream': return { id: task.id, type: 'dream', label: task.description, status: task.status, task, } } } function Item({ item, isSelected, }: { item: ListItem isSelected: boolean }): ReactNode { const { columns } = useTerminalSize() // Dialog border (2) + padding (2) + pointer prefix (2) + name/status overhead (~20) const maxActivityWidth = Math.max(30, columns - 26) // In coordinator mode, use grey pointer instead of blue const useGreyPointer = isCoordinatorMode() return ( {isSelected ? figures.pointer + ' ' : ' '} {item.type === 'leader' ? ( @{TEAM_LEAD_NAME} ) : ( )} ) } function TeammateTaskGroups({ teammateTasks, currentSelectionId, }: { teammateTasks: ListItem[] currentSelectionId: string | undefined }): ReactNode { // Separate leader from teammates, group teammates by team const leaderItems = teammateTasks.filter(i => i.type === 'leader') const teammateItems = teammateTasks.filter( i => i.type === 'in_process_teammate', ) const teams = new Map() for (const item of teammateItems) { const teamName = item.task.identity.teamName const group = teams.get(teamName) if (group) { group.push(item) } else { teams.set(teamName, [item]) } } const teamEntries = [...teams.entries()] return ( <> {teamEntries.map(([teamName, items]) => { const memberCount = items.length + leaderItems.length return ( {' '}Team: {teamName} ({memberCount}) {/* Render leader first within each team */} {leaderItems.map(item => ( ))} {items.map(item => ( ))} ) })} ) }