import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { Box, useApp, useInput } from 'ink'; import type { ParsedCommand } from '@mosaic/types'; 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'; import { executeHelp, executeStatus } from './commands/index.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 handleLocalCommand = useCallback( (parsed: ParsedCommand) => { switch (parsed.command) { case 'help': case 'h': { const result = executeHelp(parsed); socket.addSystemMessage(result); break; } case 'status': case 's': { const result = executeStatus(parsed, { connected: socket.connected, model: socket.modelName, provider: socket.providerName, sessionId: socket.conversationId ?? null, tokenCount: socket.tokenUsage.total, }); socket.addSystemMessage(result); break; } case 'clear': socket.clearMessages(); break; case 'stop': // Currently no stop mechanism exposed — show feedback socket.addSystemMessage('Stop is not available for the current session.'); break; case 'cost': { const u = socket.tokenUsage; socket.addSystemMessage( `Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`, ); break; } default: socket.addSystemMessage(`Local command not implemented: /${parsed.command}`); } }, [socket], ); const handleGatewayCommand = useCallback( (parsed: ParsedCommand) => { if (!socket.socketRef.current?.connected || !socket.conversationId) { socket.addSystemMessage('Not connected to gateway. Command cannot be executed.'); return; } socket.socketRef.current.emit('command:execute', { conversationId: socket.conversationId, command: parsed.command, args: parsed.args ?? undefined, }); }, [socket], ); 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} )} ); }