/** * CoordinatorTaskPanel — Steerable list of background agents. * * Renders below the prompt input footer whenever local_agent tasks exist. * Visibility is driven by evictAfter: undefined (running/retained) shows * always; a timestamp shows until passed. Enter to view/steer, x to dismiss. */ import figures from 'figures' import * as React from 'react' import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' import { Box, Text, stringWidth, wrapText } from '@anthropic/ink' import { type AppState, useAppState, useSetAppState, } from '../state/AppState.js' import { enterTeammateView, exitTeammateView, } from '../state/teammateViewHelpers.js' import { isPanelAgentTask, type LocalAgentTaskState, } from '../tasks/LocalAgentTask/LocalAgentTask.js' import { formatDuration, formatNumber } from '../utils/format.js' import { evictTerminalTask } from '../utils/task/framework.js' import { isTerminalStatus } from './tasks/taskStatusUtils.js' /** * Which panel-managed tasks currently have a visible row. * Presence in AppState.tasks IS visibility — the 1s tick in * CoordinatorTaskPanel evicts tasks past their evictAfter deadline. The * evictAfter !== 0 check handles immediate dismiss (x key) without making * the filter time-dependent. Shared by panel render, useCoordinatorTaskCount, * and index resolvers so the math can't drift. */ export function getVisibleAgentTasks( tasks: AppState['tasks'], ): LocalAgentTaskState[] { return Object.values(tasks) .filter( (t): t is LocalAgentTaskState => isPanelAgentTask(t) && t.evictAfter !== 0, ) .sort((a, b) => a.startTime - b.startTime) } export function CoordinatorTaskPanel(): React.ReactNode { const tasks = useAppState(s => s.tasks) const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) const agentNameRegistry = useAppState(s => s.agentNameRegistry) const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) const tasksSelected = useAppState(s => s.footerSelection === 'tasks') const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined const setAppState = useSetAppState() const visibleTasks = getVisibleAgentTasks(tasks) const hasTasks = Object.values(tasks).some(isPanelAgentTask) // 1s tick: re-render for elapsed time + evict tasks past their deadline. // The eviction deletes from prev.tasks, which makes useCoordinatorTaskCount // (and other consumers) see the updated count without their own tick. const tasksRef = React.useRef(tasks) tasksRef.current = tasks const [, setTick] = React.useState(0) React.useEffect(() => { if (!hasTasks) return const interval = setInterval( (tasksRef, setAppState, setTick) => { const now = Date.now() for (const t of Object.values(tasksRef.current)) { if (isPanelAgentTask(t) && (t.evictAfter ?? Infinity) <= now) { evictTerminalTask(t.id, setAppState) } } setTick((prev: number) => prev + 1) }, 1000, tasksRef, setAppState, setTick, ) return () => clearInterval(interval) }, [hasTasks, setAppState]) const nameByAgentId = React.useMemo(() => { const inv = new Map() for (const [n, id] of agentNameRegistry) inv.set(id, n) return inv }, [agentNameRegistry]) if (visibleTasks.length === 0) { return null } return ( exitTeammateView(setAppState)} /> {visibleTasks.map((task, i) => ( enterTeammateView(task.id, setAppState)} /> ))} ) } /** * Returns the number of visible coordinator tasks (for selection bounds). * The panel's 1s tick evicts expired tasks from prev.tasks, so this count * stays accurate without needing its own tick. */ export function useCoordinatorTaskCount(): number { const tasks = useAppState(s => s.tasks) return React.useMemo(() => { if ((process.env.USER_TYPE as string) !== 'ant') return 0 const count = getVisibleAgentTasks(tasks).length return count > 0 ? count + 1 : 0 }, [tasks]) } function MainLine({ isSelected, isViewed, onClick, }: { isSelected?: boolean isViewed?: boolean onClick: () => void }): React.ReactNode { const [hover, setHover] = React.useState(false) const prefix = isSelected || hover ? figures.pointer + ' ' : ' ' const bullet = isViewed ? BLACK_CIRCLE : figures.circle return ( setHover(true)} onMouseLeave={() => setHover(false)} > {prefix} {bullet} main ) } type AgentLineProps = { task: LocalAgentTaskState name?: string isSelected?: boolean isViewed?: boolean onClick?: () => void } function AgentLine({ task, name, isSelected, isViewed, onClick, }: AgentLineProps): React.ReactNode { const { columns } = useTerminalSize() const [hover, setHover] = React.useState(false) const isRunning = !isTerminalStatus(task.status) const pausedMs = task.totalPausedMs ?? 0 const elapsedMs = Math.max( 0, isRunning ? Date.now() - task.startTime - pausedMs : (task.endTime ?? task.startTime) - task.startTime - pausedMs, ) const elapsed = formatDuration(elapsedMs) const tokenCount = task.progress?.tokenCount // Derive direction arrow from activity state, same logic as Spinner const lastActivity = task.progress?.lastActivity const arrow = lastActivity ? figures.arrowDown : figures.arrowUp const tokenText = tokenCount !== undefined && tokenCount > 0 ? ` · ${arrow} ${formatNumber(tokenCount)} tokens` : '' const queuedCount = task.pendingMessages.length const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : '' // Precedence: AI summary > static description (no tool-call activity noise) const displayDescription = task.progress?.summary || task.description const highlighted = isSelected || hover const prefix = highlighted ? figures.pointer + ' ' : ' ' const bullet = isViewed ? BLACK_CIRCLE : figures.circle const dim = !highlighted && !isViewed const sep = isRunning ? PLAY_ICON : PAUSE_ICON // Name is the steering handle — kept out of truncation and undimmed so it // stays readable even when the row is inactive. Short by convention (the // Agent tool prompt asks for "one or two words, lowercase"). const namePart = name ? `${name}: ` : '' const hintPart = isSelected && !isViewed ? ` · x to ${isRunning ? 'stop' : 'clear'}` : '' const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}` const availableForDesc = columns - stringWidth(prefix) - stringWidth(`${bullet} `) - stringWidth(namePart) - stringWidth(suffixPart) const truncated = wrapText( displayDescription, Math.max(0, availableForDesc), 'truncate-end', ) const line = ( {prefix} {bullet}{' '} {name && ( <> {name} {': '} )} {truncated} {sep} {elapsed} {tokenText} {queuedCount > 0 && {queuedText}} {hintPart && {hintPart}} ) if (!onClick) return line return ( setHover(true)} onMouseLeave={() => setHover(false)} > {line} ) }