From 9ef578c20d56f704bd4d754f6115f21a36754ba0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 15:02:46 -0500 Subject: [PATCH] feat(cli): conversation sidebar with list/create/switch/delete (TUI-008) --- packages/cli/src/tui/app.tsx | 102 ++++++++++--- packages/cli/src/tui/components/input-bar.tsx | 20 ++- packages/cli/src/tui/components/sidebar.tsx | 143 ++++++++++++++++++ packages/cli/src/tui/hooks/use-app-mode.ts | 37 +++++ .../cli/src/tui/hooks/use-conversations.ts | 139 +++++++++++++++++ packages/cli/src/tui/hooks/use-socket.ts | 20 +++ 6 files changed, 436 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/tui/components/sidebar.tsx create mode 100644 packages/cli/src/tui/hooks/use-app-mode.ts create mode 100644 packages/cli/src/tui/hooks/use-conversations.ts diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 5f16270..4d7d3b1 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -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 ( - - - + const inputPlaceholder = + appMode.mode !== 'chat' ? 'focus is on sidebar… press Esc to return' : undefined; + const messageArea = ( + + + ); + + return ( + + + + + {appMode.sidebarOpen ? ( + + + {messageArea} + + ) : ( + {messageArea} + )} 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 ( diff --git a/packages/cli/src/tui/components/sidebar.tsx b/packages/cli/src/tui/components/sidebar.tsx new file mode 100644 index 0000000..f4e3497 --- /dev/null +++ b/packages/cli/src/tui/components/sidebar.tsx @@ -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 ( + + + Conversations + + + {loading && conversations.length === 0 ? ( + Loading… + ) : conversations.length === 0 ? ( + No conversations + ) : ( + 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 ( + + + {marker} + {displayTitle} + {' '.repeat( + Math.max(0, innerWidth - marker.length - displayTitle.length - time.length), + )} + {time} + + + ); + }) + )} + + {focused && ↑↓ navigate • enter switch • d delete} + + ); +} diff --git a/packages/cli/src/tui/hooks/use-app-mode.ts b/packages/cli/src/tui/hooks/use-app-mode.ts new file mode 100644 index 0000000..52fc3e0 --- /dev/null +++ b/packages/cli/src/tui/hooks/use-app-mode.ts @@ -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('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 }; +} diff --git a/packages/cli/src/tui/hooks/use-conversations.ts b/packages/cli/src/tui/hooks/use-conversations.ts new file mode 100644 index 0000000..0855eca --- /dev/null +++ b/packages/cli/src/tui/hooks/use-conversations.ts @@ -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; + createConversation: (title?: string) => Promise; + deleteConversation: (id: string) => Promise; + renameConversation: (id: string, title: string) => Promise; +} + +export function useConversations(opts: UseConversationsOptions): UseConversationsReturn { + const { gatewayUrl, sessionCookie } = opts; + + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + const headers = useCallback((): Record => { + const h: Record = { '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 => { + 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 => { + 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 => { + 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, + }; +} diff --git a/packages/cli/src/tui/hooks/use-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts index 933a255..47cc60c 100644 --- a/packages/cli/src/tui/hooks/use-socket.ts +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -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, }; }