diff --git a/bun.lock b/bun.lock index 35ac9c9917..ba2ff70803 100644 --- a/bun.lock +++ b/bun.lock @@ -85,7 +85,7 @@ "@types/pg": "^8.11.10", "@types/readable-stream": "^4.0.18", "@types/seedrandom": "^3.0.8", - "ai": "5.0.0", + "ai": "^5.0.0", "ignore": "5.3.2", "lodash": "4.17.21", "next-auth": "^4.24.11", @@ -218,7 +218,7 @@ "@ai-sdk/anthropic": "2.0.50", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@vscode/tree-sitter-wasm": "0.1.4", - "ai": "5.0.0", + "ai": "^5.0.0", "diff": "8.0.2", "ignore": "7.0.5", "micromatch": "^4.0.8", diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 97c21c2823..854a66170f 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -12,9 +12,11 @@ import { import { useShallow } from 'zustand/react/shallow' import { getAdsEnabled } from './commands/ads' +import { handleChatSelection } from './commands/chats' import { routeUserPrompt, addBashMessageToHistory } from './commands/router' import { AdBanner } from './components/ad-banner' import { ChatInputBar } from './components/chat-input-bar' +import { ChatPickerScreen } from './components/chat-picker-screen' import { BottomStatusLine } from './components/bottom-status-line' import { areCreditsRestored } from './components/out-of-credits-banner' import { LoadPreviousButton } from './components/load-previous-button' @@ -215,6 +217,8 @@ export const Chat = ({ [streamingAgentsKey], ) const pendingBashMessages = useChatStore((state) => state.pendingBashMessages) + const showChatPicker = useChatStore((state) => state.showChatPicker) + const setShowChatPicker = useChatStore((state) => state.setShowChatPicker) // Refs for tracking state across renders const activeAgentStreamsRef = useRef(0) @@ -1401,6 +1405,39 @@ export const Chat = ({ } }, []) + // Show chat picker if active + if (showChatPicker) { + return ( + { + await handleChatSelection(chatId, { + abortControllerRef, + agentMode, + inputRef, + inputValue, + isChainInProgressRef, + isStreaming, + logoutMutation, + streamMessageIdRef, + addToQueue, + clearMessages, + saveToHistory, + scrollToLatest, + sendMessage, + setCanProcessQueue, + setInputFocused, + setInputValue, + setIsAuthenticated, + setMessages, + setUser, + stopStreaming, + }) + }} + onClose={() => setShowChatPicker(false)} + /> + ) + } + return ( loadChatMetadata(dir.chatId)) + .filter((meta): meta is ChatMetadata => meta !== null) + + // Sort by timestamp descending (most recent first) + metadata.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime() + const bTime = new Date(b.timestamp).getTime() + return bTime - aTime + }) + + return metadata +} + +/** + * Command handler for /chats + * Opens the chat picker screen + */ +export async function handleChatsCommand(params: RouterParams): Promise { + // Save current command to history + params.saveToHistory(params.inputValue.trim()) + + // Clear input + params.setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + + // Set chat picker mode/state + useChatStore.getState().setShowChatPicker(true) +} + +/** + * Handle selection of a chat from the chat picker + */ +export async function handleChatSelection( + chatId: string, + params: RouterParams, +): Promise { + try { + // Save current chat state before switching + const currentMessages = useChatStore.getState().messages + const currentRunState = useChatStore.getState().runState + if (currentMessages.length > 0 && currentRunState) { + saveChatState(currentRunState, currentMessages) + } + + // Start a new chat session (generate new chat ID) + startNewChat() + + // Load selected chat + const savedState = loadMostRecentChatState(chatId) + + if (!savedState) { + throw new Error('Failed to load chat') + } + + // Update state with loaded chat + params.setMessages(() => savedState.messages) + useChatStore.getState().setRunState(savedState.runState) + + // Update current chat ID to the loaded chat + setCurrentChatId(chatId) + + // Close chat picker + useChatStore.getState().setShowChatPicker(false) + } catch (error) { + console.error('Error resuming chat:', error) + + // Show error to user + params.setMessages((prev) => [ + ...prev, + { + id: Date.now().toString(), + variant: 'error', + content: 'Failed to resume chat. Please try again.', + timestamp: new Date().toISOString(), + }, + ]) + + // Close chat picker + useChatStore.getState().setShowChatPicker(false) + } +} diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 98bd158025..91648717d1 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -1,6 +1,7 @@ import open from 'open' import { handleAdsEnable, handleAdsDisable } from './ads' +import { handleChatsCommand } from './chats' import { handleHelpCommand } from './help' import { handleImageCommand } from './image' import { handleInitializationFlowLocally } from './init' @@ -187,6 +188,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ clearInput(params) }, }), + defineCommand({ + name: 'chats', + aliases: ['history'], + handler: async (params) => { + await handleChatsCommand(params) + }, + }), defineCommandWithArgs({ name: 'feedback', aliases: ['bug', 'report'], diff --git a/cli/src/components/chat-picker-screen.tsx b/cli/src/components/chat-picker-screen.tsx new file mode 100644 index 0000000000..ab2bc93b96 --- /dev/null +++ b/cli/src/components/chat-picker-screen.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' + +import { MultilineInput } from './multiline-input' +import { SelectableList } from './selectable-list' +import { getAllChatsMetadata } from '../commands/chats' +import { useSearchableList } from '../hooks/use-searchable-list' +import { useTerminalLayout } from '../hooks/use-terminal-layout' +import { useTheme } from '../hooks/use-theme' + +import type { ChatMetadata } from '../commands/chats' +import type { SelectableListItem } from './selectable-list' +import type { KeyEvent } from '@opentui/core' + +// Layout constants +const LAYOUT = { + MAX_CONTENT_WIDTH: 100, + CONTENT_PADDING: 4, + HEADER_HEIGHT: 3, + INPUT_HEIGHT: 3, + FOOTER_HEIGHT: 2, + MIN_LIST_HEIGHT: 3, + COMPACT_MODE_THRESHOLD: 15, +} as const + +interface ChatPickerScreenProps { + /** Called when user selects a chat to resume */ + onSelectChat: (chatId: string) => void + /** Called when user closes the picker */ + onClose: () => void +} + +export const ChatPickerScreen: React.FC = ({ + onSelectChat, + onClose, +}) => { + const theme = useTheme() + const [chats, setChats] = useState([]) + const [loading, setLoading] = useState(true) + + // Load chats on mount + useEffect(() => { + const metadata = getAllChatsMetadata() + setChats(metadata) + setLoading(false) + }, []) + + // Convert chats to SelectableListItem format + const chatItems: SelectableListItem[] = useMemo( + () => + chats.map((chat) => ({ + id: chat.chatId, + label: chat.title, + icon: '💬', + secondary: `${chat.lastPrompt ? chat.lastPrompt + ' • ' : ''}${chat.formattedDate} • ${chat.messageCount} message${chat.messageCount === 1 ? '' : 's'}`, + })), + [chats], + ) + + // Search filtering and focus management + const { + searchQuery, + setSearchQuery, + focusedIndex, + setFocusedIndex, + filteredItems, + handleFocusChange, + } = useSearchableList({ + items: chatItems, + }) + + // Layout calculations + const { terminalWidth, terminalHeight } = useTerminalLayout() + const contentWidth = Math.min( + terminalWidth - LAYOUT.CONTENT_PADDING, + LAYOUT.MAX_CONTENT_WIDTH, + ) + + const isCompactMode = terminalHeight < LAYOUT.COMPACT_MODE_THRESHOLD + const mainPadding = isCompactMode ? 0 : 1 + + // Calculate list height + const essentialHeight = + LAYOUT.HEADER_HEIGHT + + LAYOUT.INPUT_HEIGHT + + LAYOUT.FOOTER_HEIGHT + + mainPadding * 2 + const availableForList = Math.max( + LAYOUT.MIN_LIST_HEIGHT, + terminalHeight - essentialHeight, + ) + + // Handle selection + const handleChatSelect = useCallback( + (item: SelectableListItem) => { + onSelectChat(item.id) + }, + [onSelectChat], + ) + + // Keyboard handling + const handleKeyIntercept = useCallback( + (key: KeyEvent): boolean => { + if (key.name === 'escape') { + if (searchQuery.length > 0) { + setSearchQuery('') + return true + } + onClose() + return true + } + if (key.name === 'up') { + setFocusedIndex((prev) => Math.max(0, prev - 1)) + return true + } + if (key.name === 'down') { + setFocusedIndex((prev) => Math.min(filteredItems.length - 1, prev + 1)) + return true + } + if (key.name === 'return' || key.name === 'enter') { + if (filteredItems[focusedIndex]) { + handleChatSelect(filteredItems[focusedIndex]) + } + return true + } + // Ctrl+C to quit + if (key.name === 'c' && key.ctrl) { + process.exit(0) + return true + } + return false + }, + [ + searchQuery, + setSearchQuery, + onClose, + setFocusedIndex, + filteredItems, + focusedIndex, + handleChatSelect, + ], + ) + + return ( + + {/* Main content area */} + + {/* Header */} + {!isCompactMode && ( + + + Browse your chat history + + + )} + + {/* Search input */} + + setSearchQuery(text)} + onSubmit={() => {}} // Enter key handled by onKeyIntercept + onPaste={() => {}} // Paste not needed + onKeyIntercept={handleKeyIntercept} + placeholder="Search chats..." + focused={true} + maxHeight={1} + minHeight={1} + cursorPosition={searchQuery.length} + /> + + + {/* Chat list or empty state */} + + {loading ? ( + + + Loading chats... + + + ) : filteredItems.length === 0 ? ( + + + {searchQuery + ? 'No matching chats found' + : 'No chat history. Start a conversation to create your first chat!'} + + {!searchQuery && ( + + Press Esc to close + + )} + + ) : ( + + )} + + + {/* Footer help text */} + {!isCompactMode && filteredItems.length > 0 && ( + + + ↑↓ Navigate • Enter Select • Esc {searchQuery ? 'Clear' : 'Close'} + + + )} + + + ) +} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index ff774ea981..c82c03639e 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -36,6 +36,12 @@ export const SLASH_COMMANDS: SlashCommand[] = [ // label: 'redo', // description: 'Redo the most recent undone change', // }, + { + id: 'chats', + label: 'chats', + description: 'View your chat history', + aliases: ['history'], + }, { id: 'usage', label: 'usage', diff --git a/cli/src/project-files.ts b/cli/src/project-files.ts index 6429fd97e8..0fdfea9513 100644 --- a/cli/src/project-files.ts +++ b/cli/src/project-files.ts @@ -90,6 +90,41 @@ export function getMostRecentChatDir(): string | null { } } +/** + * Get all chat directories sorted by modification time (most recent first) + * Returns empty array if no chat directories exist + */ +export function getAllChatDirs(): Array<{ chatId: string; mtime: Date }> { + try { + const chatsDir = path.join(getProjectDataDir(), 'chats') + if (!statSync(chatsDir, { throwIfNoEntry: false })) { + return [] + } + + const chatDirs = readdirSync(chatsDir) + .map((name) => { + const fullPath = path.join(chatsDir, name) + try { + const stat = statSync(fullPath) + return { chatId: name, mtime: stat.mtime, fullPath } + } catch { + return null + } + }) + .filter( + (item): item is { chatId: string; mtime: Date; fullPath: string } => + item !== null && statSync(item.fullPath).isDirectory(), + ) + + // Sort by modification time, most recent first + chatDirs.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) + + return chatDirs.map(({ chatId, mtime }) => ({ chatId, mtime })) + } catch { + return [] + } +} + export function getCurrentChatDir(): string { const chatId = getCurrentChatId() const dir = path.join(getProjectDataDir(), 'chats', chatId) diff --git a/cli/src/state/chat-store.ts b/cli/src/state/chat-store.ts index 1dc68be610..748abf7c23 100644 --- a/cli/src/state/chat-store.ts +++ b/cli/src/state/chat-store.ts @@ -116,6 +116,7 @@ export type ChatStoreState = { activeTopBanner: TopBannerType inputMode: InputMode isRetrying: boolean + showChatPicker: boolean askUserState: AskUserState pendingImages: PendingImage[] pendingBashMessages: PendingBashMessage[] @@ -184,6 +185,7 @@ type ChatStoreActions = { closeTopBanner: () => void setInputMode: (mode: InputMode) => void setIsRetrying: (retrying: boolean) => void + setShowChatPicker: (show: boolean) => void setAskUserState: (state: AskUserState) => void updateAskUserAnswer: (questionIndex: number, optionIndex: number) => void updateAskUserOtherText: (questionIndex: number, text: string) => void @@ -225,6 +227,7 @@ const initialState: ChatStoreState = { activeTopBanner: null, inputMode: 'default' as InputMode, isRetrying: false, + showChatPicker: false, askUserState: null, pendingImages: [], pendingBashMessages: [], @@ -356,6 +359,11 @@ export const useChatStore = create()( state.isRetrying = retrying }), + setShowChatPicker: (show) => + set((state) => { + state.showChatPicker = show + }), + setAskUserState: (askUserState) => set((state) => { state.askUserState = askUserState diff --git a/cli/src/utils/chat-title-generator.ts b/cli/src/utils/chat-title-generator.ts new file mode 100644 index 0000000000..9ec8e13cc8 --- /dev/null +++ b/cli/src/utils/chat-title-generator.ts @@ -0,0 +1,138 @@ +import type { ChatMessage } from '../types/chat' +import { isTextBlock } from '../types/chat' + +/** + * Generate a chat title from the first user message + * Falls back to "New chat" if no user message found + */ +export function generateChatTitle(messages: ChatMessage[]): string { + const firstUserMessage = messages.find((msg) => msg.variant === 'user') + + if (!firstUserMessage) { + return 'New chat' + } + + // Extract text from content or blocks + let text = '' + + // Try direct content first + if (firstUserMessage.content && firstUserMessage.content.trim()) { + text = firstUserMessage.content.trim() + } + // Try extracting from text blocks + else if (firstUserMessage.blocks && firstUserMessage.blocks.length > 0) { + const textBlocks = firstUserMessage.blocks.filter(isTextBlock) + text = textBlocks.map((block) => block.content).join(' ') + } + + // Handle slash commands + if (text.startsWith('/')) { + const commandEnd = text.indexOf(' ') + if (commandEnd > 0) { + const command = text.slice(0, commandEnd) + const description = text.slice(commandEnd + 1) + return truncateText(`${command}: ${description}`, 60) + } + return truncateText(text, 60) + } + + // Default truncation + if (!text) { + return 'Empty chat' + } + + return truncateText(text, 60) +} + +/** + * Get the last user prompt from the messages + * Returns empty string if no user messages found + */ +export function getLastUserPrompt(messages: ChatMessage[]): string { + // Find last user message + const userMessages = messages.filter((msg) => msg.variant === 'user') + const lastUserMessage = userMessages[userMessages.length - 1] + + if (!lastUserMessage) { + return '' + } + + // Extract text + let text = '' + if (lastUserMessage.content && lastUserMessage.content.trim()) { + text = lastUserMessage.content.trim() + } else if (lastUserMessage.blocks && lastUserMessage.blocks.length > 0) { + const textBlocks = lastUserMessage.blocks.filter(isTextBlock) + text = textBlocks.map((block) => block.content).join(' ') + } + + return truncateText(text, 80) +} + +/** + * Format a timestamp as a relative or absolute date + */ +export function formatRelativeDate(timestamp: string): string { + try { + const date = new Date(timestamp) + const now = new Date() + + // Check if invalid date + if (isNaN(date.getTime())) { + return 'Unknown date' + } + + const diffMs = now.getTime() - date.getTime() + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + // Today + if (diffDays === 0) { + return `Today at ${formatTime(date)}` + } + + // Yesterday + if (diffDays === 1) { + return `Yesterday at ${formatTime(date)}` + } + + // This week (within 7 days) + if (diffDays < 7) { + const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }) + return `${dayName} at ${formatTime(date)}` + } + + // Older - show full date + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + } catch { + return 'Unknown date' + } +} + +/** + * Format time as HH:MM AM/PM + */ +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + +/** + * Truncate text to maxLength and add ellipsis if needed + */ +export function truncateText(text: string, maxLength: number): string { + // Normalize whitespace - collapse multiple spaces/newlines to single space + const normalized = text.replace(/\s+/g, ' ').trim() + + if (normalized.length <= maxLength) { + return normalized + } + + return normalized.slice(0, maxLength).trim() + '...' +}