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 3792576566
commit 9ae1bac614
10 changed files with 629 additions and 3 deletions

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { TokenUsage } from '../hooks/use-socket.js';
import type { GitInfo } from '../hooks/use-git-info.js';
export interface BottomBarProps {
gitInfo: GitInfo;
tokenUsage: TokenUsage;
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
/** Compact the cwd — show ~ for home, truncate long paths */
function compactCwd(cwd: string): string {
const home = process.env['HOME'] ?? '';
if (home && cwd.startsWith(home)) {
cwd = '~' + cwd.slice(home.length);
}
// If still very long, show last 3 segments
const parts = cwd.split('/');
if (parts.length > 4) {
return '…/' + parts.slice(-3).join('/');
}
return cwd;
}
export function BottomBar({ gitInfo, tokenUsage }: BottomBarProps) {
const hasTokens = tokenUsage.total > 0;
return (
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
<Box>
<Text dimColor>cwd: </Text>
<Text>{compactCwd(gitInfo.cwd)}</Text>
{gitInfo.branch && (
<>
<Text dimColor> </Text>
<Text color="cyan">{gitInfo.branch}</Text>
</>
)}
</Box>
<Box>
{hasTokens ? (
<>
<Text dimColor>tokens: </Text>
<Text color="green">{formatTokens(tokenUsage.input)}</Text>
<Text dimColor> / </Text>
<Text color="yellow">{formatTokens(tokenUsage.output)}</Text>
<Text dimColor> ({formatTokens(tokenUsage.total)})</Text>
</>
) : (
<Text dimColor>tokens: </Text>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,44 @@
import React, { useState, useCallback } from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
export interface InputBarProps {
onSubmit: (value: string) => void;
isStreaming: boolean;
connected: boolean;
}
export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) {
const [input, setInput] = useState('');
const handleSubmit = useCallback(
(value: string) => {
if (!value.trim() || isStreaming || !connected) return;
onSubmit(value);
setInput('');
},
[onSubmit, isStreaming, connected],
);
const placeholder = !connected
? 'disconnected — waiting for gateway…'
: isStreaming
? 'waiting for response…'
: 'message mosaic…';
const promptColor = !connected ? 'red' : isStreaming ? 'yellow' : 'green';
return (
<Box paddingX={1} borderStyle="single" borderColor="gray">
<Text bold color={promptColor}>
{' '}
</Text>
<TextInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
placeholder={placeholder}
/>
</Box>
);
}

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Message, ToolCall } from '../hooks/use-socket.js';
export interface MessageListProps {
messages: Message[];
isStreaming: boolean;
currentStreamText: string;
currentThinkingText: string;
activeToolCalls: ToolCall[];
}
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function MessageBubble({ msg }: { msg: Message }) {
const isUser = msg.role === 'user';
const prefix = isUser ? '' : '◆';
const color = isUser ? 'green' : 'cyan';
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold color={color}>
{prefix}{' '}
</Text>
<Text bold color={color}>
{isUser ? 'you' : 'assistant'}
</Text>
<Text dimColor> {formatTime(msg.timestamp)}</Text>
</Box>
<Box marginLeft={2}>
<Text wrap="wrap">{msg.content}</Text>
</Box>
</Box>
);
}
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
const color =
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
return (
<Box marginLeft={2}>
{toolCall.status === 'running' ? (
<Text color="yellow">
<Spinner type="dots" />
</Text>
) : (
<Text color={color}>{icon}</Text>
)}
<Text dimColor> tool: </Text>
<Text color={color}>{toolCall.toolName}</Text>
</Box>
);
}
export function MessageList({
messages,
isStreaming,
currentStreamText,
currentThinkingText,
activeToolCalls,
}: MessageListProps) {
return (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{messages.length === 0 && !isStreaming && (
<Box justifyContent="center" marginY={1}>
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
</Box>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
))}
{/* Active thinking */}
{isStreaming && currentThinkingText && (
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
<Text dimColor italic>
💭 {currentThinkingText}
</Text>
</Box>
)}
{/* Active tool calls */}
{activeToolCalls.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
{activeToolCalls.map((tc) => (
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
))}
</Box>
)}
{/* Streaming response */}
{isStreaming && currentStreamText && (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold color="cyan">
{' '}
</Text>
<Text bold color="cyan">
assistant
</Text>
</Box>
<Box marginLeft={2}>
<Text wrap="wrap">{currentStreamText}</Text>
</Box>
</Box>
)}
{/* Waiting spinner */}
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
<Box marginLeft={2}>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text dimColor> thinking</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Box, Text } from 'ink';
export interface TopBarProps {
connected: boolean;
connecting: boolean;
gatewayUrl: string;
conversationId?: string;
modelName: string | null;
}
export function TopBar({
connected,
connecting,
gatewayUrl,
conversationId,
modelName,
}: TopBarProps) {
const indicator = connected ? '●' : '○';
const indicatorColor = connected ? 'green' : connecting ? 'yellow' : 'red';
const statusLabel = connected ? 'connected' : connecting ? 'connecting' : 'disconnected';
// Strip protocol for compact display
const host = gatewayUrl.replace(/^https?:\/\//, '');
return (
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
<Box>
<Text bold color="blue">
mosaic
</Text>
<Text> </Text>
<Text color={indicatorColor}>{indicator}</Text>
<Text dimColor> {statusLabel}</Text>
<Text dimColor> · {host}</Text>
</Box>
<Box>
{modelName && (
<>
<Text dimColor>model: </Text>
<Text color="magenta">{modelName}</Text>
<Text> </Text>
</>
)}
{conversationId && (
<>
<Text dimColor>conv: </Text>
<Text>{conversationId.slice(0, 8)}</Text>
</>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { execSync } from 'node:child_process';
export interface GitInfo {
branch: string | null;
cwd: string;
}
export function useGitInfo(): GitInfo {
const [info, setInfo] = useState<GitInfo>({
branch: null,
cwd: process.cwd(),
});
useEffect(() => {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf-8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
setInfo({ branch, cwd: process.cwd() });
} catch {
setInfo({ branch: null, cwd: process.cwd() });
}
}, []);
return info;
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { io, type Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
MessageAckPayload,
AgentTextPayload,
AgentThinkingPayload,
ToolStartPayload,
ToolEndPayload,
ErrorPayload,
} from '@mosaic/types';
export interface ToolCall {
toolCallId: string;
toolName: string;
status: 'running' | 'success' | 'error';
}
export interface Message {
role: 'user' | 'assistant' | 'thinking' | 'tool';
content: string;
timestamp: Date;
toolCalls?: ToolCall[];
}
export interface TokenUsage {
input: number;
output: number;
total: number;
}
export interface UseSocketOptions {
gatewayUrl: string;
sessionCookie?: string;
initialConversationId?: string;
}
export interface UseSocketReturn {
connected: boolean;
connecting: boolean;
messages: Message[];
conversationId: string | undefined;
isStreaming: boolean;
currentStreamText: string;
currentThinkingText: string;
activeToolCalls: ToolCall[];
tokenUsage: TokenUsage;
modelName: string | null;
sendMessage: (content: string) => void;
connectionError: string | null;
}
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
const { gatewayUrl, sessionCookie, initialConversationId } = opts;
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(true);
const [messages, setMessages] = useState<Message[]>([]);
const [conversationId, setConversationId] = useState(initialConversationId);
const [isStreaming, setIsStreaming] = useState(false);
const [currentStreamText, setCurrentStreamText] = useState('');
const [currentThinkingText, setCurrentThinkingText] = useState('');
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
// TODO: wire up once gateway emits token-usage and model-info events
const tokenUsage: TokenUsage = { input: 0, output: 0, total: 0 };
const modelName: string | null = null;
const [connectionError, setConnectionError] = useState<string | null>(null);
const socketRef = useRef<TypedSocket | null>(null);
useEffect(() => {
const socket = io(`${gatewayUrl}/chat`, {
transports: ['websocket'],
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
reconnection: true,
reconnectionDelay: 2000,
reconnectionAttempts: Infinity,
}) as TypedSocket;
socketRef.current = socket;
socket.on('connect', () => {
setConnected(true);
setConnecting(false);
setConnectionError(null);
});
socket.on('disconnect', () => {
setConnected(false);
setIsStreaming(false);
setCurrentStreamText('');
setCurrentThinkingText('');
setActiveToolCalls([]);
});
socket.io.on('error', (err: Error) => {
setConnecting(false);
setConnectionError(err.message);
});
socket.on('message:ack', (data: MessageAckPayload) => {
setConversationId(data.conversationId);
});
socket.on('agent:start', () => {
setIsStreaming(true);
setCurrentStreamText('');
setCurrentThinkingText('');
setActiveToolCalls([]);
});
socket.on('agent:text', (data: AgentTextPayload) => {
setCurrentStreamText((prev) => prev + data.text);
});
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
setCurrentThinkingText((prev) => prev + data.text);
});
socket.on('agent:tool:start', (data: ToolStartPayload) => {
setActiveToolCalls((prev) => [
...prev,
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
]);
});
socket.on('agent:tool:end', (data: ToolEndPayload) => {
setActiveToolCalls((prev) =>
prev.map((tc) =>
tc.toolCallId === data.toolCallId
? { ...tc, status: data.isError ? 'error' : 'success' }
: tc,
),
);
});
socket.on('agent:end', () => {
setCurrentStreamText((prev) => {
if (prev) {
setMessages((msgs) => [
...msgs,
{ role: 'assistant', content: prev, timestamp: new Date() },
]);
}
return '';
});
setCurrentThinkingText('');
setActiveToolCalls([]);
setIsStreaming(false);
});
socket.on('error', (data: ErrorPayload) => {
setMessages((msgs) => [
...msgs,
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
]);
setIsStreaming(false);
});
return () => {
socket.disconnect();
};
}, [gatewayUrl, sessionCookie]);
const sendMessage = useCallback(
(content: string) => {
if (!content.trim() || isStreaming) return;
if (!socketRef.current?.connected) return;
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
socketRef.current.emit('message', {
conversationId,
content,
});
},
[conversationId, isStreaming],
);
return {
connected,
connecting,
messages,
conversationId,
isStreaming,
currentStreamText,
currentThinkingText,
activeToolCalls,
tokenUsage,
modelName,
sendMessage,
connectionError,
};
}