mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
2558eac657
commit
eea540201e
1 changed files with 277 additions and 160 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue