— switch model (e.g. /model gpt-4o)',
- ' /model / — switch model with provider (e.g. /model ollama/llama3.2)',
- ' /provider — list available providers',
- ' /provider — switch provider (e.g. /provider ollama)',
- ' /help — show this help',
- ].join('\n'),
- },
- ]);
- return true;
- }
-
- // Unknown slash command — let the user know
- setMessages((msgs) => [
- ...msgs,
- {
- role: 'system',
- content: `Unknown command: /${command}. Type /help for available commands.`,
- },
- ]);
- return true;
+ const handleSwitchConversation = useCallback(
+ (id: string) => {
+ socket.switchConversation(id);
+ appMode.setMode('chat');
},
- [availableModels, currentModel, currentProvider],
+ [socket, appMode],
);
- const handleSubmit = useCallback(
- (value: string) => {
- if (!value.trim() || isStreaming) return;
-
- setInput('');
-
- // Handle slash commands first
- if (handleSlashCommand(value)) 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 }]);
-
- socketRef.current.emit('message', {
- conversationId,
- content: value,
- provider: currentProvider,
- modelId: currentModel,
- });
+ const handleDeleteConversation = useCallback(
+ (id: string) => {
+ void conversations
+ .deleteConversation(id)
+ .then((ok) => {
+ if (ok && id === socket.conversationId) {
+ socket.clearMessages();
+ }
+ })
+ .catch(() => {});
},
- [conversationId, isStreaming, currentModel, currentProvider, handleSlashCommand],
+ [conversations, socket],
);
useInput((ch, key) => {
if (key.ctrl && ch === 'c') {
exit();
}
+ // Ctrl+L: toggle sidebar (refresh on open)
+ if (key.ctrl && ch === 'l') {
+ const willOpen = !appMode.sidebarOpen;
+ appMode.toggleSidebar();
+ if (willOpen) {
+ void conversations.refresh();
+ }
+ }
+ // Ctrl+N: create new conversation and switch to it
+ if (key.ctrl && ch === 'n') {
+ void conversations
+ .createConversation()
+ .then((conv) => {
+ if (conv) {
+ socket.switchConversation(conv.id);
+ appMode.setMode('chat');
+ }
+ })
+ .catch(() => {});
+ }
+ // Ctrl+K: toggle search mode
+ if (key.ctrl && ch === 'k') {
+ if (appMode.mode === 'search') {
+ search.clear();
+ appMode.setMode('chat');
+ } else {
+ appMode.setMode('search');
+ }
+ }
+ // Page Up / Page Down: scroll message history (only in chat mode)
+ if (appMode.mode === 'chat') {
+ if (key.pageUp) {
+ viewport.scrollBy(-viewport.viewportSize);
+ }
+ if (key.pageDown) {
+ viewport.scrollBy(viewport.viewportSize);
+ }
+ }
+ // Ctrl+T: cycle thinking level
+ if (key.ctrl && ch === 't') {
+ const levels = socket.availableThinkingLevels;
+ if (levels.length > 0) {
+ const currentIdx = levels.indexOf(socket.thinkingLevel);
+ const nextIdx = (currentIdx + 1) % levels.length;
+ const next = levels[nextIdx];
+ if (next) {
+ socket.setThinkingLevel(next);
+ }
+ }
+ }
+ // Escape: return to chat from sidebar/search; in chat, scroll to bottom
+ if (key.escape) {
+ if (appMode.mode === 'search') {
+ search.clear();
+ appMode.setMode('chat');
+ } else if (appMode.mode === 'sidebar') {
+ appMode.setMode('chat');
+ } else if (appMode.mode === 'chat') {
+ viewport.scrollToBottom();
+ }
+ }
});
- const modelLabel = currentModel
- ? currentProvider
- ? `${currentProvider}/${currentModel}`
- : currentModel
- : null;
+ const inputPlaceholder =
+ appMode.mode === 'sidebar'
+ ? 'focus is on sidebar… press Esc to return'
+ : appMode.mode === 'search'
+ ? 'search mode… press Esc to return'
+ : undefined;
+
+ const isSearchMode = appMode.mode === 'search';
+
+ const messageArea = (
+
+
+
+ {isSearchMode && (
+ {
+ search.clear();
+ appMode.setMode('chat');
+ }}
+ focused={isSearchMode}
+ />
+ )}
+
+
+
+ );
return (
-
-
-
- Mosaic
-
-
- {connected ? `connected` : 'connecting...'}
- {conversationId && | {conversationId.slice(0, 8)}}
- {modelLabel && (
- <>
- |
- {modelLabel}
- >
- )}
-
+
+
+
-
- {messages.map((msg, i) => (
-
- {msg.role === 'system' ? (
-
- {msg.content}
-
- ) : (
- <>
-
- {msg.role === 'user' ? '> ' : ' '}
-
- {msg.content}
- >
- )}
-
- ))}
+ {appMode.sidebarOpen ? (
+
+
+ {messageArea}
+
+ ) : (
+ {messageArea}
+ )}
- {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..53c80fe
--- /dev/null
+++ b/packages/cli/src/tui/components/bottom-bar.tsx
@@ -0,0 +1,125 @@
+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;
+ connected: boolean;
+ connecting: boolean;
+ modelName: string | null;
+ providerName: string | null;
+ thinkingLevel: string;
+ conversationId: string | undefined;
+}
+
+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(0)}k`;
+ return String(n);
+}
+
+/** Compact the cwd — replace home with ~ */
+function compactCwd(cwd: string): string {
+ const home = process.env['HOME'] ?? '';
+ if (home && cwd.startsWith(home)) {
+ return '~' + cwd.slice(home.length);
+ }
+ return cwd;
+}
+
+export function BottomBar({
+ gitInfo,
+ tokenUsage,
+ connected,
+ connecting,
+ modelName,
+ providerName,
+ thinkingLevel,
+ conversationId,
+}: BottomBarProps) {
+ const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
+ const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
+
+ const hasTokens = tokenUsage.total > 0;
+
+ return (
+
+ {/* Line 0: keybinding hints */}
+
+ ^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll
+
+
+ {/* Line 1: blank ····· Gateway: Status */}
+
+
+
+ Gateway:
+ {gatewayStatus}
+
+
+
+ {/* Line 2: cwd (branch) ····· Session: id */}
+
+
+ {compactCwd(gitInfo.cwd)}
+ {gitInfo.branch && ({gitInfo.branch})}
+
+
+
+ {conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
+
+
+
+
+ {/* Line 3: token stats ····· (provider) model */}
+
+
+ {hasTokens ? (
+ <>
+ ↑{formatTokens(tokenUsage.input)}
+ {' '}
+ ↓{formatTokens(tokenUsage.output)}
+ {tokenUsage.cacheRead > 0 && (
+ <>
+ {' '}
+ R{formatTokens(tokenUsage.cacheRead)}
+ >
+ )}
+ {tokenUsage.cacheWrite > 0 && (
+ <>
+ {' '}
+ W{formatTokens(tokenUsage.cacheWrite)}
+ >
+ )}
+ {tokenUsage.cost > 0 && (
+ <>
+ {' '}
+ ${tokenUsage.cost.toFixed(3)}
+ >
+ )}
+ {tokenUsage.contextPercent > 0 && (
+ <>
+ {' '}
+
+ {tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
+
+ >
+ )}
+ >
+ ) : (
+ ↑0 ↓0 $0.000
+ )}
+
+
+
+ {providerName ? `(${providerName}) ` : ''}
+ {modelName ?? 'awaiting model'}
+ {thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''}
+
+
+
+
+ );
+}
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..ea40a4e
--- /dev/null
+++ b/packages/cli/src/tui/components/input-bar.tsx
@@ -0,0 +1,50 @@
+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;
+ placeholder?: string;
+}
+
+export function InputBar({
+ onSubmit,
+ isStreaming,
+ connected,
+ placeholder: placeholderOverride,
+}: InputBarProps) {
+ const [input, setInput] = useState('');
+
+ const handleSubmit = useCallback(
+ (value: string) => {
+ if (!value.trim() || isStreaming || !connected) return;
+ onSubmit(value);
+ setInput('');
+ },
+ [onSubmit, isStreaming, connected],
+ );
+
+ const placeholder =
+ placeholderOverride ??
+ (!connected
+ ? 'disconnected — waiting for gateway…'
+ : isStreaming
+ ? 'waiting for response…'
+ : 'message mosaic…');
+
+ 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..beb09fb
--- /dev/null
+++ b/packages/cli/src/tui/components/message-list.tsx
@@ -0,0 +1,177 @@
+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[];
+ scrollOffset?: number;
+ viewportSize?: number;
+ isScrolledUp?: boolean;
+ highlightedMessageIndices?: Set;
+ currentHighlightIndex?: number;
+}
+
+function formatTime(date: Date): string {
+ return date.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ });
+}
+
+function MessageBubble({
+ msg,
+ highlight,
+}: {
+ msg: Message;
+ highlight?: 'match' | 'current' | undefined;
+}) {
+ const isUser = msg.role === 'user';
+ const prefix = isUser ? '❯' : '◆';
+ const color = isUser ? 'green' : 'cyan';
+
+ const borderIndicator =
+ highlight === 'current' ? (
+
+ ▌{' '}
+
+ ) : highlight === 'match' ? (
+ ▌
+ ) : null;
+
+ return (
+
+ {borderIndicator}
+
+
+
+ {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,
+ scrollOffset,
+ viewportSize,
+ isScrolledUp,
+ highlightedMessageIndices,
+ currentHighlightIndex,
+}: MessageListProps) {
+ const useSlicing = scrollOffset != null && viewportSize != null;
+ const visibleMessages = useSlicing
+ ? messages.slice(scrollOffset, scrollOffset + viewportSize)
+ : messages;
+ const hiddenAbove = useSlicing ? scrollOffset : 0;
+
+ return (
+
+ {isScrolledUp && hiddenAbove > 0 && (
+
+ ↑ {hiddenAbove} more messages ↑
+
+ )}
+
+ {messages.length === 0 && !isStreaming && (
+
+ No messages yet. Type below to start a conversation.
+
+ )}
+
+ {visibleMessages.map((msg, i) => {
+ const globalIndex = hiddenAbove + i;
+ const highlight =
+ globalIndex === currentHighlightIndex
+ ? ('current' as const)
+ : highlightedMessageIndices?.has(globalIndex)
+ ? ('match' as const)
+ : undefined;
+ return ;
+ })}
+
+ {/* 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/search-bar.tsx b/packages/cli/src/tui/components/search-bar.tsx
new file mode 100644
index 0000000..5d42fef
--- /dev/null
+++ b/packages/cli/src/tui/components/search-bar.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { Box, Text, useInput } from 'ink';
+import TextInput from 'ink-text-input';
+
+export interface SearchBarProps {
+ query: string;
+ onQueryChange: (q: string) => void;
+ totalMatches: number;
+ currentMatch: number;
+ onNext: () => void;
+ onPrev: () => void;
+ onClose: () => void;
+ focused: boolean;
+}
+
+export function SearchBar({
+ query,
+ onQueryChange,
+ totalMatches,
+ currentMatch,
+ onNext,
+ onPrev,
+ onClose,
+ focused,
+}: SearchBarProps) {
+ useInput(
+ (_input, key) => {
+ if (key.upArrow) {
+ onPrev();
+ }
+ if (key.downArrow) {
+ onNext();
+ }
+ if (key.escape) {
+ onClose();
+ }
+ },
+ { isActive: focused },
+ );
+
+ const borderColor = focused ? 'yellow' : 'gray';
+
+ const matchDisplay =
+ query.length >= 2
+ ? totalMatches > 0
+ ? `${String(currentMatch + 1)}/${String(totalMatches)}`
+ : 'no matches'
+ : '';
+
+ return (
+
+ 🔍
+
+
+
+ {matchDisplay && {matchDisplay}}
+ ↑↓ navigate · Esc close
+
+ );
+}
diff --git a/packages/cli/src/tui/components/sidebar.tsx b/packages/cli/src/tui/components/sidebar.tsx
new file mode 100644
index 0000000..f4e3497
--- /dev/null
+++ b/packages/cli/src/tui/components/sidebar.tsx
@@ -0,0 +1,143 @@
+import React from 'react';
+import { Box, Text, useInput } from 'ink';
+import type { ConversationSummary } from '../hooks/use-conversations.js';
+
+export interface SidebarProps {
+ conversations: ConversationSummary[];
+ activeConversationId: string | undefined;
+ selectedIndex: number;
+ onSelectIndex: (index: number) => void;
+ onSwitchConversation: (id: string) => void;
+ onDeleteConversation: (id: string) => void;
+ loading: boolean;
+ focused: boolean;
+ width: number;
+}
+
+function formatRelativeTime(dateStr: string): string {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) {
+ const hh = String(date.getHours()).padStart(2, '0');
+ const mm = String(date.getMinutes()).padStart(2, '0');
+ return `${hh}:${mm}`;
+ }
+ if (diffDays < 7) {
+ return `${diffDays}d ago`;
+ }
+ const months = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+ const mon = months[date.getMonth()];
+ const dd = String(date.getDate()).padStart(2, '0');
+ return `${mon} ${dd}`;
+}
+
+function truncate(text: string, maxLen: number): string {
+ if (text.length <= maxLen) return text;
+ return text.slice(0, maxLen - 1) + '…';
+}
+
+export function Sidebar({
+ conversations,
+ activeConversationId,
+ selectedIndex,
+ onSelectIndex,
+ onSwitchConversation,
+ onDeleteConversation,
+ loading,
+ focused,
+ width,
+}: SidebarProps) {
+ useInput(
+ (_input, key) => {
+ if (key.upArrow) {
+ onSelectIndex(Math.max(0, selectedIndex - 1));
+ }
+ if (key.downArrow) {
+ onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
+ }
+ if (key.return) {
+ const conv = conversations[selectedIndex];
+ if (conv) {
+ onSwitchConversation(conv.id);
+ }
+ }
+ if (_input === 'd') {
+ const conv = conversations[selectedIndex];
+ if (conv) {
+ onDeleteConversation(conv.id);
+ }
+ }
+ },
+ { isActive: focused },
+ );
+
+ const borderColor = focused ? 'cyan' : 'gray';
+ // Available width for content inside border + padding
+ const innerWidth = width - 4; // 2 border + 2 padding
+
+ return (
+
+
+ Conversations
+
+
+ {loading && conversations.length === 0 ? (
+ Loading…
+ ) : conversations.length === 0 ? (
+ No conversations
+ ) : (
+ conversations.map((conv, idx) => {
+ const isActive = conv.id === activeConversationId;
+ const isSelected = idx === selectedIndex && focused;
+ const marker = isActive ? '● ' : ' ';
+ const time = formatRelativeTime(conv.updatedAt);
+ const title = conv.title ?? 'Untitled';
+ // marker(2) + title + space(1) + time
+ const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
+ const displayTitle = truncate(title, maxTitleLen);
+
+ return (
+
+
+ {marker}
+ {displayTitle}
+ {' '.repeat(
+ Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
+ )}
+ {time}
+
+
+ );
+ })
+ )}
+
+ {focused && ↑↓ navigate • enter switch • d delete}
+
+ );
+}
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..1c28c39
--- /dev/null
+++ b/packages/cli/src/tui/components/top-bar.tsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import { Box, Text } from 'ink';
+
+export interface TopBarProps {
+ gatewayUrl: string;
+ version: string;
+ modelName: string | null;
+ thinkingLevel: string;
+ contextWindow: number;
+ agentName: string;
+ connected: boolean;
+ connecting: boolean;
+}
+
+/** Compact the URL — strip protocol */
+function compactHost(url: string): string {
+ return url.replace(/^https?:\/\//, '');
+}
+
+function formatContextWindow(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
+ return String(n);
+}
+
+/**
+ * Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
+ *
+ * Layout:
+ * blue ·· purple
+ * ·· pink ··
+ * amber ·· teal
+ */
+// Two-space gap between tiles (extracted to avoid prettier collapse)
+const GAP = ' ';
+
+function MosaicIcon() {
+ return (
+
+
+ ██
+ {GAP}
+ ██
+
+
+ {GAP}
+ ██
+
+
+ ██
+ {GAP}
+ ██
+
+
+ );
+}
+
+export function TopBar({
+ gatewayUrl,
+ version,
+ modelName,
+ thinkingLevel,
+ contextWindow,
+ agentName,
+ connected,
+ connecting,
+}: TopBarProps) {
+ const host = compactHost(gatewayUrl);
+ const connectionIndicator = connected ? '●' : '○';
+ const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
+
+ // Build model description line like: "claude-opus-4-6 (1M context) · default"
+ const modelDisplay = modelName ?? 'awaiting model';
+ const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
+ const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
+
+ return (
+
+
+
+
+
+ Mosaic Stack
+
+ v{version}
+
+
+ {modelDisplay}
+ {contextStr}
+ {thinkingStr} · {agentName}
+
+
+ {connectionIndicator}
+ {host}
+
+
+
+ );
+}
diff --git a/packages/cli/src/tui/hooks/use-app-mode.ts b/packages/cli/src/tui/hooks/use-app-mode.ts
new file mode 100644
index 0000000..52fc3e0
--- /dev/null
+++ b/packages/cli/src/tui/hooks/use-app-mode.ts
@@ -0,0 +1,37 @@
+import { useState, useCallback } from 'react';
+
+export type AppMode = 'chat' | 'sidebar' | 'search';
+
+export interface UseAppModeReturn {
+ mode: AppMode;
+ setMode: (mode: AppMode) => void;
+ toggleSidebar: () => void;
+ sidebarOpen: boolean;
+}
+
+export function useAppMode(): UseAppModeReturn {
+ const [mode, setModeState] = useState('chat');
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ const setMode = useCallback((next: AppMode) => {
+ setModeState(next);
+ if (next === 'sidebar') {
+ setSidebarOpen(true);
+ }
+ }, []);
+
+ const toggleSidebar = useCallback(() => {
+ setSidebarOpen((prev) => {
+ if (prev) {
+ // Closing sidebar — return to chat
+ setModeState('chat');
+ return false;
+ }
+ // Opening sidebar — set mode to sidebar
+ setModeState('sidebar');
+ return true;
+ });
+ }, []);
+
+ return { mode, setMode, toggleSidebar, sidebarOpen };
+}
diff --git a/packages/cli/src/tui/hooks/use-conversations.ts b/packages/cli/src/tui/hooks/use-conversations.ts
new file mode 100644
index 0000000..0855eca
--- /dev/null
+++ b/packages/cli/src/tui/hooks/use-conversations.ts
@@ -0,0 +1,139 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+
+export interface ConversationSummary {
+ id: string;
+ title: string | null;
+ archived: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface UseConversationsOptions {
+ gatewayUrl: string;
+ sessionCookie?: string;
+}
+
+export interface UseConversationsReturn {
+ conversations: ConversationSummary[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ createConversation: (title?: string) => Promise;
+ deleteConversation: (id: string) => Promise;
+ renameConversation: (id: string, title: string) => Promise;
+}
+
+export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
+ const { gatewayUrl, sessionCookie } = opts;
+
+ const [conversations, setConversations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const mountedRef = useRef(true);
+
+ const headers = useCallback((): Record => {
+ const h: Record = { 'Content-Type': 'application/json' };
+ if (sessionCookie) h['Cookie'] = sessionCookie;
+ return h;
+ }, [sessionCookie]);
+
+ const refresh = useCallback(async () => {
+ if (!mountedRef.current) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers() });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = (await res.json()) as ConversationSummary[];
+ if (mountedRef.current) {
+ setConversations(data);
+ }
+ } catch (err) {
+ if (mountedRef.current) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ }
+ } finally {
+ if (mountedRef.current) {
+ setLoading(false);
+ }
+ }
+ }, [gatewayUrl, headers]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ void refresh();
+ return () => {
+ mountedRef.current = false;
+ };
+ }, [refresh]);
+
+ const createConversation = useCallback(
+ async (title?: string): Promise => {
+ try {
+ const res = await fetch(`${gatewayUrl}/api/conversations`, {
+ method: 'POST',
+ headers: headers(),
+ body: JSON.stringify({ title: title ?? null }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = (await res.json()) as ConversationSummary;
+ if (mountedRef.current) {
+ setConversations((prev) => [data, ...prev]);
+ }
+ return data;
+ } catch {
+ return null;
+ }
+ },
+ [gatewayUrl, headers],
+ );
+
+ const deleteConversation = useCallback(
+ async (id: string): Promise => {
+ try {
+ const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
+ method: 'DELETE',
+ headers: headers(),
+ });
+ if (!res.ok) return false;
+ if (mountedRef.current) {
+ setConversations((prev) => prev.filter((c) => c.id !== id));
+ }
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ [gatewayUrl, headers],
+ );
+
+ const renameConversation = useCallback(
+ async (id: string, title: string): Promise => {
+ try {
+ const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
+ method: 'PATCH',
+ headers: headers(),
+ body: JSON.stringify({ title }),
+ });
+ if (!res.ok) return false;
+ if (mountedRef.current) {
+ setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
+ }
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ [gatewayUrl, headers],
+ );
+
+ return {
+ conversations,
+ loading,
+ error,
+ refresh,
+ createConversation,
+ deleteConversation,
+ renameConversation,
+ };
+}
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-search.ts b/packages/cli/src/tui/hooks/use-search.ts
new file mode 100644
index 0000000..5ede4fe
--- /dev/null
+++ b/packages/cli/src/tui/hooks/use-search.ts
@@ -0,0 +1,76 @@
+import { useState, useMemo, useCallback } from 'react';
+import type { Message } from './use-socket.js';
+
+export interface SearchMatch {
+ messageIndex: number;
+ charOffset: number;
+}
+
+export interface UseSearchReturn {
+ query: string;
+ setQuery: (q: string) => void;
+ matches: SearchMatch[];
+ currentMatchIndex: number;
+ nextMatch: () => void;
+ prevMatch: () => void;
+ clear: () => void;
+ totalMatches: number;
+}
+
+export function useSearch(messages: Message[]): UseSearchReturn {
+ const [query, setQuery] = useState('');
+ const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
+
+ const matches = useMemo(() => {
+ if (query.length < 2) return [];
+
+ const lowerQuery = query.toLowerCase();
+ const result: SearchMatch[] = [];
+
+ for (let i = 0; i < messages.length; i++) {
+ const msg = messages[i];
+ if (!msg) continue;
+ const content = msg.content.toLowerCase();
+ let offset = 0;
+ while (true) {
+ const idx = content.indexOf(lowerQuery, offset);
+ if (idx === -1) break;
+ result.push({ messageIndex: i, charOffset: idx });
+ offset = idx + 1;
+ }
+ }
+
+ return result;
+ }, [query, messages]);
+
+ // Reset match index when matches change
+ useMemo(() => {
+ setCurrentMatchIndex(0);
+ }, [matches]);
+
+ const nextMatch = useCallback(() => {
+ if (matches.length === 0) return;
+ setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
+ }, [matches.length]);
+
+ const prevMatch = useCallback(() => {
+ if (matches.length === 0) return;
+ setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
+ }, [matches.length]);
+
+ const clear = useCallback(() => {
+ setQuery('');
+ setCurrentMatchIndex(0);
+ }, []);
+
+ return {
+ query,
+ setQuery,
+ matches,
+ currentMatchIndex,
+ nextMatch,
+ prevMatch,
+ clear,
+ totalMatches: matches.length,
+ };
+}
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..7ce9d55
--- /dev/null
+++ b/packages/cli/src/tui/hooks/use-socket.ts
@@ -0,0 +1,284 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { io, type Socket } from 'socket.io-client';
+import type {
+ ServerToClientEvents,
+ ClientToServerEvents,
+ MessageAckPayload,
+ AgentEndPayload,
+ AgentTextPayload,
+ AgentThinkingPayload,
+ ToolStartPayload,
+ ToolEndPayload,
+ SessionInfoPayload,
+ 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;
+ cacheRead: number;
+ cacheWrite: number;
+ cost: number;
+ contextPercent: number;
+ contextWindow: number;
+}
+
+export interface UseSocketOptions {
+ gatewayUrl: string;
+ sessionCookie?: string;
+ initialConversationId?: string;
+ initialModel?: string;
+ initialProvider?: 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;
+ providerName: string | null;
+ thinkingLevel: string;
+ availableThinkingLevels: string[];
+ sendMessage: (content: string) => void;
+ setThinkingLevel: (level: string) => void;
+ switchConversation: (id: string) => void;
+ clearMessages: () => void;
+ connectionError: string | null;
+}
+
+type TypedSocket = Socket;
+
+const EMPTY_USAGE: TokenUsage = {
+ input: 0,
+ output: 0,
+ total: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ cost: 0,
+ contextPercent: 0,
+ contextWindow: 0,
+};
+
+export function useSocket(opts: UseSocketOptions): UseSocketReturn {
+ const { gatewayUrl, sessionCookie, initialConversationId, initialModel, initialProvider } = 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([]);
+ const [tokenUsage, setTokenUsage] = useState(EMPTY_USAGE);
+ const [modelName, setModelName] = useState(null);
+ const [providerName, setProviderName] = useState(null);
+ const [thinkingLevel, setThinkingLevelState] = useState('off');
+ const [availableThinkingLevels, setAvailableThinkingLevels] = useState([]);
+ const [connectionError, setConnectionError] = useState(null);
+
+ const socketRef = useRef(null);
+ const conversationIdRef = useRef(conversationId);
+ conversationIdRef.current = conversationId;
+
+ 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('session:info', (data: SessionInfoPayload) => {
+ setProviderName(data.provider);
+ setModelName(data.modelId);
+ setThinkingLevelState(data.thinkingLevel);
+ setAvailableThinkingLevels(data.availableThinkingLevels);
+ });
+
+ 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', (data: AgentEndPayload) => {
+ setCurrentStreamText((prev) => {
+ if (prev) {
+ setMessages((msgs) => [
+ ...msgs,
+ { role: 'assistant', content: prev, timestamp: new Date() },
+ ]);
+ }
+ return '';
+ });
+ setCurrentThinkingText('');
+ setActiveToolCalls([]);
+ setIsStreaming(false);
+
+ // Update usage from the payload
+ if (data.usage) {
+ setProviderName(data.usage.provider);
+ setModelName(data.usage.modelId);
+ setThinkingLevelState(data.usage.thinkingLevel);
+ setTokenUsage({
+ input: data.usage.tokens.input,
+ output: data.usage.tokens.output,
+ total: data.usage.tokens.total,
+ cacheRead: data.usage.tokens.cacheRead,
+ cacheWrite: data.usage.tokens.cacheWrite,
+ cost: data.usage.cost,
+ contextPercent: data.usage.context.percent ?? 0,
+ contextWindow: data.usage.context.window,
+ });
+ }
+ });
+
+ 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,
+ ...(initialProvider ? { provider: initialProvider } : {}),
+ ...(initialModel ? { modelId: initialModel } : {}),
+ });
+ },
+ [conversationId, isStreaming],
+ );
+
+ const setThinkingLevel = useCallback((level: string) => {
+ const cid = conversationIdRef.current;
+ if (!socketRef.current?.connected || !cid) return;
+ socketRef.current.emit('set:thinking', {
+ conversationId: cid,
+ level,
+ });
+ }, []);
+
+ const clearMessages = useCallback(() => {
+ setMessages([]);
+ setCurrentStreamText('');
+ setCurrentThinkingText('');
+ setActiveToolCalls([]);
+ setIsStreaming(false);
+ }, []);
+
+ const switchConversation = useCallback(
+ (id: string) => {
+ clearMessages();
+ setConversationId(id);
+ },
+ [clearMessages],
+ );
+
+ return {
+ connected,
+ connecting,
+ messages,
+ conversationId,
+ isStreaming,
+ currentStreamText,
+ currentThinkingText,
+ activeToolCalls,
+ tokenUsage,
+ modelName,
+ providerName,
+ thinkingLevel,
+ availableThinkingLevels,
+ sendMessage,
+ setThinkingLevel,
+ switchConversation,
+ clearMessages,
+ connectionError,
+ };
+}
diff --git a/packages/cli/src/tui/hooks/use-viewport.ts b/packages/cli/src/tui/hooks/use-viewport.ts
new file mode 100644
index 0000000..54a1bfc
--- /dev/null
+++ b/packages/cli/src/tui/hooks/use-viewport.ts
@@ -0,0 +1,80 @@
+import { useState, useCallback, useEffect } from 'react';
+import { useStdout } from 'ink';
+
+export interface UseViewportOptions {
+ totalItems: number;
+ reservedLines?: number;
+}
+
+export interface UseViewportReturn {
+ scrollOffset: number;
+ viewportSize: number;
+ isScrolledUp: boolean;
+ scrollToBottom: () => void;
+ scrollBy: (delta: number) => void;
+ scrollTo: (offset: number) => void;
+ canScrollUp: boolean;
+ canScrollDown: boolean;
+}
+
+export function useViewport({
+ totalItems,
+ reservedLines = 10,
+}: UseViewportOptions): UseViewportReturn {
+ const { stdout } = useStdout();
+ const rows = stdout?.rows ?? 24;
+ const viewportSize = Math.max(1, rows - reservedLines);
+
+ const [scrollOffset, setScrollOffset] = useState(0);
+ const [autoFollow, setAutoFollow] = useState(true);
+
+ // Compute the maximum valid scroll offset
+ const maxOffset = Math.max(0, totalItems - viewportSize);
+
+ // Auto-follow: when new items arrive and auto-follow is on, snap to bottom
+ useEffect(() => {
+ if (autoFollow) {
+ setScrollOffset(maxOffset);
+ }
+ }, [autoFollow, maxOffset]);
+
+ const scrollTo = useCallback(
+ (offset: number) => {
+ const clamped = Math.max(0, Math.min(offset, maxOffset));
+ setScrollOffset(clamped);
+ setAutoFollow(clamped >= maxOffset);
+ },
+ [maxOffset],
+ );
+
+ const scrollBy = useCallback(
+ (delta: number) => {
+ setScrollOffset((prev) => {
+ const next = Math.max(0, Math.min(prev + delta, maxOffset));
+ setAutoFollow(next >= maxOffset);
+ return next;
+ });
+ },
+ [maxOffset],
+ );
+
+ const scrollToBottom = useCallback(() => {
+ setScrollOffset(maxOffset);
+ setAutoFollow(true);
+ }, [maxOffset]);
+
+ const isScrolledUp = scrollOffset < maxOffset;
+ const canScrollUp = scrollOffset > 0;
+ const canScrollDown = scrollOffset < maxOffset;
+
+ return {
+ scrollOffset,
+ viewportSize,
+ isScrolledUp,
+ scrollToBottom,
+ scrollBy,
+ scrollTo,
+ canScrollUp,
+ canScrollDown,
+ };
+}
diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts
index 1f61358..0cf999d 100644
--- a/packages/types/src/chat/events.ts
+++ b/packages/types/src/chat/events.ts
@@ -9,6 +9,26 @@ export interface AgentStartPayload {
export interface AgentEndPayload {
conversationId: string;
+ usage?: SessionUsagePayload;
+}
+
+/** Session metadata emitted with agent:end and on session:info */
+export interface SessionUsagePayload {
+ provider: string;
+ modelId: string;
+ thinkingLevel: string;
+ tokens: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ total: number;
+ };
+ cost: number;
+ context: {
+ percent: number | null;
+ window: number;
+ };
}
export interface AgentTextPayload {
@@ -42,6 +62,23 @@ export interface ErrorPayload {
export interface ChatMessagePayload {
conversationId?: string;
content: string;
+ provider?: string;
+ modelId?: string;
+}
+
+/** Session info pushed when session is created or model changes */
+export interface SessionInfoPayload {
+ conversationId: string;
+ provider: string;
+ modelId: string;
+ thinkingLevel: string;
+ availableThinkingLevels: string[];
+}
+
+/** Client request to change thinking level */
+export interface SetThinkingPayload {
+ conversationId: string;
+ level: string;
}
/** Socket.IO typed event map: server → client */
@@ -53,10 +90,12 @@ export interface ServerToClientEvents {
'agent:thinking': (payload: AgentThinkingPayload) => void;
'agent:tool:start': (payload: ToolStartPayload) => void;
'agent:tool:end': (payload: ToolEndPayload) => void;
+ 'session:info': (payload: SessionInfoPayload) => void;
error: (payload: ErrorPayload) => void;
}
/** Socket.IO typed event map: client → server */
export interface ClientToServerEvents {
message: (data: ChatMessagePayload) => void;
+ 'set:thinking': (data: SetThinkingPayload) => void;
}
diff --git a/packages/types/src/chat/index.ts b/packages/types/src/chat/index.ts
index 551a146..7d039a7 100644
--- a/packages/types/src/chat/index.ts
+++ b/packages/types/src/chat/index.ts
@@ -7,6 +7,9 @@ export type {
AgentThinkingPayload,
ToolStartPayload,
ToolEndPayload,
+ SessionUsagePayload,
+ SessionInfoPayload,
+ SetThinkingPayload,
ErrorPayload,
ChatMessagePayload,
ServerToClientEvents,
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