📄 File detail
hooks/useBackgroundTaskNavigation.ts
🧩 .ts📏 252 lines💾 8,553 bytes📝 text
← Back to All Files🎯 Use case
This file lives under “hooks/”, which covers reusable UI or integration hooks. On the API surface it exposes useBackgroundTaskNavigation — mainly functions, hooks, or classes. Dependencies touch React UI. It composes internal code from ink, state, and tasks (relative imports).
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
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 <Box onKeyDown> import { useInput } from '../ink.js' import {
📤 Exports (heuristic)
useBackgroundTaskNavigation
📚 External import roots
Package roots from from "…" (relative paths omitted).
react
🖥️ Source preview
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 <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 }
}