Files
stack/packages/cli/src/tui/app.tsx
jason.woltje 0d12471868
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
feat: add web search, file edit, MCP management, file refs, and /stop to CLI/TUI (#348)
2026-04-02 18:08:30 +00:00

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>
);
}