import React, { useState, useCallback, useEffect, useMemo, useRef } 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, executeHistory, commandRegistry } from './commands/index.js'; import { fetchConversationMessages } from './gateway-api.js'; import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js'; export interface TuiAppProps { gatewayUrl: string; conversationId?: string; sessionCookie?: string; initialModel?: string; initialProvider?: string; agentId?: string; agentName?: string; projectId?: string; /** CLI package version passed from the entry point (cli.ts). */ version?: string; } export function TuiApp({ gatewayUrl, conversationId, sessionCookie, initialModel, initialProvider, agentId, agentName, projectId: _projectId, version = '0.0.0', }: 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); // Controlled input state — held here so Ctrl+C can clear it const [tuiInput, setTuiInput] = useState(''); // Ctrl+C double-press: first press with empty input shows hint; second exits const ctrlCPendingExit = useRef(false); // Flag to suppress the character that ink-text-input leaks when a Ctrl+key // combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't'). const ctrlJustFired = useRef(false); // Wrap sendMessage to expand @file references before sending const sendMessageWithFileRefs = useCallback( (content: string) => { if (!hasFileRefs(content)) { socket.sendMessage(content); return; } void expandFileRefs(content) .then(({ expandedMessage, filesAttached, errors }) => { for (const err of errors) { socket.addSystemMessage(err); } if (filesAttached.length > 0) { socket.addSystemMessage( `📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`, ); } socket.sendMessage(expandedMessage); }) .catch((err: unknown) => { socket.addSystemMessage( `File expansion failed: ${err instanceof Error ? err.message : String(err)}`, ); // Send original message without expansion socket.sendMessage(content); }); }, [socket], ); 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 'new': case 'n': void conversations .createConversation() .then((conv) => { if (conv) { socket.switchConversation(conv.id); appMode.setMode('chat'); } }) .catch(() => { socket.addSystemMessage('Failed to create new conversation.'); }); break; case 'attach': { if (!parsed.args) { socket.addSystemMessage('Usage: /attach '); break; } void handleAttachCommand(parsed.args) .then(({ content, error }) => { if (error) { socket.addSystemMessage(`Attach error: ${error}`); } else if (content) { // Send the file content as a user message socket.sendMessage(content); } }) .catch((err: unknown) => { socket.addSystemMessage( `Attach failed: ${err instanceof Error ? err.message : String(err)}`, ); }); break; } case 'stop': if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) { socket.socketRef.current.emit('abort', { conversationId: socket.conversationId, }); socket.addSystemMessage('Abort signal sent.'); } else { socket.addSystemMessage('No active stream to stop.'); } 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; } case 'history': case 'hist': { void executeHistory({ conversationId: socket.conversationId, gatewayUrl, sessionCookie, fetchMessages: fetchConversationMessages, }) .then((result) => { socket.addSystemMessage(result); }) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); socket.addSystemMessage(`Failed to fetch history: ${msg}`); }); break; } default: socket.addSystemMessage(`Local command not implemented: /${parsed.command}`); } }, [socket], ); const handleGatewayCommand = useCallback( (parsed: ParsedCommand) => { if (!socket.socketRef.current?.connected) { 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) => { // Ctrl+C: clear input → show hint → second empty press exits if (key.ctrl && ch === 'c') { if (tuiInput) { setTuiInput(''); ctrlCPendingExit.current = false; } else if (ctrlCPendingExit.current) { exit(); } else { ctrlCPendingExit.current = true; socket.addSystemMessage('Press Ctrl+C again to exit.'); } return; } // Any other key resets the pending-exit flag ctrlCPendingExit.current = false; // Ctrl+L: toggle sidebar (refresh on open) if (key.ctrl && ch === 'l') { ctrlJustFired.current = true; queueMicrotask(() => { ctrlJustFired.current = false; }); const willOpen = !appMode.sidebarOpen; appMode.toggleSidebar(); if (willOpen) { void conversations.refresh(); } return; } // Ctrl+N: create new conversation and switch to it if (key.ctrl && ch === 'n') { ctrlJustFired.current = true; queueMicrotask(() => { ctrlJustFired.current = false; }); void conversations .createConversation() .then((conv) => { if (conv) { socket.switchConversation(conv.id); appMode.setMode('chat'); } }) .catch(() => {}); return; } // Ctrl+K: toggle search mode if (key.ctrl && ch === 'k') { ctrlJustFired.current = true; queueMicrotask(() => { ctrlJustFired.current = false; }); if (appMode.mode === 'search') { search.clear(); appMode.setMode('chat'); } else { appMode.setMode('search'); } return; } // 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') { ctrlJustFired.current = true; queueMicrotask(() => { ctrlJustFired.current = false; }); 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); } } return; } // 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} /> )} { // Suppress the character that ink-text-input leaks when a Ctrl+key // combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is // set synchronously in the useInput handler and cleared via a // microtask, so this callback sees it as still true on the same // event-loop tick. if (ctrlJustFired.current) { ctrlJustFired.current = false; return; } setTuiInput(val); }} onSubmit={sendMessageWithFileRefs} onSystemMessage={socket.addSystemMessage} onLocalCommand={handleLocalCommand} onGatewayCommand={handleGatewayCommand} isStreaming={socket.isStreaming} connected={socket.connected} focused={appMode.mode === 'chat'} placeholder={inputPlaceholder} allCommands={commandRegistry.getAll()} /> ); return ( {appMode.sidebarOpen ? ( {messageArea} ) : ( {messageArea} )} ); }