refactor(cli): rewrite Fleet View to match Claude Code agent view UI

Complete rewrite based on Claude Code's actual agent view observed via
tmux (`claude agents`) and official docs (code.claude.com/docs/en/agent-view):

- Remove border boxes, use plain text layout matching Claude Code
- Header: product name + workspace + stats (N working · N completed)
- Session groups: Working / Completed (not the hallucinated Running/Recent)
- Session icons: ✽ (working) / ✻ (idle/completed) matching Claude Code
- Bottom dispatch input: ❯ describe a task for a new session
- Correct shortcuts: Enter=attach, Space=peek, Ctrl+X=stop/delete,
  Ctrl+S=group, Ctrl+R=rename, Esc=close
- Remove hallucinated shortcuts (n/r/d/tab)
- Add peek panel (Space toggle)
- Ctrl+X two-press stop-then-delete with 2s timeout
- Context-aware footer hints per mode
- Fix Enter: dispatch only when input has text, attach when empty
This commit is contained in:
LaZzyMan 2026-07-08 12:04:28 +08:00
parent 2558eac657
commit eea540201e

View file

@ -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<string, string> = {
active: '●',
backgrounded: '○',
idle: '○',
};
type FleetSessionState =
| 'needs_input'
| 'working'
| 'completed'
| 'idle'
| 'active';
const STATUS_COLORS: Record<string, string> = {
active: theme.status.success,
backgrounded: theme.text.secondary,
idle: theme.text.secondary,
const STATE_ICONS: Record<FleetSessionState, string> = {
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<SessionRowProps> = ({
entry,
selected,
width,
renaming,
renameValue,
}) => {
const icon = STATUS_ICONS[entry.status] ?? '○';
const iconColor = STATUS_COLORS[entry.status] ?? theme.text.secondary;
const SessionRow: React.FC<SessionRowProps> = ({ 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 (
<Box>
<Text color={theme.text.accent}>{'> '}</Text>
<Text color={iconColor}>{icon} </Text>
<Text color={theme.text.primary} bold>
{(renameValue ?? '').padEnd(nameMaxLen)}
</Text>
<Text color={theme.text.secondary}> </Text>
</Box>
);
}
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 (
<Box>
<Text color={selected ? theme.text.accent : undefined}>
{selected ? '> ' : ' '}
</Text>
<Text>{selected ? ' ' : ' '}</Text>
<Text color={iconColor}>{icon} </Text>
<Text
color={selected ? theme.text.primary : theme.text.secondary}
@ -101,59 +109,61 @@ const SessionRow: React.FC<SessionRowProps> = ({
>
{name.padEnd(nameMaxLen)}
</Text>
<Text color={theme.text.secondary}> {pathStr.padEnd(32)}</Text>
<Text color={theme.text.secondary}> {timeStr.padStart(8)}</Text>
<Text color={theme.text.secondary}>
{' '}
{summary.padEnd(summaryMaxLen)}
</Text>
<Text color={theme.text.secondary}>{timeStr.padStart(5)}</Text>
</Box>
);
};
interface GroupHeaderProps {
label: string;
selected: boolean;
}
const GroupHeader: React.FC<GroupHeaderProps> = ({ label }) => (
const GroupHeader: React.FC<GroupHeaderProps> = ({ label, selected }) => (
<Box marginTop={1}>
<Text color={theme.text.secondary} bold>
{' '}
{selected ? ' ' : ' '}
{label}
</Text>
</Box>
);
function groupSessions(
function groupByState(
sessions: FleetSessionEntry[],
mode: 'state' | 'directory',
): Array<{ label: string; entries: FleetSessionEntry[] }> {
if (mode === 'directory') {
const byDir = new Map<string, FleetSessionEntry[]>();
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<string, FleetSessionEntry[]>();
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<FleetViewProps> = ({
onDelete,
onCreateNew,
onCycleGroupMode,
onDispatch,
workspaceCwd,
isActive = true,
sessionService,
onRefresh,
}) => {
const { rows, columns } = useTerminalSize();
const [mode, setMode] = useState<FleetMode>('normal');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [inputValue, setInputValue] = useState('');
const [stopPendingId, setStopPendingId] = useState<string | null>(null);
const stopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -199,6 +215,7 @@ export const FleetView: React.FC<FleetViewProps> = ({
useEffect(
() => () => {
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
if (stopTimerRef.current) clearTimeout(stopTimerRef.current);
},
[],
);
@ -213,7 +230,10 @@ export const FleetView: React.FC<FleetViewProps> = ({
}, []);
const groups = useMemo(
() => groupSessions(sessions, groupMode),
() =>
groupMode === 'directory'
? groupByDirectory(sessions)
: groupByState(sessions),
[sessions, groupMode],
);
@ -230,12 +250,16 @@ export const FleetView: React.FC<FleetViewProps> = ({
}, [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<FleetViewProps> = ({
})
.catch(() => showStatus('Rename failed'));
}
setMode('normal');
setRenaming(false);
setRenameValue('');
return;
}
@ -274,60 +298,131 @@ export const FleetView: React.FC<FleetViewProps> = ({
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<FleetViewProps> = ({
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<FleetViewProps> = ({
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 (
<AlternateScreen>
<Box flexDirection="column" height={rows} width={columns}>
{/* Header */}
<Box
paddingX={2}
borderStyle="single"
borderColor={theme.border.default}
flexShrink={0}
>
{/* Header — no border, plain text like Claude Code */}
<Box flexDirection="column" paddingX={1} flexShrink={0}>
<Text color={theme.text.accent} bold>
Fleet View
Qwen Code
</Text>
{workspaceCwd && (
<Text color={theme.text.secondary}>
{' '}
{shortenPath(workspaceCwd, 40)}
</Text>
)}
<Box flexGrow={1} />
<Text color={theme.text.secondary}>
group: {groupMode}
{loading ? ' ↻' : ''}
{workspaceCwd ? shortenPath(workspaceCwd, 60) : ''}
</Text>
<Text color={theme.text.secondary}>
{workingCount} working · {completedCount} completed
{loading ? ' · ↻' : ''}
</Text>
</Box>
{/* Status message */}
{statusMessage && (
<Box paddingX={2}>
<Box paddingX={1}>
<Text color={theme.status.warning}>{statusMessage}</Text>
</Box>
)}
{/* Error display */}
{error && (
<Box paddingX={2}>
<Box paddingX={1}>
<Text color={theme.status.error}>Error: {error}</Text>
</Box>
)}
{/* Session list */}
<Box flexDirection="column" flexGrow={1} paddingX={1}>
<Box flexDirection="column" flexGrow={1} paddingX={0}>
{sessions.length === 0 && !loading ? (
<Box paddingX={1} paddingY={1}>
<Text color={theme.text.secondary}>
No sessions found. Press{' '}
<Text bold color={theme.text.primary}>
n
</Text>{' '}
to create a new session.
No sessions. Type a task below and press Enter to start one.
</Text>
</Box>
) : (
(() => {
let globalIdx = 0;
const rowNodes: React.ReactNode[] = [];
let globalIdx = 0;
for (const group of groups) {
rowNodes.push(
<GroupHeader
key={`group-${group.label}`}
key={`g-${group.label}`}
label={group.label}
selected={false}
/>,
);
for (const entry of group.entries) {
const idx = globalIdx;
rowNodes.push(
<SessionRow
key={entry.sessionId}
entry={entry}
selected={idx === clampedIndex}
selected={globalIdx === clampedIndex}
width={columns}
renaming={mode === 'rename' && idx === clampedIndex}
renameValue={renameValue}
/>,
);
globalIdx++;
@ -450,14 +531,50 @@ export const FleetView: React.FC<FleetViewProps> = ({
)}
</Box>
{/* Footer */}
<Box
paddingX={2}
borderStyle="single"
borderColor={theme.border.default}
flexShrink={0}
>
<Text color={theme.text.secondary}>{footerText}</Text>
{/* Peek panel */}
{viewMode === 'peek' && selectedEntry && (
<Box
flexDirection="column"
paddingX={1}
borderStyle="round"
borderColor={theme.border.default}
flexShrink={0}
height={6}
>
<Text color={theme.text.primary} bold>
{selectedEntry.displayName}
</Text>
<Text color={theme.text.secondary}>
{selectedEntry.prompt || 'No recent output'}
</Text>
</Box>
)}
{/* Bottom input — the core interaction, like Claude Code */}
<Box flexDirection="column" flexShrink={0}>
<Box paddingX={0}>
<Text color={theme.text.secondary}>{'─'.repeat(columns)}</Text>
</Box>
<Box paddingX={1}>
<Text color={theme.text.accent}> </Text>
{renaming ? (
<Text color={theme.text.primary}>{renameValue}</Text>
) : (
<Text
color={inputValue ? theme.text.primary : theme.text.secondary}
>
{inputValue || 'describe a task for a new session'}
</Text>
)}
</Box>
<Box paddingX={0}>
<Text color={theme.text.secondary}>{'─'.repeat(columns)}</Text>
</Box>
{/* Context-aware footer shortcuts */}
<Box paddingX={1}>
<Text color={theme.text.secondary}>{footerHint}</Text>
</Box>
</Box>
</Box>
</AlternateScreen>