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/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 18f3197..9e3bffe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -295,6 +295,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