import { useEffect, useRef } from 'react' import { KeyboardEvent } from '../ink/events/keyboard-event.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until REPL wires handleKeyDown to import { useInput } from '../ink.js' import { type AppState, useAppState, useSetAppState, } from '../state/AppState.js' import { enterTeammateView, exitTeammateView, } from '../state/teammateViewHelpers.js' import { getRunningTeammatesSorted, InProcessTeammateTask, } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' import { type InProcessTeammateTaskState, isInProcessTeammateTask, } from '../tasks/InProcessTeammateTask/types.js' import { isBackgroundTask } from '../tasks/types.js' // Step teammate selection by delta, wrapping across leader(-1)..teammates(0..n-1)..hide(n). // First step from a collapsed tree expands it and parks on leader. function stepTeammateSelection( delta: 1 | -1, setAppState: (updater: (prev: AppState) => AppState) => void, ): void { setAppState(prev => { const currentCount = getRunningTeammatesSorted(prev.tasks).length if (currentCount === 0) return prev if (prev.expandedView !== 'teammates') { return { ...prev, expandedView: 'teammates' as const, viewSelectionMode: 'selecting-agent', selectedIPAgentIndex: -1, } } const maxIdx = currentCount // hide row const cur = prev.selectedIPAgentIndex const next = delta === 1 ? cur >= maxIdx ? -1 : cur + 1 : cur <= -1 ? maxIdx : cur - 1 return { ...prev, selectedIPAgentIndex: next, viewSelectionMode: 'selecting-agent', } }) } /** * Custom hook that handles Shift+Up/Down keyboard navigation for background tasks. * When teammates (swarm) are present, navigates between leader and teammates. * When only non-teammate background tasks exist, opens the background tasks dialog. * Also handles Enter to confirm selection, 'f' to view transcript, and 'k' to kill. */ export function useBackgroundTaskNavigation(options?: { onOpenBackgroundTasks?: () => void }): { handleKeyDown: (e: KeyboardEvent) => void } { const tasks = useAppState(s => s.tasks) const viewSelectionMode = useAppState(s => s.viewSelectionMode) const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex) const setAppState = useSetAppState() // Filter to running teammates and sort alphabetically to match TeammateSpinnerTree display const teammateTasks = getRunningTeammatesSorted(tasks) const teammateCount = teammateTasks.length // Check for non-teammate background tasks (local_agent, local_bash, etc.) const hasNonTeammateBackgroundTasks = Object.values(tasks).some( t => isBackgroundTask(t) && t.type !== 'in_process_teammate', ) // Track previous teammate count to detect when teammates are removed const prevTeammateCountRef = useRef(teammateCount) // Clamp selection index if teammates are removed or reset when count becomes 0 useEffect(() => { const prevCount = prevTeammateCountRef.current prevTeammateCountRef.current = teammateCount setAppState(prev => { const currentTeammates = getRunningTeammatesSorted(prev.tasks) const currentCount = currentTeammates.length // When teammates are removed (count goes from >0 to 0), reset selection // Only reset if we previously had teammates (not on initial mount with 0) // Don't clobber viewSelectionMode if actively viewing a teammate transcript — // the user may be reviewing a completed teammate and needs escape to exit if ( currentCount === 0 && prevCount > 0 && prev.selectedIPAgentIndex !== -1 ) { if (prev.viewSelectionMode === 'viewing-agent') { return { ...prev, selectedIPAgentIndex: -1, } } return { ...prev, selectedIPAgentIndex: -1, viewSelectionMode: 'none', } } // Clamp if index is out of bounds // Max valid index is currentCount (the "hide" row) when spinner tree is shown const maxIndex = prev.expandedView === 'teammates' ? currentCount : currentCount - 1 if (currentCount > 0 && prev.selectedIPAgentIndex > maxIndex) { return { ...prev, selectedIPAgentIndex: maxIndex, } } return prev }) }, [teammateCount, setAppState]) // Get the selected teammate's task info const getSelectedTeammate = (): { taskId: string task: InProcessTeammateTaskState } | null => { if (teammateCount === 0) return null const selectedIndex = selectedIPAgentIndex const task = teammateTasks[selectedIndex] if (!task) return null return { taskId: task.id, task } } const handleKeyDown = (e: KeyboardEvent): void => { // Escape in viewing mode: // - If teammate is running: abort current work only (stops current turn, teammate stays alive) // - If teammate is not running (completed/killed/failed): exit the view back to leader if (e.key === 'escape' && viewSelectionMode === 'viewing-agent') { e.preventDefault() const taskId = viewingAgentTaskId if (taskId) { const task = tasks[taskId] if (isInProcessTeammateTask(task) && task.status === 'running') { // Abort currentWorkAbortController (stops current turn) NOT abortController (kills teammate) task.currentWorkAbortController?.abort() return } } // Teammate is not running or task doesn't exist — exit the view exitTeammateView(setAppState) return } // Escape in selection mode: exit selection without aborting leader if (e.key === 'escape' && viewSelectionMode === 'selecting-agent') { e.preventDefault() setAppState(prev => ({ ...prev, viewSelectionMode: 'none', selectedIPAgentIndex: -1, })) return } // Shift+Up/Down for teammate transcript switching (with wrapping) // Index -1 represents the leader, 0+ are teammates // When showSpinnerTree is true, index === teammateCount is the "hide" row if (e.shift && (e.key === 'up' || e.key === 'down')) { e.preventDefault() if (teammateCount > 0) { stepTeammateSelection(e.key === 'down' ? 1 : -1, setAppState) } else if (hasNonTeammateBackgroundTasks) { options?.onOpenBackgroundTasks?.() } return } // 'f' to view selected teammate's transcript (only in selecting mode) if ( e.key === 'f' && viewSelectionMode === 'selecting-agent' && teammateCount > 0 ) { e.preventDefault() const selected = getSelectedTeammate() if (selected) { enterTeammateView(selected.taskId, setAppState) } return } // Enter to confirm selection (only when in selecting mode) if (e.key === 'return' && viewSelectionMode === 'selecting-agent') { e.preventDefault() if (selectedIPAgentIndex === -1) { exitTeammateView(setAppState) } else if (selectedIPAgentIndex >= teammateCount) { // "Hide" row selected - collapse the spinner tree setAppState(prev => ({ ...prev, expandedView: 'none' as const, viewSelectionMode: 'none', selectedIPAgentIndex: -1, })) } else { const selected = getSelectedTeammate() if (selected) { enterTeammateView(selected.taskId, setAppState) } } return } // k to kill selected teammate (only in selecting mode) if ( e.key === 'k' && viewSelectionMode === 'selecting-agent' && selectedIPAgentIndex >= 0 ) { e.preventDefault() const selected = getSelectedTeammate() if (selected && selected.task.status === 'running') { void InProcessTeammateTask.kill(selected.taskId, setAppState) } return } } // Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → // KeyboardEvent until the consumer is migrated (separate PR). // TODO(onKeyDown-migration): remove once REPL passes handleKeyDown. useInput((_input, _key, event) => { handleKeyDown(new KeyboardEvent(event.keypress)) }) return { handleKeyDown } }