feat(cli): TUI component architecture — status bars, message list, input bar

- Split monolithic app.tsx into composable components:
  - TopBar: connection indicator (●/○), gateway URL, model name, conversation ID
  - BottomBar: cwd, git branch, token usage
  - MessageList: timestamped messages, tool call indicators, thinking display
  - InputBar: context-aware prompt with streaming/disconnect states
- Extract socket logic into useSocket hook with typed events
- Extract git/cwd info into useGitInfo hook
- Quiet disconnect: single indicator instead of error flood
- Add @mosaic/types dependency for typed Socket.IO events
- Add PRD and task tracking docs

Tasks: TUI-001 through TUI-007 (Wave 1)
This commit is contained in:
2026-03-15 13:33:37 -05:00
parent 09e649fc7e
commit 79ff308aad
11 changed files with 668 additions and 153 deletions

View File

@@ -1,170 +1,59 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Box, Text, useInput, useApp } from 'ink';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import { io, type Socket } from 'socket.io-client';
import React from 'react';
import { Box, useApp, useInput } from 'ink';
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 { useSocket } from './hooks/use-socket.js';
import { useGitInfo } from './hooks/use-git-info.js';
interface Message {
role: 'user' | 'assistant';
content: string;
}
interface TuiAppProps {
export interface TuiAppProps {
gatewayUrl: string;
conversationId?: string;
sessionCookie?: string;
}
export function TuiApp({
gatewayUrl,
conversationId: initialConversationId,
sessionCookie,
}: TuiAppProps) {
export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProps) {
const { exit } = useApp();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [connected, setConnected] = useState(false);
const [conversationId, setConversationId] = useState(initialConversationId);
const [currentStreamText, setCurrentStreamText] = useState('');
const socketRef = useRef<Socket | null>(null);
const gitInfo = useGitInfo();
useEffect(() => {
const socket = io(`${gatewayUrl}/chat`, {
transports: ['websocket'],
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
});
const socket = useSocket({
gatewayUrl,
sessionCookie,
initialConversationId: conversationId,
});
socketRef.current = socket;
socket.on('connect', () => setConnected(true));
socket.on('disconnect', () => {
setConnected(false);
setIsStreaming(false);
setCurrentStreamText('');
});
socket.on('connect_error', (err: Error) => {
setMessages((msgs) => [
...msgs,
{
role: 'assistant',
content: `Connection failed: ${err.message}. Check that the gateway is running at ${gatewayUrl}.`,
},
]);
});
socket.on('message:ack', (data: { conversationId: string }) => {
setConversationId(data.conversationId);
});
socket.on('agent:start', () => {
setIsStreaming(true);
setCurrentStreamText('');
});
socket.on('agent:text', (data: { text: string }) => {
setCurrentStreamText((prev) => prev + data.text);
});
socket.on('agent:end', () => {
setCurrentStreamText((prev) => {
if (prev) {
setMessages((msgs) => [...msgs, { role: 'assistant', content: prev }]);
}
return '';
});
setIsStreaming(false);
});
socket.on('error', (data: { error: string }) => {
setMessages((msgs) => [...msgs, { role: 'assistant', content: `Error: ${data.error}` }]);
setIsStreaming(false);
});
return () => {
socket.disconnect();
};
}, [gatewayUrl]);
const handleSubmit = useCallback(
(value: string) => {
if (!value.trim() || isStreaming) return;
if (!socketRef.current?.connected) {
setMessages((msgs) => [
...msgs,
{ role: 'assistant', content: 'Not connected to gateway. Message not sent.' },
]);
return;
}
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
setInput('');
socketRef.current.emit('message', {
conversationId,
content: value,
});
},
[conversationId, isStreaming],
);
useInput((ch, key) => {
if (key.ctrl && ch === 'c') {
useInput((_ch, key) => {
if (key.ctrl && _ch === 'c') {
exit();
}
});
return (
<Box flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text bold color="blue">
Mosaic
</Text>
<Text> </Text>
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
</Box>
<Box flexDirection="column" height="100%">
<TopBar
connected={socket.connected}
connecting={socket.connecting}
gatewayUrl={gatewayUrl}
conversationId={socket.conversationId}
modelName={socket.modelName}
/>
<Box flexDirection="column" marginBottom={1}>
{messages.map((msg, i) => (
<Box key={i} marginBottom={1}>
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
{msg.role === 'user' ? '> ' : ' '}
</Text>
<Text wrap="wrap">{msg.content}</Text>
</Box>
))}
<MessageList
messages={socket.messages}
isStreaming={socket.isStreaming}
currentStreamText={socket.currentStreamText}
currentThinkingText={socket.currentThinkingText}
activeToolCalls={socket.activeToolCalls}
/>
{isStreaming && currentStreamText && (
<Box marginBottom={1}>
<Text bold color="cyan">
{' '}
</Text>
<Text wrap="wrap">{currentStreamText}</Text>
</Box>
)}
<BottomBar gitInfo={gitInfo} tokenUsage={socket.tokenUsage} />
{isStreaming && !currentStreamText && (
<Box>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text dimColor> thinking...</Text>
</Box>
)}
</Box>
<Box>
<Text bold color="green">
{'> '}
</Text>
<TextInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
placeholder={isStreaming ? 'waiting...' : 'type a message'}
/>
</Box>
<InputBar
onSubmit={socket.sendMessage}
isStreaming={socket.isStreaming}
connected={socket.connected}
/>
</Box>
);
}