diff --git a/docs/PRD-TUI_Improvements.md b/docs/PRD-TUI_Improvements.md new file mode 100644 index 0000000..49c57d0 --- /dev/null +++ b/docs/PRD-TUI_Improvements.md @@ -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 diff --git a/docs/TASKS-TUI_Improvements.md b/docs/TASKS-TUI_Improvements.md new file mode 100644 index 0000000..af70d73 --- /dev/null +++ b/docs/TASKS-TUI_Improvements.md @@ -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 | | diff --git a/packages/cli/package.json b/packages/cli/package.json index 3883023..680dd3c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,12 +24,13 @@ "@mosaic/mosaic": "workspace:^", "@mosaic/prdy": "workspace:^", "@mosaic/quality-rails": "workspace:^", + "@mosaic/types": "workspace:^", + "commander": "^13.0.0", "ink": "^5.0.0", - "ink-text-input": "^6.0.0", "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "react": "^18.3.0", - "socket.io-client": "^4.8.0", - "commander": "^13.0.0" + "socket.io-client": "^4.8.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 3a6698f..889247a 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -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([]); - 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(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 ( - - - - Mosaic - - - {connected ? `connected` : 'connecting...'} - {conversationId && | {conversationId.slice(0, 8)}} - + + - - {messages.map((msg, i) => ( - - - {msg.role === 'user' ? '> ' : ' '} - - {msg.content} - - ))} + - {isStreaming && currentStreamText && ( - - - {' '} - - {currentStreamText} - - )} + - {isStreaming && !currentStreamText && ( - - - - - thinking... - - )} - - - - - {'> '} - - - + ); } diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx new file mode 100644 index 0000000..6d00e21 --- /dev/null +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -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 ( + + + cwd: + {compactCwd(gitInfo.cwd)} + {gitInfo.branch && ( + <> + + {gitInfo.branch} + + )} + + + {hasTokens ? ( + <> + tokens: + ↑{formatTokens(tokenUsage.input)} + / + ↓{formatTokens(tokenUsage.output)} + ({formatTokens(tokenUsage.total)}) + + ) : ( + tokens: — + )} + + + ); +} diff --git a/packages/cli/src/tui/components/input-bar.tsx b/packages/cli/src/tui/components/input-bar.tsx new file mode 100644 index 0000000..6f65ef6 --- /dev/null +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -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 ( + + + ❯{' '} + + + + ); +} diff --git a/packages/cli/src/tui/components/message-list.tsx b/packages/cli/src/tui/components/message-list.tsx new file mode 100644 index 0000000..7bd4dd7 --- /dev/null +++ b/packages/cli/src/tui/components/message-list.tsx @@ -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 ( + + + + {prefix}{' '} + + + {isUser ? 'you' : 'assistant'} + + {formatTime(msg.timestamp)} + + + {msg.content} + + + ); +} + +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 ( + + {toolCall.status === 'running' ? ( + + + + ) : ( + {icon} + )} + tool: + {toolCall.toolName} + + ); +} + +export function MessageList({ + messages, + isStreaming, + currentStreamText, + currentThinkingText, + activeToolCalls, +}: MessageListProps) { + return ( + + {messages.length === 0 && !isStreaming && ( + + No messages yet. Type below to start a conversation. + + )} + + {messages.map((msg, i) => ( + + ))} + + {/* Active thinking */} + {isStreaming && currentThinkingText && ( + + + 💭 {currentThinkingText} + + + )} + + {/* Active tool calls */} + {activeToolCalls.length > 0 && ( + + {activeToolCalls.map((tc) => ( + + ))} + + )} + + {/* Streaming response */} + {isStreaming && currentStreamText && ( + + + + ◆{' '} + + + assistant + + + + {currentStreamText} + + + )} + + {/* Waiting spinner */} + {isStreaming && !currentStreamText && activeToolCalls.length === 0 && ( + + + + + thinking… + + )} + + ); +} diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx new file mode 100644 index 0000000..e4f4be5 --- /dev/null +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -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 ( + + + + mosaic + + + {indicator} + {statusLabel} + · {host} + + + {modelName && ( + <> + model: + {modelName} + + + )} + {conversationId && ( + <> + conv: + {conversationId.slice(0, 8)} + + )} + + + ); +} diff --git a/packages/cli/src/tui/hooks/use-git-info.ts b/packages/cli/src/tui/hooks/use-git-info.ts new file mode 100644 index 0000000..c37c455 --- /dev/null +++ b/packages/cli/src/tui/hooks/use-git-info.ts @@ -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({ + 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; +} diff --git a/packages/cli/src/tui/hooks/use-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts new file mode 100644 index 0000000..080e26d --- /dev/null +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -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; + +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([]); + const [conversationId, setConversationId] = useState(initialConversationId); + const [isStreaming, setIsStreaming] = useState(false); + const [currentStreamText, setCurrentStreamText] = useState(''); + const [currentThinkingText, setCurrentThinkingText] = useState(''); + const [activeToolCalls, setActiveToolCalls] = useState([]); + // 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(null); + + const socketRef = useRef(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, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd0c158..020b526 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,9 @@ importers: '@mosaic/quality-rails': specifier: workspace:^ version: link:../quality-rails + '@mosaic/types': + specifier: workspace:^ + version: link:../types commander: specifier: ^13.0.0 version: 13.1.0