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:
70
docs/PRD-TUI_Improvements.md
Normal file
70
docs/PRD-TUI_Improvements.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# PRD: TUI Improvements — Phase 7
|
||||||
|
|
||||||
|
**Branch:** `feat/p7-tui-improvements`
|
||||||
|
**Package:** `packages/cli`
|
||||||
|
**Status:** In Progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current Mosaic CLI TUI (`packages/cli/src/tui/app.tsx`) is a minimal single-file Ink application with:
|
||||||
|
|
||||||
|
- Flat message list with no visual hierarchy
|
||||||
|
- No system context visibility (cwd, branch, model, tokens)
|
||||||
|
- Noisy error messages when gateway is disconnected
|
||||||
|
- No conversation management (list, switch, rename, delete)
|
||||||
|
- No multi-panel layout or navigation
|
||||||
|
- No tool call visibility during agent execution
|
||||||
|
- No thinking/reasoning display
|
||||||
|
|
||||||
|
The TUI should be the power-user interface to Mosaic — informative, responsive, and visually clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
### Wave 1 — Status Bar & Polish (MVP)
|
||||||
|
|
||||||
|
Provide essential context at a glance and reduce noise.
|
||||||
|
|
||||||
|
1. **Top status bar** — shows: connection indicator (●/○), gateway URL, agent model name
|
||||||
|
2. **Bottom status bar** — shows: cwd, git branch, token usage (input/output/total)
|
||||||
|
3. **Better message formatting** — distinct visual treatment for user vs assistant messages, timestamps, word wrap
|
||||||
|
4. **Quiet disconnect** — single-line indicator when gateway is offline instead of flooding error messages; auto-reconnect silently
|
||||||
|
5. **Tool call display** — inline indicators when agent uses tools (spinner + tool name during execution, ✓/✗ on completion)
|
||||||
|
6. **Thinking/reasoning display** — collapsible dimmed block for `agent:thinking` events
|
||||||
|
|
||||||
|
### Wave 2 — Layout & Navigation
|
||||||
|
|
||||||
|
Multi-panel layout with keyboard navigation.
|
||||||
|
|
||||||
|
1. **Conversation sidebar** — list conversations, create new, switch between them
|
||||||
|
2. **Keybinding system** — Ctrl+N (new conversation), Ctrl+L (conversation list toggle), Ctrl+K (command palette concept)
|
||||||
|
3. **Scrollable message history** — viewport with PgUp/PgDn/arrow key scrolling
|
||||||
|
4. **Message search** — find in current conversation
|
||||||
|
|
||||||
|
### Wave 3 — Advanced Features
|
||||||
|
|
||||||
|
1. **Project/mission views** — show active projects, missions, tasks
|
||||||
|
2. **Agent status monitoring** — real-time agent state, queue depth
|
||||||
|
3. **Settings/config screen** — view/edit connection settings, model preferences
|
||||||
|
4. **Multiple agent sessions** — split view or tab-based multi-agent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
- **Ink 5** (React for CLI) — already in deps
|
||||||
|
- **Component architecture** — break monolithic `app.tsx` into composable components
|
||||||
|
- **Typed Socket.IO events** — leverage `@mosaic/types` `ServerToClientEvents` / `ClientToServerEvents`
|
||||||
|
- **Local state only** (Wave 1) — cwd/branch read from `process.cwd()` and `git` at startup
|
||||||
|
- **Gateway metadata** (future) — extend socket handshake or add REST endpoint for model info, token usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Goals (for now)
|
||||||
|
|
||||||
|
- Image rendering in terminal
|
||||||
|
- File editor integration
|
||||||
|
- SSH/remote gateway auto-discovery
|
||||||
36
docs/TASKS-TUI_Improvements.md
Normal file
36
docs/TASKS-TUI_Improvements.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Tasks: TUI Improvements
|
||||||
|
|
||||||
|
**Branch:** `feat/p7-tui-improvements`
|
||||||
|
**PRD:** [PRD-TUI_Improvements.md](./PRD-TUI_Improvements.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 1 — Status Bar & Polish
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| ------- | ------------------------------------------------------------------------------------------------- | ----------- | ----------------------------- |
|
||||||
|
| TUI-001 | Component architecture — split `app.tsx` into `StatusBar`, `MessageList`, `InputBar`, `App` shell | not-started | Foundation for all other work |
|
||||||
|
| TUI-002 | Top status bar — connection indicator (●/○), gateway URL, agent model | not-started | Depends: TUI-001 |
|
||||||
|
| TUI-003 | Bottom status bar — cwd, git branch, token usage | not-started | Depends: TUI-001 |
|
||||||
|
| TUI-004 | Message formatting — timestamps, role colors, markdown-lite rendering, word wrap | not-started | Depends: TUI-001 |
|
||||||
|
| TUI-005 | Quiet disconnect — suppress error flood, single-line reconnecting indicator | not-started | Depends: TUI-001 |
|
||||||
|
| TUI-006 | Tool call display — inline spinner + tool name during execution, ✓/✗ on completion | not-started | Depends: TUI-001 |
|
||||||
|
| TUI-007 | Thinking/reasoning display — dimmed collapsible block for `agent:thinking` events | not-started | Depends: TUI-001 |
|
||||||
|
|
||||||
|
## Wave 2 — Layout & Navigation
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| ------- | --------------------------------------------------------- | ----------- | ---------------- |
|
||||||
|
| TUI-008 | Conversation sidebar — list, create, switch conversations | not-started | Depends: TUI-001 |
|
||||||
|
| TUI-009 | Keybinding system — Ctrl+N, Ctrl+L, Ctrl+K | not-started | Depends: TUI-008 |
|
||||||
|
| TUI-010 | Scrollable message history — viewport with PgUp/PgDn | not-started | Depends: TUI-001 |
|
||||||
|
| TUI-011 | Message search — find in current conversation | not-started | Depends: TUI-010 |
|
||||||
|
|
||||||
|
## Wave 3 — Advanced Features
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| ------- | ----------------------- | ----------- | ----- |
|
||||||
|
| TUI-012 | Project/mission views | not-started | |
|
||||||
|
| TUI-013 | Agent status monitoring | not-started | |
|
||||||
|
| TUI-014 | Settings/config screen | not-started | |
|
||||||
|
| TUI-015 | Multiple agent sessions | not-started | |
|
||||||
@@ -24,12 +24,13 @@
|
|||||||
"@mosaic/mosaic": "workspace:^",
|
"@mosaic/mosaic": "workspace:^",
|
||||||
"@mosaic/prdy": "workspace:^",
|
"@mosaic/prdy": "workspace:^",
|
||||||
"@mosaic/quality-rails": "workspace:^",
|
"@mosaic/quality-rails": "workspace:^",
|
||||||
|
"@mosaic/types": "workspace:^",
|
||||||
|
"commander": "^13.0.0",
|
||||||
"ink": "^5.0.0",
|
"ink": "^5.0.0",
|
||||||
"ink-text-input": "^6.0.0",
|
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0"
|
||||||
"commander": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -1,170 +1,59 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React from 'react';
|
||||||
import { Box, Text, useInput, useApp } from 'ink';
|
import { Box, useApp, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import { TopBar } from './components/top-bar.js';
|
||||||
import Spinner from 'ink-spinner';
|
import { BottomBar } from './components/bottom-bar.js';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
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 {
|
export interface TuiAppProps {
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TuiAppProps {
|
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
sessionCookie?: string;
|
sessionCookie?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TuiApp({
|
export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProps) {
|
||||||
gatewayUrl,
|
|
||||||
conversationId: initialConversationId,
|
|
||||||
sessionCookie,
|
|
||||||
}: TuiAppProps) {
|
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const gitInfo = useGitInfo();
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const socket = useSocket({
|
||||||
const socket = io(`${gatewayUrl}/chat`, {
|
gatewayUrl,
|
||||||
transports: ['websocket'],
|
sessionCookie,
|
||||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
initialConversationId: conversationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
socketRef.current = socket;
|
useInput((_ch, key) => {
|
||||||
|
if (key.ctrl && _ch === 'c') {
|
||||||
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') {
|
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1}>
|
<Box flexDirection="column" height="100%">
|
||||||
<Box marginBottom={1}>
|
<TopBar
|
||||||
<Text bold color="blue">
|
connected={socket.connected}
|
||||||
Mosaic
|
connecting={socket.connecting}
|
||||||
</Text>
|
gatewayUrl={gatewayUrl}
|
||||||
<Text> </Text>
|
conversationId={socket.conversationId}
|
||||||
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
modelName={socket.modelName}
|
||||||
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
/>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<MessageList
|
||||||
{messages.map((msg, i) => (
|
messages={socket.messages}
|
||||||
<Box key={i} marginBottom={1}>
|
isStreaming={socket.isStreaming}
|
||||||
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
currentStreamText={socket.currentStreamText}
|
||||||
{msg.role === 'user' ? '> ' : ' '}
|
currentThinkingText={socket.currentThinkingText}
|
||||||
</Text>
|
activeToolCalls={socket.activeToolCalls}
|
||||||
<Text wrap="wrap">{msg.content}</Text>
|
/>
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{isStreaming && currentStreamText && (
|
<BottomBar gitInfo={gitInfo} tokenUsage={socket.tokenUsage} />
|
||||||
<Box marginBottom={1}>
|
|
||||||
<Text bold color="cyan">
|
|
||||||
{' '}
|
|
||||||
</Text>
|
|
||||||
<Text wrap="wrap">{currentStreamText}</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isStreaming && !currentStreamText && (
|
<InputBar
|
||||||
<Box>
|
onSubmit={socket.sendMessage}
|
||||||
<Text color="cyan">
|
isStreaming={socket.isStreaming}
|
||||||
<Spinner type="dots" />
|
connected={socket.connected}
|
||||||
</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>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
packages/cli/src/tui/hooks/use-git-info.ts
Normal file
29
packages/cli/src/tui/hooks/use-git-info.ts
Normal 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;
|
||||||
|
}
|
||||||
197
packages/cli/src/tui/hooks/use-socket.ts
Normal file
197
packages/cli/src/tui/hooks/use-socket.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -286,6 +286,9 @@ importers:
|
|||||||
'@mosaic/quality-rails':
|
'@mosaic/quality-rails':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../quality-rails
|
version: link:../quality-rails
|
||||||
|
'@mosaic/types':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../types
|
||||||
commander:
|
commander:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.1.0
|
version: 13.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user