/** * AgentViewDashboard — Ink TUI for background agent session management. * * Displays sessions grouped by status (needs-input / working / completed / failed), * with j/k navigation, Space peek, Enter attach, and Esc exit. * * Usage: * mipham agents (from CLI) * /agents (from slash command within a running session) */ import React, { useState, useCallback, useMemo } from 'react' import { Box, Text, useInput } from 'ink' import { AgentViewManager, type AgentSession, type SessionStatus } from './agent-view-manager' import { SessionRow } from './session-row' import { SessionPeek } from './session-peek' import { useCtrlCConfirm } from '../ui/ctrl-c-confirm' interface DashboardProps { manager: AgentViewManager onAttach?: (session: AgentSession) => void onExit: () => void } const STATUS_HEADERS: Record = { 'needs-input': { label: 'Needs Input', color: 'yellow' }, working: { label: 'Working', color: 'cyan' }, completed: { label: 'Completed', color: 'green' }, failed: { label: 'Failed', color: 'red' }, } export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps) { const [selectedIndex, setSelectedIndex] = useState(0) const [peekingSessionId, setPeekingSessionId] = useState(null) const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status') const [feedback, setFeedback] = useState(null) // Bump to force flatList recompute after a session is removed (list membership change). const [version, setVersion] = useState(0) // Ctrl+C 的「再按一次才退」,与主界面同源(见 ui/ctrl-c-confirm.ts) const ctrlC = useCtrlCConfirm() // Flash a brief feedback message that auto-clears const showFeedback = useCallback((msg: string) => { setFeedback(msg) setTimeout(() => setFeedback(null), 1800) }, []) // Build a flat list of sessions in group order, with group headers const flatList = useMemo(() => { const result: Array< | { type: 'header'; key: string; label: string; color: string; count: number } | { type: 'session'; session: AgentSession } > = [] if (groupBy === 'directory') { for (const group of manager.groupByDirectory()) { result.push({ type: 'header', key: `dir-${group.directory}`, label: group.directory, color: 'blue', count: group.sessions.length, }) for (const session of group.sessions) { result.push({ type: 'session', session }) } } } else { const groups = manager.groupByStatus() const statusOrder: Array = [ 'working', 'needs-input', 'completed', 'failed', ] for (const _status of statusOrder) { const status = _status as SessionStatus const sessions = groups[status] ?? [] result.push({ type: 'header', key: `status-${status}`, label: STATUS_HEADERS[status]!.label, color: STATUS_HEADERS[status]!.color, count: sessions.length, }) for (const session of sessions) { result.push({ type: 'session', session }) } } } return result }, [manager, groupBy, version]) // Flatten sessions only for navigation (skip headers) const sessionsOnly = useMemo( () => flatList.filter((item) => item.type === 'session') as Array<{ type: 'session' session: AgentSession }>, [flatList], ) const handleAttach = useCallback( (sessionId: string) => { const session = manager.attach(sessionId) if (session && onAttach) { onAttach(session) } }, [manager, onAttach], ) useInput((input, key) => { // Ctrl+C 不再一下就退出(Ink 的 `exitOnCtrlC` 已在 render 处关掉,见 // src/index.tsx):第一次只提示,再按一次才走。面板里 Esc 已经是退出键, // 所以这里只补「误按一次不带走整个面板」。 if (key.ctrl && input === 'c') { if (peekingSessionId) { setPeekingSessionId(null) ctrlC.reset() return } if (ctrlC.isArmed()) { onExit() return } ctrlC.arm() showFeedback('Ctrl+C again to exit') return } if (key.escape) { if (peekingSessionId) { setPeekingSessionId(null) return } onExit() return } // Ctrl+T — toggle group by (status ↔ directory) if (key.ctrl && input === 't') { setGroupBy((prev) => (prev === 'status' ? 'directory' : 'status')) showFeedback(`Grouped by ${groupBy === 'status' ? 'directory' : 'status'}`) return } // Ctrl+R — rename selected session if (key.ctrl && input === 'r') { if (sessionsOnly.length === 0) { showFeedback('No sessions to rename') return } const current = sessionsOnly[selectedIndex] if (!current) return const newTitle = `session-${Date.now().toString(36)}` manager.rename(current.session.id, newTitle) showFeedback(`Renamed to ${newTitle}`) return } // Ctrl+X — permanently remove the selected session if (key.ctrl && input === 'x') { if (sessionsOnly.length === 0) { showFeedback('No sessions to remove') return } const current = sessionsOnly[selectedIndex] if (!current) return manager.remove(current.session.id) setPeekingSessionId(null) setSelectedIndex((prev) => Math.max(0, Math.min(prev, sessionsOnly.length - 2))) setVersion((v) => v + 1) showFeedback(`Removed ${current.session.title || current.session.id}`) return } if (input === 'j') { if (sessionsOnly.length === 0) { showFeedback('No sessions to navigate — spawn a background agent first') return } setSelectedIndex((prev) => Math.min(prev + 1, sessionsOnly.length - 1)) setPeekingSessionId(null) return } if (input === 'k') { if (sessionsOnly.length === 0) { showFeedback('No sessions to navigate — spawn a background agent first') return } setSelectedIndex((prev) => Math.max(prev - 1, 0)) setPeekingSessionId(null) return } // Space — toggle peek if (input === ' ') { if (sessionsOnly.length === 0) { showFeedback('No sessions to peek') return } const current = sessionsOnly[selectedIndex] if (!current) return setPeekingSessionId(peekingSessionId === current.session.id ? null : current.session.id) return } // Enter — attach to selected session if (key.return) { if (sessionsOnly.length === 0) { showFeedback('No sessions to attach — spawn a background agent first') return } const current = sessionsOnly[selectedIndex] if (!current) return if (peekingSessionId) { handleAttach(current.session.id) } else { handleAttach(current.session.id) } return } }) // Compute the peek data for the currently peeking session const peekData = useMemo(() => { if (!peekingSessionId) return null return manager.peek(peekingSessionId) ?? null }, [manager, peekingSessionId]) const totalSessions = sessionsOnly.length const counts = manager.countByStatus() return ( {/* Header */} Agent View — Background Agent Dashboard {totalSessions} session{totalSessions !== 1 ? 's' : ''} {' · '} {counts.working} working {' · '} {counts['needs-input']} input {' · '} {counts.completed} done {' · '} {counts.failed} failed j/k navigate · Space peek · Enter attach · Ctrl+T group · Ctrl+R rename · Ctrl+X remove · Esc back {/* Divider */} {'─'.repeat(70)} {/* Empty state */} {totalSessions === 0 ? ( No background agents. Use the Agent tool or type "run this in background" to spawn one. {feedback && ( ⚡ {feedback} )} ) : ( {/* Session list with group headers */} {flatList.map((item, _flatIdx) => { if (item.type === 'header') { return ( {' '} {item.label} ({item.count}) ) } // Map this session's position in sessionsOnly to selectedIndex const sessionIdx = sessionsOnly.findIndex((s) => s.session.id === item.session.id) return ( ) })} )} {/* Peek panel (shown below the list when peeking) */} {peekData && ( )} {/* Feedback toast — flashes briefly on action */} {feedback && ( ⚡ {feedback} )} ) }