import figures from 'figures' import * as React from 'react' import { useMemo, useState } from 'react' import { useTerminalSize } from 'src/hooks/useTerminalSize.js' import { stringWidth } from '@anthropic/ink' import { useAppState, useSetAppState } from 'src/state/AppState.js' import { enterTeammateView, exitTeammateView, } from 'src/state/teammateViewHelpers.js' import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js' import { type BackgroundTaskState, isBackgroundTask, type TaskState, } from 'src/tasks/types.js' import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js' import { Box, Text } from '@anthropic/ink' import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName, } from '../../tools/AgentTool/agentColorManager.js' import type { Theme } from '../../utils/theme.js' import { KeyboardShortcutHint } from '@anthropic/ink' import { shouldHideTasksFooter } from './taskStatusUtils.js' type Props = { tasksSelected: boolean isViewingTeammate?: boolean teammateFooterIndex?: number isLeaderIdle?: boolean onOpenDialog?: (taskId?: string) => void } export function BackgroundTaskStatus({ tasksSelected, isViewingTeammate, teammateFooterIndex = 0, isLeaderIdle = false, onOpenDialog, }: Props): React.ReactNode { const setAppState = useSetAppState() const { columns } = useTerminalSize() const tasks = useAppState(s => s.tasks) const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) const runningTasks = useMemo( () => (Object.values(tasks ?? {}) as TaskState[]).filter( t => isBackgroundTask(t) && !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), ), [tasks], ) // Check if all tasks are in-process teammates (team mode) // In spinner-tree mode, don't show teammate pills (teammates appear in the spinner tree) const expandedView = useAppState(s => s.expandedView) const showSpinnerTree = expandedView === 'teammates' const allTeammates = !showSpinnerTree && runningTasks.length > 0 && runningTasks.every(t => t.type === 'in_process_teammate') // Memoize teammate-related computations at the top level (rules of hooks) const teammateEntries = useMemo( () => runningTasks .filter( (t): t is BackgroundTaskState & { type: 'in_process_teammate' } => t.type === 'in_process_teammate', ) .sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName), ), [runningTasks], ) // Build array of all pills with their activity state // Each pill is "@{name}" and separator is " " (1 char) // Sort idle agents to the end, but only when not in selection mode // to avoid reordering while user is arrowing through the list // "main" always stays first regardless of idle state const allPills = useMemo(() => { const mainPill = { name: 'main', color: undefined as keyof Theme | undefined, isIdle: isLeaderIdle, taskId: undefined as string | undefined, } const teammatePills = teammateEntries.map(t => ({ name: t.identity.agentName, color: getAgentThemeColor(t.identity.color), isIdle: t.isIdle, taskId: t.id, })) // Only sort teammates when not selecting to avoid reordering during navigation if (!tasksSelected) { teammatePills.sort((a, b) => { // Active agents first, idle agents last if (a.isIdle !== b.isIdle) return a.isIdle ? 1 : -1 return 0 // Keep original order within each group }) } // main always first, then sorted teammates const pills = [mainPill, ...teammatePills] // Add idx after sorting return pills.map((pill, i) => ({ ...pill, idx: i })) }, [teammateEntries, isLeaderIdle, tasksSelected]) // Calculate pill widths (including separator space, except first) const pillWidths = useMemo( () => allPills.map((pill, i) => { const pillText = `@${pill.name}` // First pill has no leading space, others have 1 space separator return stringWidth(pillText) + (i > 0 ? 1 : 0) }), [allPills], ) if (allTeammates || (!showSpinnerTree && isViewingTeammate)) { const selectedIdx = tasksSelected ? teammateFooterIndex : -1 // Which agent is currently foregrounded (bold) const viewedIdx = viewingAgentTaskId ? teammateEntries.findIndex(t => t.id === viewingAgentTaskId) + 1 : 0 // 0 = main/leader // Calculate available width for pills // Reserve space for: arrows, hint, and minimal padding // Pills are rendered on their own line when in team mode const ARROW_WIDTH = 2 // arrow char + space const HINT_WIDTH = 20 // shift+↓ to expand const PADDING = 4 // minimal safety margin const availableWidth = Math.max(20, columns - HINT_WIDTH - PADDING) // Calculate visible window of pills const { startIndex, endIndex, showLeftArrow, showRightArrow } = calculateHorizontalScrollWindow( pillWidths, availableWidth, ARROW_WIDTH, selectedIdx >= 0 ? selectedIdx : 0, ) const visiblePills = allPills.slice(startIndex, endIndex) return ( <> {showLeftArrow && {figures.arrowLeft} } {visiblePills.map((pill, i) => { // First visible pill has no leading separator // (left arrow already provides spacing if present) const needsSeparator = i > 0 return ( {needsSeparator && } pill.taskId ? enterTeammateView(pill.taskId, setAppState) : exitTeammateView(setAppState) } /> ) })} {showRightArrow && {figures.arrowRight}} {' · '} ) } // In spinner-tree mode, don't show any footer status for teammates // (they appear in the spinner tree above) if (shouldHideTasksFooter(tasks ?? {}, showSpinnerTree)) { return null } if (runningTasks.length === 0) { return null } return ( <> {getPillLabel(runningTasks)} {pillNeedsCta(runningTasks) && ( · {figures.arrowDown} to view )} ) } type AgentPillProps = { name: string color?: keyof Theme isSelected: boolean isViewed: boolean isIdle: boolean onClick?: () => void } function AgentPill({ name, color, isSelected, isViewed, isIdle, onClick, }: AgentPillProps): React.ReactNode { const [hover, setHover] = useState(false) // Hover mirrors the keyboard-selected look so the affordance is familiar. const highlighted = isSelected || hover let label: React.ReactNode if (highlighted) { label = color ? ( @{name} ) : ( @{name} ) } else if (isIdle) { label = ( @{name} ) } else if (isViewed) { label = ( @{name} ) } else { label = ( @{name} ) } if (!onClick) return label return ( setHover(true)} onMouseLeave={() => setHover(false)} > {label} ) } function SummaryPill({ selected, onClick, children, }: { selected: boolean onClick?: () => void children: React.ReactNode }): React.ReactNode { const [hover, setHover] = useState(false) const label = ( {children} ) if (!onClick) return label return ( setHover(true)} onMouseLeave={() => setHover(false)} > {label} ) } function getAgentThemeColor( colorName: string | undefined, ): keyof Theme | undefined { if (!colorName) return undefined if (AGENT_COLORS.includes(colorName as AgentColorName)) { return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName] } return undefined }