import { randomUUID } from 'crypto' import figures from 'figures' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useInterval } from 'usehooks-ts' import { useRegisterOverlay } from '../../context/overlayContext.js' import { stringWidth } from '../../ink/stringWidth.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow dialog navigation import { Box, Text, useInput } from '../../ink.js' import { useKeybindings } from '../../keybindings/useKeybinding.js' import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' import { type AppState, useAppState, useSetAppState, } from '../../state/AppState.js' import { getEmptyToolPermissionContext } from '../../Tool.js' import { AGENT_COLOR_TO_THEME_COLOR } from '../../tools/AgentTool/agentColorManager.js' import { logForDebugging } from '../../utils/debug.js' import { execFileNoThrow } from '../../utils/execFileNoThrow.js' import { truncateToWidth } from '../../utils/format.js' import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js' import { getModeColor, type PermissionMode, permissionModeFromString, permissionModeSymbol, } from '../../utils/permissions/PermissionMode.js' import { jsonStringify } from '../../utils/slowOperations.js' import { IT2_COMMAND, isInsideTmuxSync, } from '../../utils/swarm/backends/detection.js' import { ensureBackendsRegistered, getBackendByType, getCachedBackend, } from '../../utils/swarm/backends/registry.js' import type { PaneBackendType } from '../../utils/swarm/backends/types.js' import { getSwarmSocketName, TMUX_COMMAND, } from '../../utils/swarm/constants.js' import { addHiddenPaneId, removeHiddenPaneId, removeMemberFromTeam, setMemberMode, setMultipleMemberModes, } from '../../utils/swarm/teamHelpers.js' import { listTasks, type Task, unassignTeammateTasks, } from '../../utils/tasks.js' import { getTeammateStatuses, type TeammateStatus, type TeamSummary, } from '../../utils/teamDiscovery.js' import { createModeSetRequestMessage, sendShutdownRequestToMailbox, writeToMailbox, } from '../../utils/teammateMailbox.js' import { Dialog } from '../design-system/Dialog.js' import ThemedText from '../design-system/ThemedText.js' type Props = { initialTeams?: TeamSummary[] onDone: () => void } type DialogLevel = | { type: 'teammateList'; teamName: string } | { type: 'teammateDetail'; teamName: string; memberName: string } /** * Dialog for viewing teammates in the current team */ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode { // Register as overlay so CancelRequestHandler doesn't intercept escape useRegisterOverlay('teams-dialog') // initialTeams is derived from teamContext in PromptInput (no filesystem I/O) const setAppState = useSetAppState() // Initialize dialogLevel with first team name if available const firstTeamName = initialTeams?.[0]?.name ?? '' const [dialogLevel, setDialogLevel] = useState({ type: 'teammateList', teamName: firstTeamName, }) const [selectedIndex, setSelectedIndex] = useState(0) const [refreshKey, setRefreshKey] = useState(0) // initialTeams is now always provided from PromptInput (derived from teamContext) // No filesystem I/O needed here const teammateStatuses = useMemo(() => { return getTeammateStatuses(dialogLevel.teamName) // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: intentional }, [dialogLevel.teamName, refreshKey]) // Periodically refresh to pick up mode changes from teammates useInterval(() => { setRefreshKey(k => k + 1) }, 1000) const currentTeammate = useMemo(() => { if (dialogLevel.type !== 'teammateDetail') return null return teammateStatuses.find(t => t.name === dialogLevel.memberName) ?? null }, [dialogLevel, teammateStatuses]) // Get isBypassPermissionsModeAvailable from AppState const isBypassAvailable = useAppState( s => s.toolPermissionContext.isBypassPermissionsModeAvailable, ) const goBackToList = (): void => { setDialogLevel({ type: 'teammateList', teamName: dialogLevel.teamName }) setSelectedIndex(0) } // Handler for confirm:cycleMode - cycle teammate permission modes const handleCycleMode = useCallback(() => { if (dialogLevel.type === 'teammateDetail' && currentTeammate) { // Detail view: cycle just this teammate cycleTeammateMode( currentTeammate, dialogLevel.teamName, isBypassAvailable, ) setRefreshKey(k => k + 1) } else if ( dialogLevel.type === 'teammateList' && teammateStatuses.length > 0 ) { // List view: cycle all teammates in tandem cycleAllTeammateModes( teammateStatuses, dialogLevel.teamName, isBypassAvailable, ) setRefreshKey(k => k + 1) } }, [dialogLevel, currentTeammate, teammateStatuses, isBypassAvailable]) // Use keybindings for mode cycling useKeybindings( { 'confirm:cycleMode': handleCycleMode }, { context: 'Confirmation' }, ) useInput((input, key) => { // Handle left arrow to go back if (key.leftArrow) { if (dialogLevel.type === 'teammateDetail') { goBackToList() } return } // Handle up/down navigation if (key.upArrow || key.downArrow) { const maxIndex = getMaxIndex() if (key.upArrow) { setSelectedIndex(prev => Math.max(0, prev - 1)) } else { setSelectedIndex(prev => Math.min(maxIndex, prev + 1)) } return } // Handle Enter to drill down or view output if (key.return) { if ( dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex] ) { setDialogLevel({ type: 'teammateDetail', teamName: dialogLevel.teamName, memberName: teammateStatuses[selectedIndex].name, }) } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { // View output - switch to tmux pane void viewTeammateOutput( currentTeammate.tmuxPaneId, currentTeammate.backendType, ) onDone() } return } // Handle 'k' to kill teammate if (input === 'k') { if ( dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex] ) { void killTeammate( teammateStatuses[selectedIndex].tmuxPaneId, teammateStatuses[selectedIndex].backendType, dialogLevel.teamName, teammateStatuses[selectedIndex].agentId, teammateStatuses[selectedIndex].name, setAppState, ).then(() => { setRefreshKey(k => k + 1) // Adjust selection if needed setSelectedIndex(prev => Math.max(0, Math.min(prev, teammateStatuses.length - 2)), ) }) } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { void killTeammate( currentTeammate.tmuxPaneId, currentTeammate.backendType, dialogLevel.teamName, currentTeammate.agentId, currentTeammate.name, setAppState, ) goBackToList() } return } // Handle 's' for shutdown of selected teammate if (input === 's') { if ( dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex] ) { const teammate = teammateStatuses[selectedIndex] void sendShutdownRequestToMailbox( teammate.name, dialogLevel.teamName, 'Graceful shutdown requested by team lead', ) } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { void sendShutdownRequestToMailbox( currentTeammate.name, dialogLevel.teamName, 'Graceful shutdown requested by team lead', ) goBackToList() } return } // Handle 'h' to hide/show individual teammate (only for backends that support it) if (input === 'h') { const backend = getCachedBackend() const teammate = dialogLevel.type === 'teammateList' ? teammateStatuses[selectedIndex] : dialogLevel.type === 'teammateDetail' ? currentTeammate : null if (teammate && backend?.supportsHideShow) { void toggleTeammateVisibility(teammate, dialogLevel.teamName).then( () => { // Force refresh of teammate statuses setRefreshKey(k => k + 1) }, ) if (dialogLevel.type === 'teammateDetail') { goBackToList() } } return } // Handle 'H' to hide/show all teammates (only for backends that support it) if (input === 'H' && dialogLevel.type === 'teammateList') { const backend = getCachedBackend() if (backend?.supportsHideShow && teammateStatuses.length > 0) { // If any are visible, hide all. Otherwise, show all. const anyVisible = teammateStatuses.some(t => !t.isHidden) void Promise.all( teammateStatuses.map(t => anyVisible ? hideTeammate(t, dialogLevel.teamName) : showTeammate(t, dialogLevel.teamName), ), ).then(() => { // Force refresh of teammate statuses setRefreshKey(k => k + 1) }) } return } // Handle 'p' to prune (kill) all idle teammates if (input === 'p' && dialogLevel.type === 'teammateList') { const idleTeammates = teammateStatuses.filter(t => t.status === 'idle') if (idleTeammates.length > 0) { void Promise.all( idleTeammates.map(t => killTeammate( t.tmuxPaneId, t.backendType, dialogLevel.teamName, t.agentId, t.name, setAppState, ), ), ).then(() => { setRefreshKey(k => k + 1) setSelectedIndex(prev => Math.max( 0, Math.min( prev, teammateStatuses.length - idleTeammates.length - 1, ), ), ) }) } return } // Note: Mode cycling (shift+tab) is handled via useKeybindings with confirm:cycleMode action }) function getMaxIndex(): number { if (dialogLevel.type === 'teammateList') { return Math.max(0, teammateStatuses.length - 1) } return 0 } // Render based on dialog level if (dialogLevel.type === 'teammateList') { return ( ) } if (dialogLevel.type === 'teammateDetail' && currentTeammate) { return ( ) } return null } type TeamDetailViewProps = { teamName: string teammates: TeammateStatus[] selectedIndex: number onCancel: () => void } function TeamDetailView({ teamName, teammates, selectedIndex, onCancel, }: TeamDetailViewProps): React.ReactNode { const subtitle = `${teammates.length} ${teammates.length === 1 ? 'teammate' : 'teammates'}` // Check if the backend supports hide/show const supportsHideShow = getCachedBackend()?.supportsHideShow ?? false // Get the display text for the cycle mode shortcut const cycleModeShortcut = useShortcutDisplay( 'confirm:cycleMode', 'Confirmation', 'shift+tab', ) return ( <> {teammates.length === 0 ? ( No teammates ) : ( {teammates.map((teammate, index) => ( ))} )} {figures.arrowUp}/{figures.arrowDown} select · Enter view · k kill · s shutdown · p prune idle {supportsHideShow && ' · h hide/show · H hide/show all'} {' · '} {cycleModeShortcut} sync cycle modes for all · Esc close ) } type TeammateListItemProps = { teammate: TeammateStatus isSelected: boolean } function TeammateListItem({ teammate, isSelected, }: TeammateListItemProps): React.ReactNode { const isIdle = teammate.status === 'idle' // Only dim if idle AND not selected - selection highlighting takes precedence const shouldDim = isIdle && !isSelected // Get mode display const mode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default' const modeSymbol = permissionModeSymbol(mode) const modeColor = getModeColor(mode) return ( {isSelected ? figures.pointer + ' ' : ' '} {teammate.isHidden && [hidden] } {isIdle && [idle] } {modeSymbol && {modeSymbol} }@ {teammate.name} {teammate.model && ({teammate.model})} ) } type TeammateDetailViewProps = { teammate: TeammateStatus teamName: string onCancel: () => void } function TeammateDetailView({ teammate, teamName, onCancel, }: TeammateDetailViewProps): React.ReactNode { const [promptExpanded, setPromptExpanded] = useState(false) // Get the display text for the cycle mode shortcut const cycleModeShortcut = useShortcutDisplay( 'confirm:cycleMode', 'Confirmation', 'shift+tab', ) const themeColor = teammate.color ? AGENT_COLOR_TO_THEME_COLOR[ teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR ] : undefined // Get tasks assigned to this teammate const [teammateTasks, setTeammateTasks] = useState([]) useEffect(() => { let cancelled = false void listTasks(teamName).then(allTasks => { if (cancelled) return // Filter tasks owned by this teammate (by agentId or name) setTeammateTasks( allTasks.filter( task => task.owner === teammate.agentId || task.owner === teammate.name, ), ) }) return () => { cancelled = true } }, [teamName, teammate.agentId, teammate.name]) useInput(input => { // Handle 'p' to expand/collapse prompt if (input === 'p') { setPromptExpanded(prev => !prev) } }) // Determine working directory display const workingPath = teammate.worktreePath || teammate.cwd // Build subtitle with metadata const subtitleParts: string[] = [] if (teammate.model) subtitleParts.push(teammate.model) if (workingPath) { subtitleParts.push( teammate.worktreePath ? `worktree: ${workingPath}` : workingPath, ) } const subtitle = subtitleParts.join(' · ') || undefined // Get mode display for title const mode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default' const modeSymbol = permissionModeSymbol(mode) const modeColor = getModeColor(mode) // Build title with mode symbol and colored name if applicable const title = ( <> {modeSymbol && {modeSymbol} } {themeColor ? ( {`@${teammate.name}`} ) : ( `@${teammate.name}` )} ) return ( <> {/* Tasks section */} {teammateTasks.length > 0 && ( Tasks {teammateTasks.map(task => ( {task.status === 'completed' ? figures.tick : '◼'}{' '} {task.subject} ))} )} {/* Prompt section */} {teammate.prompt && ( Prompt {promptExpanded ? teammate.prompt : truncateToWidth(teammate.prompt, 80)} {stringWidth(teammate.prompt) > 80 && !promptExpanded && ( (p to expand) )} )} {figures.arrowLeft} back · Esc close · k kill · s shutdown {getCachedBackend()?.supportsHideShow && ' · h hide/show'} {' · '} {cycleModeShortcut} cycle mode ) } async function killTeammate( paneId: string, backendType: PaneBackendType | undefined, teamName: string, teammateId: string, teammateName: string, setAppState: (f: (prev: AppState) => AppState) => void, ): Promise { // Kill the pane using the backend that created it (handles -s / -L flags correctly). // Wrapped in try/catch so cleanup (removeMemberFromTeam, unassignTeammateTasks, // setAppState) always runs — matches useInboxPoller.ts error isolation. if (backendType) { try { // Use ensureBackendsRegistered (not detectAndGetBackend) — this process may // be a teammate that never ran detection, but we only need class imports // here, not subprocess probes that could throw in a different environment. await ensureBackendsRegistered() await getBackendByType(backendType).killPane(paneId, !isInsideTmuxSync()) } catch (error) { logForDebugging(`[TeamsDialog] Failed to kill pane ${paneId}: ${error}`) } } else { // backendType undefined: old team files predating this field, or in-process. // Old tmux-file case is a migration gap — the pane is orphaned. In-process // teammates have no pane to kill, so this is correct for them. logForDebugging( `[TeamsDialog] Skipping pane kill for ${paneId}: no backendType recorded`, ) } // Remove from team config file removeMemberFromTeam(teamName, paneId) // Unassign tasks and build notification message const { notificationMessage } = await unassignTeammateTasks( teamName, teammateId, teammateName, 'terminated', ) // Update AppState to keep status line in sync and notify the lead setAppState(prev => { if (!prev.teamContext?.teammates) return prev if (!(teammateId in prev.teamContext.teammates)) return prev const { [teammateId]: _, ...remainingTeammates } = prev.teamContext.teammates return { ...prev, teamContext: { ...prev.teamContext, teammates: remainingTeammates, }, inbox: { messages: [ ...prev.inbox.messages, { id: randomUUID(), from: 'system', text: jsonStringify({ type: 'teammate_terminated', message: notificationMessage, }), timestamp: new Date().toISOString(), status: 'pending' as const, }, ], }, } }) logForDebugging(`[TeamsDialog] Removed ${teammateId} from teamContext`) } async function viewTeammateOutput( paneId: string, backendType: PaneBackendType | undefined, ): Promise { if (backendType === 'iterm2') { // -s is required to target a specific session (ITermBackend.ts:216-217) await execFileNoThrow(IT2_COMMAND, ['session', 'focus', '-s', paneId]) } else { // External-tmux teammates live on the swarm socket — without -L, this // targets the default server and silently no-ops. Mirrors runTmuxInSwarm // in TmuxBackend.ts:85-89. const args = isInsideTmuxSync() ? ['select-pane', '-t', paneId] : ['-L', getSwarmSocketName(), 'select-pane', '-t', paneId] await execFileNoThrow(TMUX_COMMAND, args) } } /** * Toggle visibility of a teammate pane (hide if visible, show if hidden) */ async function toggleTeammateVisibility( teammate: TeammateStatus, teamName: string, ): Promise { if (teammate.isHidden) { await showTeammate(teammate, teamName) } else { await hideTeammate(teammate, teamName) } } /** * Hide a teammate pane using the backend abstraction. * Only available for ant users (gated for dead code elimination in external builds) */ async function hideTeammate( teammate: TeammateStatus, teamName: string, ): Promise { } /** * Show a previously hidden teammate pane using the backend abstraction. * Only available for ant users (gated for dead code elimination in external builds) */ async function showTeammate( teammate: TeammateStatus, teamName: string, ): Promise { } /** * Send a mode change message to a single teammate * Also updates config.json directly so the UI reflects the change immediately */ function sendModeChangeToTeammate( teammateName: string, teamName: string, targetMode: PermissionMode, ): void { // Update config.json directly so UI shows the change immediately setMemberMode(teamName, teammateName, targetMode) // Also send message so teammate updates their local permission context const message = createModeSetRequestMessage({ mode: targetMode, from: 'team-lead', }) void writeToMailbox( teammateName, { from: 'team-lead', text: jsonStringify(message), timestamp: new Date().toISOString(), }, teamName, ) logForDebugging( `[TeamsDialog] Sent mode change to ${teammateName}: ${targetMode}`, ) } /** * Cycle a single teammate's mode */ function cycleTeammateMode( teammate: TeammateStatus, teamName: string, isBypassAvailable: boolean, ): void { const currentMode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default' const context = { ...getEmptyToolPermissionContext(), mode: currentMode, isBypassPermissionsModeAvailable: isBypassAvailable, } const nextMode = getNextPermissionMode(context) sendModeChangeToTeammate(teammate.name, teamName, nextMode) } /** * Cycle all teammates' modes in tandem * If modes differ, reset all to default first * If same, cycle all to next mode * Uses batch update to avoid race conditions */ function cycleAllTeammateModes( teammates: TeammateStatus[], teamName: string, isBypassAvailable: boolean, ): void { if (teammates.length === 0) return const modes = teammates.map(t => t.mode ? permissionModeFromString(t.mode) : 'default', ) const allSame = modes.every(m => m === modes[0]) // Determine target mode for all teammates const targetMode = !allSame ? 'default' : getNextPermissionMode({ ...getEmptyToolPermissionContext(), mode: modes[0] ?? 'default', isBypassPermissionsModeAvailable: isBypassAvailable, }) // Batch update config.json in a single atomic operation const modeUpdates = teammates.map(t => ({ memberName: t.name, mode: targetMode, })) setMultipleMemberModes(teamName, modeUpdates) // Send mailbox messages to each teammate for (const teammate of teammates) { const message = createModeSetRequestMessage({ mode: targetMode, from: 'team-lead', }) void writeToMailbox( teammate.name, { from: 'team-lead', text: jsonStringify(message), timestamp: new Date().toISOString(), }, teamName, ) } logForDebugging( `[TeamsDialog] Sent mode change to all ${teammates.length} teammates: ${targetMode}`, ) }