469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
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 <file-path>');
|
|
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 = (
|
|
<Box flexDirection="column" flexGrow={1}>
|
|
<MessageList
|
|
messages={socket.messages}
|
|
isStreaming={socket.isStreaming}
|
|
currentStreamText={socket.currentStreamText}
|
|
currentThinkingText={socket.currentThinkingText}
|
|
activeToolCalls={socket.activeToolCalls}
|
|
scrollOffset={viewport.scrollOffset}
|
|
viewportSize={viewport.viewportSize}
|
|
isScrolledUp={viewport.isScrolledUp}
|
|
highlightedMessageIndices={highlightedMessageIndices}
|
|
currentHighlightIndex={currentHighlightIndex}
|
|
/>
|
|
|
|
{isSearchMode && (
|
|
<SearchBar
|
|
query={search.query}
|
|
onQueryChange={search.setQuery}
|
|
totalMatches={search.totalMatches}
|
|
currentMatch={search.currentMatchIndex}
|
|
onNext={search.nextMatch}
|
|
onPrev={search.prevMatch}
|
|
onClose={() => {
|
|
search.clear();
|
|
appMode.setMode('chat');
|
|
}}
|
|
focused={isSearchMode}
|
|
/>
|
|
)}
|
|
|
|
<InputBar
|
|
value={tuiInput}
|
|
onChange={(val: string) => {
|
|
// 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()}
|
|
/>
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<Box flexDirection="column" height="100%">
|
|
<Box marginTop={1} />
|
|
<TopBar
|
|
gatewayUrl={gatewayUrl}
|
|
version={version}
|
|
modelName={socket.modelName}
|
|
thinkingLevel={socket.thinkingLevel}
|
|
contextWindow={socket.tokenUsage.contextWindow}
|
|
agentName={agentName ?? 'default'}
|
|
connected={socket.connected}
|
|
connecting={socket.connecting}
|
|
/>
|
|
|
|
{appMode.sidebarOpen ? (
|
|
<Box flexDirection="row" flexGrow={1}>
|
|
<Sidebar
|
|
conversations={conversations.conversations}
|
|
activeConversationId={socket.conversationId}
|
|
selectedIndex={sidebarSelectedIndex}
|
|
onSelectIndex={setSidebarSelectedIndex}
|
|
onSwitchConversation={handleSwitchConversation}
|
|
onDeleteConversation={handleDeleteConversation}
|
|
loading={conversations.loading}
|
|
focused={appMode.mode === 'sidebar'}
|
|
width={30}
|
|
/>
|
|
{messageArea}
|
|
</Box>
|
|
) : (
|
|
<Box flexGrow={1}>{messageArea}</Box>
|
|
)}
|
|
|
|
<BottomBar
|
|
gitInfo={gitInfo}
|
|
tokenUsage={socket.tokenUsage}
|
|
connected={socket.connected}
|
|
connecting={socket.connecting}
|
|
modelName={socket.modelName}
|
|
providerName={socket.providerName}
|
|
thinkingLevel={socket.thinkingLevel}
|
|
conversationId={socket.conversationId}
|
|
routingDecision={socket.routingDecision}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|