mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
1013 lines
32 KiB
TypeScript
1013 lines
32 KiB
TypeScript
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<LocalShellTaskState>
|
|
}
|
|
| {
|
|
id: string
|
|
type: 'remote_agent'
|
|
label: string
|
|
status: string
|
|
task: DeepImmutable<RemoteAgentTaskState>
|
|
}
|
|
| {
|
|
id: string
|
|
type: 'local_agent'
|
|
label: string
|
|
status: string
|
|
task: DeepImmutable<LocalAgentTaskState>
|
|
}
|
|
| {
|
|
id: string
|
|
type: 'in_process_teammate'
|
|
label: string
|
|
status: string
|
|
task: DeepImmutable<InProcessTeammateTaskState>
|
|
}
|
|
| {
|
|
id: string
|
|
type: 'local_workflow'
|
|
label: string
|
|
status: string
|
|
task: DeepImmutable<LocalWorkflowTaskState>
|
|
}
|
|
| {
|
|
id: string
|
|
type: 'monitor_mcp'
|
|
label: string
|
|
status: string
|
|
task: DeepImmutable<MonitorMcpTaskState>
|
|
}
|
|
| {
|
|
id: string
|
|
type: 'dream'
|
|
label: string
|
|
status: string
|
|
task: DeepImmutable<DreamTaskState>
|
|
}
|
|
| {
|
|
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<string, TaskState> | 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<string, TaskState> | 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<ViewState>(() => {
|
|
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<number>(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<void> {
|
|
await LocalShellTask.kill(taskId, setAppState)
|
|
}
|
|
|
|
async function killAgentTask(taskId: string): Promise<void> {
|
|
await LocalAgentTask.kill(taskId, setAppState)
|
|
}
|
|
|
|
async function killTeammateTask(taskId: string): Promise<void> {
|
|
await InProcessTeammateTask.kill(taskId, setAppState)
|
|
}
|
|
|
|
async function killDreamTask(taskId: string): Promise<void> {
|
|
await DreamTask.kill(taskId, setAppState)
|
|
}
|
|
|
|
async function killRemoteAgentTask(taskId: string): Promise<void> {
|
|
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 (
|
|
<ShellDetailDialog
|
|
shell={task}
|
|
onDone={onDone}
|
|
onKillShell={() => void killShellTask(task.id)}
|
|
onBack={goBackToList}
|
|
key={`shell-${task.id}`}
|
|
/>
|
|
)
|
|
case 'local_agent':
|
|
return (
|
|
<AsyncAgentDetailDialog
|
|
agent={task}
|
|
onDone={onDone}
|
|
onKillAgent={() => void killAgentTask(task.id)}
|
|
onBack={goBackToList}
|
|
key={`agent-${task.id}`}
|
|
/>
|
|
)
|
|
case 'remote_agent':
|
|
return (
|
|
<RemoteSessionDetailDialog
|
|
session={task}
|
|
onDone={onDone}
|
|
toolUseContext={toolUseContext}
|
|
onBack={goBackToList}
|
|
onKill={
|
|
task.status !== 'running'
|
|
? undefined
|
|
: task.isUltraplan
|
|
? () =>
|
|
void stopUltraplan(task.id, task.sessionId, setAppState)
|
|
: () => void killRemoteAgentTask(task.id)
|
|
}
|
|
key={`session-${task.id}`}
|
|
/>
|
|
)
|
|
case 'in_process_teammate':
|
|
return (
|
|
<InProcessTeammateDetailDialog
|
|
teammate={task}
|
|
onDone={onDone}
|
|
onKill={
|
|
task.status === 'running'
|
|
? () => 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 (
|
|
<WorkflowDetailDialog
|
|
workflow={task}
|
|
onDone={onDone}
|
|
onKill={
|
|
task.status === 'running' && killWorkflowTask
|
|
? () => 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 (
|
|
<MonitorMcpDetailDialog
|
|
task={task}
|
|
onKill={
|
|
task.status === 'running' && killMonitorMcp
|
|
? () => killMonitorMcp(task.id, setAppState)
|
|
: undefined
|
|
}
|
|
onBack={goBackToList}
|
|
key={`monitor-mcp-${task.id}`}
|
|
/>
|
|
)
|
|
case 'dream':
|
|
return (
|
|
<DreamDetailDialog
|
|
task={task}
|
|
onDone={() =>
|
|
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
|
|
? [
|
|
<Text key="teammates">
|
|
{runningTeammateCount}{' '}
|
|
{runningTeammateCount !== 1 ? 'agents' : 'agent'}
|
|
</Text>,
|
|
]
|
|
: []),
|
|
...(runningBashCount > 0
|
|
? [
|
|
<Text key="shells">
|
|
{runningBashCount}{' '}
|
|
{runningBashCount !== 1 ? 'active shells' : 'active shell'}
|
|
</Text>,
|
|
]
|
|
: []),
|
|
...(runningAgentCount > 0
|
|
? [
|
|
<Text key="agents">
|
|
{runningAgentCount}{' '}
|
|
{runningAgentCount !== 1 ? 'active agents' : 'active agent'}
|
|
</Text>,
|
|
]
|
|
: []),
|
|
],
|
|
index => <Text key={`separator-${index}`}> · </Text>,
|
|
)
|
|
|
|
const actions = [
|
|
<KeyboardShortcutHint key="upDown" shortcut="↑/↓" action="select" />,
|
|
<KeyboardShortcutHint key="enter" shortcut="Enter" action="view" />,
|
|
...(currentSelection?.type === 'in_process_teammate' &&
|
|
currentSelection.status === 'running'
|
|
? [
|
|
<KeyboardShortcutHint
|
|
key="foreground"
|
|
shortcut="f"
|
|
action="foreground"
|
|
/>,
|
|
]
|
|
: []),
|
|
...((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'
|
|
? [<KeyboardShortcutHint key="kill" shortcut="x" action="stop" />]
|
|
: []),
|
|
...(agentTasks.some(t => t.status === 'running')
|
|
? [
|
|
<KeyboardShortcutHint
|
|
key="kill-all"
|
|
shortcut={killAgentsShortcut}
|
|
action="stop all agents"
|
|
/>,
|
|
]
|
|
: []),
|
|
<KeyboardShortcutHint key="esc" shortcut="←/Esc" action="close" />,
|
|
]
|
|
|
|
const handleCancel = () =>
|
|
onDone('Background tasks dialog dismissed', { display: 'system' })
|
|
|
|
function renderInputGuide(exitState: ExitState): React.ReactNode {
|
|
if (exitState.pending) {
|
|
return <Text>Press {exitState.keyName} again to exit</Text>
|
|
}
|
|
return <Byline>{actions}</Byline>
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="column"
|
|
tabIndex={0}
|
|
autoFocus
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<Dialog
|
|
title="Background tasks"
|
|
subtitle={<>{subtitle}</>}
|
|
onCancel={handleCancel}
|
|
color="background"
|
|
inputGuide={renderInputGuide}
|
|
>
|
|
{allSelectableItems.length === 0 ? (
|
|
<Text dimColor>No tasks currently running</Text>
|
|
) : (
|
|
<Box flexDirection="column">
|
|
{teammateTasks.length > 0 && (
|
|
<Box flexDirection="column">
|
|
{(bashTasks.length > 0 ||
|
|
remoteSessions.length > 0 ||
|
|
agentTasks.length > 0) && (
|
|
<Text dimColor>
|
|
<Text bold>{' '}Agents</Text> (
|
|
{count(teammateTasks, i => i.type !== 'leader')})
|
|
</Text>
|
|
)}
|
|
<Box flexDirection="column">
|
|
<TeammateTaskGroups
|
|
teammateTasks={teammateTasks}
|
|
currentSelectionId={currentSelection?.id}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{bashTasks.length > 0 && (
|
|
<Box
|
|
flexDirection="column"
|
|
marginTop={teammateTasks.length > 0 ? 1 : 0}
|
|
>
|
|
{(teammateTasks.length > 0 ||
|
|
remoteSessions.length > 0 ||
|
|
agentTasks.length > 0) && (
|
|
<Text dimColor>
|
|
<Text bold>{' '}Shells</Text> ({bashTasks.length})
|
|
</Text>
|
|
)}
|
|
<Box flexDirection="column">
|
|
{bashTasks.map(item => (
|
|
<Item
|
|
key={item.id}
|
|
item={item}
|
|
isSelected={item.id === currentSelection?.id}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{mcpMonitors.length > 0 && (
|
|
<Box
|
|
flexDirection="column"
|
|
marginTop={
|
|
teammateTasks.length > 0 || bashTasks.length > 0 ? 1 : 0
|
|
}
|
|
>
|
|
<Text dimColor>
|
|
<Text bold>{' '}Monitors</Text> ({mcpMonitors.length})
|
|
</Text>
|
|
<Box flexDirection="column">
|
|
{mcpMonitors.map(item => (
|
|
<Item
|
|
key={item.id}
|
|
item={item}
|
|
isSelected={item.id === currentSelection?.id}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{remoteSessions.length > 0 && (
|
|
<Box
|
|
flexDirection="column"
|
|
marginTop={
|
|
teammateTasks.length > 0 ||
|
|
bashTasks.length > 0 ||
|
|
mcpMonitors.length > 0
|
|
? 1
|
|
: 0
|
|
}
|
|
>
|
|
<Text dimColor>
|
|
<Text bold>{' '}Remote agents</Text> ({remoteSessions.length}
|
|
)
|
|
</Text>
|
|
<Box flexDirection="column">
|
|
{remoteSessions.map(item => (
|
|
<Item
|
|
key={item.id}
|
|
item={item}
|
|
isSelected={item.id === currentSelection?.id}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{agentTasks.length > 0 && (
|
|
<Box
|
|
flexDirection="column"
|
|
marginTop={
|
|
teammateTasks.length > 0 ||
|
|
bashTasks.length > 0 ||
|
|
mcpMonitors.length > 0 ||
|
|
remoteSessions.length > 0
|
|
? 1
|
|
: 0
|
|
}
|
|
>
|
|
<Text dimColor>
|
|
<Text bold>{' '}Local agents</Text> ({agentTasks.length})
|
|
</Text>
|
|
<Box flexDirection="column">
|
|
{agentTasks.map(item => (
|
|
<Item
|
|
key={item.id}
|
|
item={item}
|
|
isSelected={item.id === currentSelection?.id}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{workflowTasks.length > 0 && (
|
|
<Box
|
|
flexDirection="column"
|
|
marginTop={
|
|
teammateTasks.length > 0 ||
|
|
bashTasks.length > 0 ||
|
|
mcpMonitors.length > 0 ||
|
|
remoteSessions.length > 0 ||
|
|
agentTasks.length > 0
|
|
? 1
|
|
: 0
|
|
}
|
|
>
|
|
<Text dimColor>
|
|
<Text bold>{' '}Workflows</Text> ({workflowTasks.length})
|
|
</Text>
|
|
<Box flexDirection="column">
|
|
{workflowTasks.map(item => (
|
|
<Item
|
|
key={item.id}
|
|
item={item}
|
|
isSelected={item.id === currentSelection?.id}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{dreamTasks.length > 0 && (
|
|
<Box
|
|
flexDirection="column"
|
|
marginTop={
|
|
teammateTasks.length > 0 ||
|
|
bashTasks.length > 0 ||
|
|
mcpMonitors.length > 0 ||
|
|
remoteSessions.length > 0 ||
|
|
agentTasks.length > 0 ||
|
|
workflowTasks.length > 0
|
|
? 1
|
|
: 0
|
|
}
|
|
>
|
|
<Box flexDirection="column">
|
|
{dreamTasks.map(item => (
|
|
<Item
|
|
key={item.id}
|
|
item={item}
|
|
isSelected={item.id === currentSelection?.id}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Dialog>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Box flexDirection="row">
|
|
<Text dimColor={useGreyPointer && isSelected}>
|
|
{isSelected ? figures.pointer + ' ' : ' '}
|
|
</Text>
|
|
<Text color={isSelected && !useGreyPointer ? 'suggestion' : undefined}>
|
|
{item.type === 'leader' ? (
|
|
<Text>@{TEAM_LEAD_NAME}</Text>
|
|
) : (
|
|
<BackgroundTaskComponent
|
|
task={item.task}
|
|
maxActivityWidth={maxActivityWidth}
|
|
/>
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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<string, typeof teammateItems>()
|
|
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 (
|
|
<Box key={teamName} flexDirection="column">
|
|
<Text dimColor>
|
|
{' '}Team: {teamName} ({memberCount})
|
|
</Text>
|
|
{/* Render leader first within each team */}
|
|
{leaderItems.map(item => (
|
|
<Item
|
|
key={`${item.id}-${teamName}`}
|
|
item={item}
|
|
isSelected={item.id === currentSelectionId}
|
|
/>
|
|
))}
|
|
{items.map(item => (
|
|
<Item
|
|
key={item.id}
|
|
item={item}
|
|
isSelected={item.id === currentSelectionId}
|
|
/>
|
|
))}
|
|
</Box>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|