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:
61
packages/cli/src/tui/components/bottom-bar.tsx
Normal file
61
packages/cli/src/tui/components/bottom-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
packages/cli/src/tui/components/input-bar.tsx
Normal file
44
packages/cli/src/tui/components/input-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
packages/cli/src/tui/components/message-list.tsx
Normal file
130
packages/cli/src/tui/components/message-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
packages/cli/src/tui/components/top-bar.tsx
Normal file
55
packages/cli/src/tui/components/top-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user