Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
8.6 kB · 251 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252import { 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 <Box onKeyDown>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<number>(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 // <Box onKeyDown>. 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 }}