All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
320 lines
9.5 KiB
TypeScript
320 lines
9.5 KiB
TypeScript
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 = (
|
|
<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
|
|
onSubmit={socket.sendMessage}
|
|
onSystemMessage={socket.addSystemMessage}
|
|
onLocalCommand={handleLocalCommand}
|
|
onGatewayCommand={handleGatewayCommand}
|
|
isStreaming={socket.isStreaming}
|
|
connected={socket.connected}
|
|
placeholder={inputPlaceholder}
|
|
/>
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<Box flexDirection="column" height="100%">
|
|
<Box marginTop={1} />
|
|
<TopBar
|
|
gatewayUrl={gatewayUrl}
|
|
version="0.0.0"
|
|
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}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|