import React, { useState, useCallback, useEffect, useMemo } 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 { SearchBar } from './components/search-bar.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'; import { useSearch } from './hooks/use-search.js'; export interface TuiAppProps { gatewayUrl: string; conversationId?: string; sessionCookie?: string; initialModel?: string; initialProvider?: string; agentId?: string; agentName?: string; projectId?: string; } export function TuiApp({ gatewayUrl, conversationId, sessionCookie, initialModel, initialProvider, agentId, agentName, projectId: _projectId, }: TuiAppProps) { const { exit } = useApp(); const gitInfo = useGitInfo(); const appMode = useAppMode(); const socket = useSocket({ gatewayUrl, sessionCookie, initialConversationId: conversationId, initialModel, initialProvider, agentId, }); const conversations = useConversations({ gatewayUrl, sessionCookie }); const viewport = useViewport({ totalItems: socket.messages.length }); const search = useSearch(socket.messages); // Scroll to current match when it changes const currentMatch = search.matches[search.currentMatchIndex]; useEffect(() => { if (currentMatch && appMode.mode === 'search') { viewport.scrollTo(currentMatch.messageIndex); } }, [currentMatch, appMode.mode, viewport]); // Compute highlighted message indices for MessageList const highlightedMessageIndices = useMemo(() => { if (search.matches.length === 0) return undefined; return new Set(search.matches.map((m) => m.messageIndex)); }, [search.matches]); const currentHighlightIndex = currentMatch?.messageIndex; 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(); } }) .catch(() => {}); }, [conversations, socket], ); useInput((ch, key) => { if (key.ctrl && ch === 'c') { exit(); } // Ctrl+L: toggle sidebar (refresh on open) if (key.ctrl && ch === 'l') { const willOpen = !appMode.sidebarOpen; appMode.toggleSidebar(); if (willOpen) { void conversations.refresh(); } } // Ctrl+N: create new conversation and switch to it if (key.ctrl && ch === 'n') { void conversations .createConversation() .then((conv) => { if (conv) { socket.switchConversation(conv.id); appMode.setMode('chat'); } }) .catch(() => {}); } // Ctrl+K: toggle search mode if (key.ctrl && ch === 'k') { if (appMode.mode === 'search') { search.clear(); appMode.setMode('chat'); } else { appMode.setMode('search'); } } // 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') { const levels = socket.availableThinkingLevels; if (levels.length > 0) { const currentIdx = levels.indexOf(socket.thinkingLevel); const nextIdx = (currentIdx + 1) % levels.length; const next = levels[nextIdx]; if (next) { socket.setThinkingLevel(next); } } } // Escape: return to chat from sidebar/search; in chat, scroll to bottom if (key.escape) { if (appMode.mode === 'search') { search.clear(); appMode.setMode('chat'); } else if (appMode.mode === 'sidebar') { appMode.setMode('chat'); } else if (appMode.mode === 'chat') { viewport.scrollToBottom(); } } }); const inputPlaceholder = appMode.mode === 'sidebar' ? 'focus is on sidebar… press Esc to return' : appMode.mode === 'search' ? 'search mode… press Esc to return' : undefined; const isSearchMode = appMode.mode === 'search'; const messageArea = ( {isSearchMode && ( { search.clear(); appMode.setMode('chat'); }} focused={isSearchMode} /> )} ); return ( {appMode.sidebarOpen ? ( {messageArea} ) : ( {messageArea} )} ); }