feat(cli): conversation sidebar with list/create/switch/delete (TUI-008)
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, useApp, useInput } from 'ink';
|
||||
import { TopBar } from './components/top-bar.js';
|
||||
import { BottomBar } from './components/bottom-bar.js';
|
||||
import { MessageList } from './components/message-list.js';
|
||||
import { InputBar } from './components/input-bar.js';
|
||||
import { Sidebar } from './components/sidebar.js';
|
||||
import { useSocket } from './hooks/use-socket.js';
|
||||
import { useGitInfo } from './hooks/use-git-info.js';
|
||||
import { useViewport } from './hooks/use-viewport.js';
|
||||
import { useAppMode } from './hooks/use-app-mode.js';
|
||||
import { useConversations } from './hooks/use-conversations.js';
|
||||
|
||||
export interface TuiAppProps {
|
||||
gatewayUrl: string;
|
||||
@@ -17,6 +20,7 @@ export interface TuiAppProps {
|
||||
export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProps) {
|
||||
const { exit } = useApp();
|
||||
const gitInfo = useGitInfo();
|
||||
const appMode = useAppMode();
|
||||
|
||||
const socket = useSocket({
|
||||
gatewayUrl,
|
||||
@@ -24,18 +28,47 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
||||
initialConversationId: conversationId,
|
||||
});
|
||||
|
||||
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||
|
||||
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||
|
||||
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||
|
||||
const handleSwitchConversation = useCallback(
|
||||
(id: string) => {
|
||||
socket.switchConversation(id);
|
||||
appMode.setMode('chat');
|
||||
},
|
||||
[socket, appMode],
|
||||
);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
void conversations.deleteConversation(id).then((ok) => {
|
||||
if (ok && id === socket.conversationId) {
|
||||
socket.clearMessages();
|
||||
}
|
||||
});
|
||||
},
|
||||
[conversations, socket],
|
||||
);
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.ctrl && ch === 'c') {
|
||||
exit();
|
||||
}
|
||||
// Page Up / Page Down: scroll message history
|
||||
if (key.pageUp) {
|
||||
viewport.scrollBy(-viewport.viewportSize);
|
||||
// Ctrl+B: toggle sidebar
|
||||
if (key.ctrl && ch === 'b') {
|
||||
appMode.toggleSidebar();
|
||||
}
|
||||
if (key.pageDown) {
|
||||
viewport.scrollBy(viewport.viewportSize);
|
||||
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||
if (appMode.mode === 'chat') {
|
||||
if (key.pageUp) {
|
||||
viewport.scrollBy(-viewport.viewportSize);
|
||||
}
|
||||
if (key.pageDown) {
|
||||
viewport.scrollBy(viewport.viewportSize);
|
||||
}
|
||||
}
|
||||
// Ctrl+T: cycle thinking level
|
||||
if (key.ctrl && ch === 't') {
|
||||
@@ -49,22 +82,17 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
||||
}
|
||||
}
|
||||
}
|
||||
// Escape: return to chat from sidebar
|
||||
if (key.escape && appMode.mode === 'sidebar') {
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Box marginTop={1} />
|
||||
<TopBar
|
||||
gatewayUrl={gatewayUrl}
|
||||
version="0.0.0"
|
||||
modelName={socket.modelName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
contextWindow={socket.tokenUsage.contextWindow}
|
||||
agentName="default"
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
/>
|
||||
const inputPlaceholder =
|
||||
appMode.mode !== 'chat' ? 'focus is on sidebar… press Esc to return' : undefined;
|
||||
|
||||
const messageArea = (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<MessageList
|
||||
messages={socket.messages}
|
||||
isStreaming={socket.isStreaming}
|
||||
@@ -80,7 +108,43 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
||||
onSubmit={socket.sendMessage}
|
||||
isStreaming={socket.isStreaming}
|
||||
connected={socket.connected}
|
||||
placeholder={inputPlaceholder}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Box marginTop={1} />
|
||||
<TopBar
|
||||
gatewayUrl={gatewayUrl}
|
||||
version="0.0.0"
|
||||
modelName={socket.modelName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
contextWindow={socket.tokenUsage.contextWindow}
|
||||
agentName="default"
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
/>
|
||||
|
||||
{appMode.sidebarOpen ? (
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<Sidebar
|
||||
conversations={conversations.conversations}
|
||||
activeConversationId={socket.conversationId}
|
||||
selectedIndex={sidebarSelectedIndex}
|
||||
onSelectIndex={setSidebarSelectedIndex}
|
||||
onSwitchConversation={handleSwitchConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
loading={conversations.loading}
|
||||
focused={appMode.mode === 'sidebar'}
|
||||
width={30}
|
||||
/>
|
||||
{messageArea}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexGrow={1}>{messageArea}</Box>
|
||||
)}
|
||||
|
||||
<BottomBar
|
||||
gitInfo={gitInfo}
|
||||
|
||||
@@ -6,9 +6,15 @@ export interface InputBarProps {
|
||||
onSubmit: (value: string) => void;
|
||||
isStreaming: boolean;
|
||||
connected: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) {
|
||||
export function InputBar({
|
||||
onSubmit,
|
||||
isStreaming,
|
||||
connected,
|
||||
placeholder: placeholderOverride,
|
||||
}: InputBarProps) {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
@@ -20,11 +26,13 @@ export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) {
|
||||
[onSubmit, isStreaming, connected],
|
||||
);
|
||||
|
||||
const placeholder = !connected
|
||||
? 'disconnected — waiting for gateway…'
|
||||
: isStreaming
|
||||
? 'waiting for response…'
|
||||
: 'message mosaic…';
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
(!connected
|
||||
? 'disconnected — waiting for gateway…'
|
||||
: isStreaming
|
||||
? 'waiting for response…'
|
||||
: 'message mosaic…');
|
||||
|
||||
return (
|
||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||
|
||||
143
packages/cli/src/tui/components/sidebar.tsx
Normal file
143
packages/cli/src/tui/components/sidebar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
||||
|
||||
export interface SidebarProps {
|
||||
conversations: ConversationSummary[];
|
||||
activeConversationId: string | undefined;
|
||||
selectedIndex: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onSwitchConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
loading: boolean;
|
||||
focused: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
const mon = months[date.getMonth()];
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${mon} ${dd}`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
conversations,
|
||||
activeConversationId,
|
||||
selectedIndex,
|
||||
onSelectIndex,
|
||||
onSwitchConversation,
|
||||
onDeleteConversation,
|
||||
loading,
|
||||
focused,
|
||||
width,
|
||||
}: SidebarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onSelectIndex(Math.max(0, selectedIndex - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
||||
}
|
||||
if (key.return) {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onSwitchConversation(conv.id);
|
||||
}
|
||||
}
|
||||
if (_input === 'd') {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onDeleteConversation(conv.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'cyan' : 'gray';
|
||||
// Available width for content inside border + padding
|
||||
const innerWidth = width - 4; // 2 border + 2 padding
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Conversations
|
||||
</Text>
|
||||
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
||||
{loading && conversations.length === 0 ? (
|
||||
<Text dimColor>Loading…</Text>
|
||||
) : conversations.length === 0 ? (
|
||||
<Text dimColor>No conversations</Text>
|
||||
) : (
|
||||
conversations.map((conv, idx) => {
|
||||
const isActive = conv.id === activeConversationId;
|
||||
const isSelected = idx === selectedIndex && focused;
|
||||
const marker = isActive ? '● ' : ' ';
|
||||
const time = formatRelativeTime(conv.updatedAt);
|
||||
const title = conv.title ?? 'Untitled';
|
||||
// marker(2) + title + space(1) + time
|
||||
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
||||
const displayTitle = truncate(title, maxTitleLen);
|
||||
|
||||
return (
|
||||
<Box key={conv.id}>
|
||||
<Text
|
||||
inverse={isSelected}
|
||||
color={isActive ? 'cyan' : undefined}
|
||||
dimColor={!isActive && !isSelected}
|
||||
>
|
||||
{marker}
|
||||
{displayTitle}
|
||||
{' '.repeat(
|
||||
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
||||
)}
|
||||
{time}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
37
packages/cli/src/tui/hooks/use-app-mode.ts
Normal file
37
packages/cli/src/tui/hooks/use-app-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type AppMode = 'chat' | 'sidebar' | 'search';
|
||||
|
||||
export interface UseAppModeReturn {
|
||||
mode: AppMode;
|
||||
setMode: (mode: AppMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export function useAppMode(): UseAppModeReturn {
|
||||
const [mode, setModeState] = useState<AppMode>('chat');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const setMode = useCallback((next: AppMode) => {
|
||||
setModeState(next);
|
||||
if (next === 'sidebar') {
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarOpen((prev) => {
|
||||
if (prev) {
|
||||
// Closing sidebar — return to chat
|
||||
setModeState('chat');
|
||||
return false;
|
||||
}
|
||||
// Opening sidebar — set mode to sidebar
|
||||
setModeState('sidebar');
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { mode, setMode, toggleSidebar, sidebarOpen };
|
||||
}
|
||||
139
packages/cli/src/tui/hooks/use-conversations.ts
Normal file
139
packages/cli/src/tui/hooks/use-conversations.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsReturn {
|
||||
conversations: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
||||
deleteConversation: (id: string) => Promise<boolean>;
|
||||
renameConversation: (id: string, title: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
||||
const { gatewayUrl, sessionCookie } = opts;
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const headers = useCallback((): Record<string, string> => {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (sessionCookie) h['Cookie'] = sessionCookie;
|
||||
return h;
|
||||
}, [sessionCookie]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary[];
|
||||
if (mountedRef.current) {
|
||||
setConversations(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [gatewayUrl, headers]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const createConversation = useCallback(
|
||||
async (title?: string): Promise<ConversationSummary | null> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title: title ?? null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => [data, ...prev]);
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,8 @@ export interface UseSocketReturn {
|
||||
availableThinkingLevels: string[];
|
||||
sendMessage: (content: string) => void;
|
||||
setThinkingLevel: (level: string) => void;
|
||||
switchConversation: (id: string) => void;
|
||||
clearMessages: () => void;
|
||||
connectionError: string | null;
|
||||
}
|
||||
|
||||
@@ -239,6 +241,22 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const switchConversation = useCallback(
|
||||
(id: string) => {
|
||||
clearMessages();
|
||||
setConversationId(id);
|
||||
},
|
||||
[clearMessages],
|
||||
);
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
@@ -255,6 +273,8 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
availableThinkingLevels,
|
||||
sendMessage,
|
||||
setThinkingLevel,
|
||||
switchConversation,
|
||||
clearMessages,
|
||||
connectionError,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user