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, commandRegistry } from './commands/index.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);
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 '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.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={socket.sendMessage}
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}
)}
);
}