diff --git a/packages/cli/src/ui/components/fleet-view/FleetView.tsx b/packages/cli/src/ui/components/fleet-view/FleetView.tsx index ff3b18ec5a..800a232afa 100644 --- a/packages/cli/src/ui/components/fleet-view/FleetView.tsx +++ b/packages/cli/src/ui/components/fleet-view/FleetView.tsx @@ -14,28 +14,31 @@ import { theme } from '../../semantic-colors.js'; import type { FleetSessionEntry } from '../../contexts/FleetViewContext.js'; import type { SessionService } from '@qwen-code/qwen-code-core'; -const STATUS_ICONS: Record = { - active: '●', - backgrounded: '○', - idle: '○', -}; +type FleetSessionState = + | 'needs_input' + | 'working' + | 'completed' + | 'idle' + | 'active'; -const STATUS_COLORS: Record = { - active: theme.status.success, - backgrounded: theme.text.secondary, - idle: theme.text.secondary, +const STATE_ICONS: Record = { + active: '✽', + working: '✽', + needs_input: '✻', + idle: '✻', + completed: '✻', }; function formatTimeAgo(mtime: number): string { const diff = Date.now() - mtime; const seconds = Math.floor(diff / 1000); - if (seconds < 60) return `${seconds}s ago`; + if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; + if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); - return `${days}d ago`; + return `${days}d`; } function shortenPath(fullPath: string, maxLen: number): string { @@ -49,51 +52,56 @@ function shortenPath(fullPath: string, maxLen: number): string { return '...' + p.slice(p.length - maxLen + 3); } +function getSessionState(entry: FleetSessionEntry): FleetSessionState { + if (entry.status === 'active') return 'working'; + return 'idle'; +} + +function getIconColor(state: FleetSessionState): string { + switch (state) { + case 'working': + case 'active': + return theme.text.accent; + case 'needs_input': + return theme.status.warning; + case 'completed': + return theme.status.success; + case 'idle': + return theme.text.secondary; + default: + return theme.text.secondary; + } +} + interface SessionRowProps { entry: FleetSessionEntry; selected: boolean; width: number; - renaming?: boolean; - renameValue?: string; } -const SessionRow: React.FC = ({ - entry, - selected, - width, - renaming, - renameValue, -}) => { - const icon = STATUS_ICONS[entry.status] ?? '○'; - const iconColor = STATUS_COLORS[entry.status] ?? theme.text.secondary; +const SessionRow: React.FC = ({ entry, selected, width }) => { + const state = getSessionState(entry); + const icon = STATE_ICONS[state]; + const iconColor = getIconColor(state); const timeStr = formatTimeAgo(entry.mtime); - const pathStr = shortenPath(entry.cwd || '', 30); - const nameMaxLen = Math.max(20, width - 60); - - if (renaming && selected) { - return ( - - {'> '} - {icon} - - {(renameValue ?? '').padEnd(nameMaxLen)} - - - - ); - } + const nameMaxLen = Math.max(15, Math.floor(width * 0.25)); + const summaryMaxLen = Math.max(20, width - nameMaxLen - 15); const name = entry.displayName.length > nameMaxLen - ? entry.displayName.slice(0, nameMaxLen - 3) + '...' + ? entry.displayName.slice(0, nameMaxLen - 1) + '…' : entry.displayName; + const summary = entry.prompt + ? entry.prompt.length > summaryMaxLen + ? entry.prompt.slice(0, summaryMaxLen - 1) + '…' + : entry.prompt + : ''; + return ( - - {selected ? '> ' : ' '} - + {selected ? ' ' : ' '} {icon} = ({ > {name.padEnd(nameMaxLen)} - {pathStr.padEnd(32)} - {timeStr.padStart(8)} + + {' '} + {summary.padEnd(summaryMaxLen)} + + {timeStr.padStart(5)} ); }; interface GroupHeaderProps { label: string; + selected: boolean; } -const GroupHeader: React.FC = ({ label }) => ( +const GroupHeader: React.FC = ({ label, selected }) => ( - {' '} + {selected ? ' ' : ' '} {label} ); -function groupSessions( +function groupByState( sessions: FleetSessionEntry[], - mode: 'state' | 'directory', ): Array<{ label: string; entries: FleetSessionEntry[] }> { - if (mode === 'directory') { - const byDir = new Map(); - for (const s of sessions) { - const key = s.cwd || 'unknown'; - if (!byDir.has(key)) byDir.set(key, []); - byDir.get(key)!.push(s); - } - return Array.from(byDir.entries()).map(([dir, entries]) => ({ - label: shortenPath(dir, 60), - entries, - })); - } - - const active: FleetSessionEntry[] = []; - const backgrounded: FleetSessionEntry[] = []; - const idle: FleetSessionEntry[] = []; + const working: FleetSessionEntry[] = []; + const completed: FleetSessionEntry[] = []; for (const s of sessions) { - if (s.status === 'active') active.push(s); - else if (s.status === 'backgrounded') backgrounded.push(s); - else idle.push(s); + if (s.status === 'active') working.push(s); + else completed.push(s); } const groups: Array<{ label: string; entries: FleetSessionEntry[] }> = []; - if (active.length > 0) groups.push({ label: 'Running', entries: active }); - if (backgrounded.length > 0) - groups.push({ label: 'Backgrounded', entries: backgrounded }); - if (idle.length > 0) groups.push({ label: 'Recent', entries: idle }); + if (working.length > 0) groups.push({ label: 'Working', entries: working }); + if (completed.length > 0) + groups.push({ label: 'Completed', entries: completed }); return groups; } -type FleetMode = 'normal' | 'confirm-delete' | 'rename'; +function groupByDirectory( + sessions: FleetSessionEntry[], +): Array<{ label: string; entries: FleetSessionEntry[] }> { + const byDir = new Map(); + for (const s of sessions) { + const key = shortenPath(s.cwd || 'unknown', 60); + if (!byDir.has(key)) byDir.set(key, []); + byDir.get(key)!.push(s); + } + return Array.from(byDir.entries()).map(([dir, entries]) => ({ + label: dir, + entries, + })); +} + +type ViewMode = 'list' | 'peek' | 'confirm-stop'; export interface FleetViewProps { sessions: FleetSessionEntry[]; @@ -167,6 +177,7 @@ export interface FleetViewProps { onDelete: (sessionId: string) => void; onCreateNew: () => void; onCycleGroupMode: () => void; + onDispatch?: (prompt: string) => void; workspaceCwd?: string; isActive?: boolean; sessionService?: SessionService; @@ -185,13 +196,18 @@ export const FleetView: React.FC = ({ onDelete, onCreateNew, onCycleGroupMode, + onDispatch, workspaceCwd, isActive = true, sessionService, onRefresh, }) => { const { rows, columns } = useTerminalSize(); - const [mode, setMode] = useState('normal'); + const [viewMode, setViewMode] = useState('list'); + const [inputValue, setInputValue] = useState(''); + const [stopPendingId, setStopPendingId] = useState(null); + const stopTimerRef = useRef | null>(null); + const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(''); const [statusMessage, setStatusMessage] = useState(null); const statusTimerRef = useRef | null>(null); @@ -199,6 +215,7 @@ export const FleetView: React.FC = ({ useEffect( () => () => { if (statusTimerRef.current) clearTimeout(statusTimerRef.current); + if (stopTimerRef.current) clearTimeout(stopTimerRef.current); }, [], ); @@ -213,7 +230,10 @@ export const FleetView: React.FC = ({ }, []); const groups = useMemo( - () => groupSessions(sessions, groupMode), + () => + groupMode === 'directory' + ? groupByDirectory(sessions) + : groupByState(sessions), [sessions, groupMode], ); @@ -230,12 +250,16 @@ export const FleetView: React.FC = ({ }, [clampedIndex, selectedIndex, flatEntries.length, onSelect]); const selectedEntry = flatEntries[clampedIndex]; + // Count stats + const workingCount = sessions.filter((s) => s.status === 'active').length; + const completedCount = sessions.length - workingCount; + const handleKeypress = useCallback( (key: Key) => { - // --- Rename mode: capture typed characters --- - if (mode === 'rename') { + // --- Rename mode --- + if (renaming) { if (key.name === 'escape') { - setMode('normal'); + setRenaming(false); setRenameValue(''); return; } @@ -254,7 +278,7 @@ export const FleetView: React.FC = ({ }) .catch(() => showStatus('Rename failed')); } - setMode('normal'); + setRenaming(false); setRenameValue(''); return; } @@ -274,60 +298,131 @@ export const FleetView: React.FC = ({ return; } - // --- Delete confirmation --- - if (mode === 'confirm-delete') { - if (key.name === 'y' && selectedEntry) { - onDelete(selectedEntry.sessionId); - setMode('normal'); + // --- Input has text: dispatch on Enter --- + if (key.name === 'return' && inputValue.length > 0) { + if (onDispatch) { + onDispatch(inputValue); } else { - setMode('normal'); + onCreateNew(); + } + setInputValue(''); + return; + } + + // Printable chars go to dispatch input + if ( + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.meta && + key.name !== 'escape' && + key.name !== 'return' && + key.name !== 'up' && + key.name !== 'down' && + key.name !== 'left' && + key.name !== 'right' && + key.name !== 'space' && + key.name !== 'backspace' && + key.name !== 'delete' && + key.name !== 'tab' + ) { + setInputValue((prev) => prev + key.sequence); + return; + } + + if (key.name === 'backspace' && inputValue.length > 0) { + setInputValue((prev) => prev.slice(0, -1)); + return; + } + + // --- Peek mode --- + if (viewMode === 'peek') { + if (key.name === 'space' || key.name === 'escape') { + setViewMode('list'); + return; + } + if (key.name === 'up') { + onSelect(Math.max(0, clampedIndex - 1)); + return; + } + if (key.name === 'down') { + onSelect(Math.min(flatEntries.length - 1, clampedIndex + 1)); + return; + } + if ((key.name === 'return' || key.name === 'right') && selectedEntry) { + onAttach(selectedEntry.sessionId); + return; } return; } - // --- Normal mode --- - if (key.name === 'up' || key.name === 'k') { + // --- List mode shortcuts --- + if (key.name === 'up') { onSelect(Math.max(0, clampedIndex - 1)); - } else if (key.name === 'down' || key.name === 'j') { + } else if (key.name === 'down') { onSelect(Math.min(flatEntries.length - 1, clampedIndex + 1)); - } else if (key.name === 'return' && selectedEntry) { + } else if ( + (key.name === 'return' || key.name === 'right') && + selectedEntry + ) { onAttach(selectedEntry.sessionId); - } else if (key.name === 'escape' || key.name === 'q') { - onClose(); - } else if (key.name === 'n') { - onCreateNew(); - } else if (key.name === 'd' && selectedEntry) { - if (selectedEntry.status === 'active') { - showStatus('Cannot delete the active session'); - return; + } else if (key.name === 'space' && selectedEntry) { + setViewMode('peek'); + } else if (key.name === 'escape') { + if (inputValue.length > 0) { + setInputValue(''); + } else { + onClose(); } - setMode('confirm-delete'); - } else if (key.name === 'r' && selectedEntry) { - setMode('rename'); - setRenameValue(selectedEntry.displayName); - } else if (key.name === 'tab') { + } else if (key.ctrl && key.name === 'c') { + if (inputValue.length > 0) { + setInputValue(''); + } else { + onClose(); + } + } else if (key.ctrl && key.name === 'x' && selectedEntry) { + // Ctrl+X: stop, then delete on second press within 2s + if (stopPendingId === selectedEntry.sessionId) { + // Second press — delete + clearTimeout(stopTimerRef.current!); + setStopPendingId(null); + onDelete(selectedEntry.sessionId); + showStatus('Session deleted'); + } else { + // First press — stop + setStopPendingId(selectedEntry.sessionId); + showStatus('Press Ctrl+X again to delete'); + if (stopTimerRef.current) clearTimeout(stopTimerRef.current); + stopTimerRef.current = setTimeout(() => { + setStopPendingId(null); + stopTimerRef.current = null; + }, 2000); + } + } else if (key.ctrl && key.name === 's') { onCycleGroupMode(); - } else if (key.name === 'x' && selectedEntry) { - // x = stop (alias for delete for non-active sessions) - if (selectedEntry.status === 'active') { - showStatus('Cannot stop the active session'); - return; + } else if (key.ctrl && key.name === 'r') { + if (selectedEntry) { + setRenaming(true); + setRenameValue(selectedEntry.displayName); } - setMode('confirm-delete'); } }, [ - mode, + renaming, + renameValue, + viewMode, + inputValue, clampedIndex, flatEntries.length, selectedEntry, - renameValue, + stopPendingId, onSelect, onAttach, onClose, onDelete, onCreateNew, onCycleGroupMode, + onDispatch, sessionService, onRefresh, showStatus, @@ -336,14 +431,11 @@ export const FleetView: React.FC = ({ useKeypress(handleKeypress, { isActive }); - const maxVisibleRows = rows - 6; - - // Compute scroll offset to keep selected item visible. - // Each group header takes 1 row, each entry takes 1 row. + // Scroll offset const entryRowIndex = useMemo(() => { let row = 0; for (const group of groups) { - row++; // group header + row++; for (const entry of group.entries) { if (entry === selectedEntry) return row; row++; @@ -352,91 +444,80 @@ export const FleetView: React.FC = ({ return 0; }, [groups, selectedEntry]); + const maxVisibleRows = rows - 8; // header + stats + footer + input const scrollOffset = useMemo(() => { if (entryRowIndex < maxVisibleRows) return 0; return Math.max(0, entryRowIndex - maxVisibleRows + 1); }, [entryRowIndex, maxVisibleRows]); - const footerText = - mode === 'confirm-delete' - ? 'Delete this session permanently? y confirm n cancel' - : mode === 'rename' - ? 'Type new name, enter confirm, esc cancel' - : '↑↓ navigate enter attach n new r rename d delete tab group esc close'; + // Context-aware footer hints + const footerHint = useMemo(() => { + if (renaming) return 'enter to save · esc to cancel'; + if (viewMode === 'peek') + return 'enter to open · space to close · ctrl+x to delete'; + if (selectedEntry) + return 'enter to open · space to reply · ctrl+x to delete · ? for shortcuts'; + return '? for shortcuts'; + }, [renaming, viewMode, selectedEntry]); return ( - {/* Header */} - + {/* Header — no border, plain text like Claude Code */} + - Fleet View + Qwen Code - {workspaceCwd && ( - - {' '} - {shortenPath(workspaceCwd, 40)} - - )} - - group: {groupMode} - {loading ? ' ↻' : ''} + {workspaceCwd ? shortenPath(workspaceCwd, 60) : ''} + + + {workingCount} working · {completedCount} completed + {loading ? ' · ↻' : ''} {/* Status message */} {statusMessage && ( - + {statusMessage} )} {/* Error display */} {error && ( - + Error: {error} )} {/* Session list */} - + {sessions.length === 0 && !loading ? ( - No sessions found. Press{' '} - - n - {' '} - to create a new session. + No sessions. Type a task below and press Enter to start one. ) : ( (() => { - let globalIdx = 0; const rowNodes: React.ReactNode[] = []; + let globalIdx = 0; for (const group of groups) { rowNodes.push( , ); for (const entry of group.entries) { - const idx = globalIdx; rowNodes.push( , ); globalIdx++; @@ -450,14 +531,50 @@ export const FleetView: React.FC = ({ )} - {/* Footer */} - - {footerText} + {/* Peek panel */} + {viewMode === 'peek' && selectedEntry && ( + + + {selectedEntry.displayName} + + + {selectedEntry.prompt || 'No recent output'} + + + )} + + {/* Bottom input — the core interaction, like Claude Code */} + + + {'─'.repeat(columns)} + + + + {renaming ? ( + {renameValue}▏ + ) : ( + + {inputValue || 'describe a task for a new session'} + + )} + + + {'─'.repeat(columns)} + + + {/* Context-aware footer shortcuts */} + + {footerHint} +