From 9ae1bac6147feda2c1186f9f2b15b6ed1708a502 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 13:33:37 -0500 Subject: [PATCH 01/26] =?UTF-8?q?feat(cli):=20TUI=20component=20architectu?= =?UTF-8?q?re=20=E2=80=94=20status=20bars,=20message=20list,=20input=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/PRD-TUI_Improvements.md | 70 +++++++ docs/TASKS-TUI_Improvements.md | 36 ++++ packages/cli/package.json | 7 +- .../cli/src/tui/components/bottom-bar.tsx | 61 ++++++ packages/cli/src/tui/components/input-bar.tsx | 44 ++++ .../cli/src/tui/components/message-list.tsx | 130 ++++++++++++ packages/cli/src/tui/components/top-bar.tsx | 55 +++++ packages/cli/src/tui/hooks/use-git-info.ts | 29 +++ packages/cli/src/tui/hooks/use-socket.ts | 197 ++++++++++++++++++ pnpm-lock.yaml | 3 + 10 files changed, 629 insertions(+), 3 deletions(-) create mode 100644 docs/PRD-TUI_Improvements.md create mode 100644 docs/TASKS-TUI_Improvements.md create mode 100644 packages/cli/src/tui/components/bottom-bar.tsx create mode 100644 packages/cli/src/tui/components/input-bar.tsx create mode 100644 packages/cli/src/tui/components/message-list.tsx create mode 100644 packages/cli/src/tui/components/top-bar.tsx create mode 100644 packages/cli/src/tui/hooks/use-git-info.ts create mode 100644 packages/cli/src/tui/hooks/use-socket.ts 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 -- 2.49.1 From 3e7e86098438fc6fad395984f0dff29c3b46c033 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 13:34:06 -0500 Subject: [PATCH 02/26] =?UTF-8?q?chore:=20update=20TUI=20task=20tracker=20?= =?UTF-8?q?=E2=80=94=20Wave=201=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TASKS-TUI_Improvements.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/TASKS-TUI_Improvements.md b/docs/TASKS-TUI_Improvements.md index af70d73..3fec7ae 100644 --- a/docs/TASKS-TUI_Improvements.md +++ b/docs/TASKS-TUI_Improvements.md @@ -7,15 +7,15 @@ ## 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 | +| ID | Task | Status | Notes | +| ------- | ------------------------------------------------------------------------------------------------- | ------- | ------- | +| TUI-001 | Component architecture — split `app.tsx` into `StatusBar`, `MessageList`, `InputBar`, `App` shell | ✅ done | 79ff308 | +| TUI-002 | Top status bar — connection indicator (●/○), gateway URL, agent model | ✅ done | 79ff308 | +| TUI-003 | Bottom status bar — cwd, git branch, token usage | ✅ done | 79ff308 | +| TUI-004 | Message formatting — timestamps, role colors, markdown-lite rendering, word wrap | ✅ done | 79ff308 | +| TUI-005 | Quiet disconnect — suppress error flood, single-line reconnecting indicator | ✅ done | 79ff308 | +| TUI-006 | Tool call display — inline spinner + tool name during execution, ✓/✗ on completion | ✅ done | 79ff308 | +| TUI-007 | Thinking/reasoning display — dimmed collapsible block for `agent:thinking` events | ✅ done | 79ff308 | ## Wave 2 — Layout & Navigation -- 2.49.1 From e42d6eadff49520c60adbd4db922ecb30c474c7b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 13:53:54 -0500 Subject: [PATCH 03/26] feat(cli): match TUI footer to reference design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove borders from input bar — clean '❯ message mosaic…' prompt - Two-line footer without borders: - Line 1: compact cwd (branch) | Gateway: Connected/Disconnected - Line 2: token stats (^in v_out R_cache W_cache $cost ctx%) | (provider) model - Extend TokenUsage with cacheRead, cacheWrite, cost, contextPercent, contextWindow - Add providerName to socket hook return - Reorder layout: top bar → messages → input → footer --- .../cli/src/tui/components/bottom-bar.tsx | 108 +++++++++++++----- packages/cli/src/tui/components/input-bar.tsx | 8 +- packages/cli/src/tui/hooks/use-socket.ts | 19 ++- 3 files changed, 98 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx index 6d00e21..7c494db 100644 --- a/packages/cli/src/tui/components/bottom-bar.tsx +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -6,55 +6,101 @@ 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; } 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`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; return String(n); } -/** Compact the cwd — show ~ for home, truncate long paths */ +/** Compact the cwd — replace home with ~ */ 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.slice(home.length); } return cwd; } -export function BottomBar({ gitInfo, tokenUsage }: BottomBarProps) { +export function BottomBar({ + gitInfo, + tokenUsage, + connected, + connecting, + modelName, + providerName, +}: BottomBarProps) { + const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected'; + const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red'; + const hasTokens = tokenUsage.total > 0; return ( - - - cwd: - {compactCwd(gitInfo.cwd)} - {gitInfo.branch && ( - <> - - {gitInfo.branch} - - )} + + {/* Line 1: path (branch) ····· Gateway: Status */} + + + {compactCwd(gitInfo.cwd)} + {gitInfo.branch && ({gitInfo.branch})} + + + Gateway: + {gatewayStatus} + - - {hasTokens ? ( - <> - tokens: - ↑{formatTokens(tokenUsage.input)} - / - ↓{formatTokens(tokenUsage.output)} - ({formatTokens(tokenUsage.total)}) - - ) : ( - tokens: — - )} + + {/* Line 2: token stats ····· (provider) model */} + + + {hasTokens ? ( + <> + ^{formatTokens(tokenUsage.input)} + {' '} + v{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)} + + + )} + + ) : ( + + )} + + + {(providerName ?? modelName) && ( + + {providerName ? `(${providerName}) ` : ''} + {modelName ?? ''} + + )} + ); diff --git a/packages/cli/src/tui/components/input-bar.tsx b/packages/cli/src/tui/components/input-bar.tsx index 6f65ef6..ab02c2f 100644 --- a/packages/cli/src/tui/components/input-bar.tsx +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -26,12 +26,10 @@ export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) { ? 'waiting for response…' : 'message mosaic…'; - const promptColor = !connected ? 'red' : isStreaming ? 'yellow' : 'green'; - return ( - - - ❯{' '} + + + {'❯ '} void; connectionError: string | null; } @@ -65,8 +71,18 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { 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 tokenUsage: TokenUsage = { + input: 0, + output: 0, + total: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + contextPercent: 0, + contextWindow: 0, + }; const modelName: string | null = null; + const providerName: string | null = null; const [connectionError, setConnectionError] = useState(null); const socketRef = useRef(null); @@ -191,6 +207,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { activeToolCalls, tokenUsage, modelName, + providerName, sendMessage, connectionError, }; -- 2.49.1 From e6708c18ed8d870b3427b73afed211a1af02a6ef Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 13:56:03 -0500 Subject: [PATCH 04/26] fix(cli): ensure footer line 2 always renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show '^0 v0 $0.000' instead of '—' when no token data yet - Always show model slot ('awaiting model' as placeholder) - Add minHeight={1} to prevent line collapse --- packages/cli/src/tui/components/bottom-bar.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx index 7c494db..9729a73 100644 --- a/packages/cli/src/tui/components/bottom-bar.tsx +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -55,7 +55,7 @@ export function BottomBar({ {/* Line 2: token stats ····· (provider) model */} - + {hasTokens ? ( <> @@ -90,16 +90,14 @@ export function BottomBar({ )} ) : ( - + ^0 v0 $0.000 )} - {(providerName ?? modelName) && ( - - {providerName ? `(${providerName}) ` : ''} - {modelName ?? ''} - - )} + + {providerName ? `(${providerName}) ` : ''} + {modelName ?? 'awaiting model'} + -- 2.49.1 From d7a03fd68b77f10289648e9489fb9fad0c3c0aaa Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 13:57:20 -0500 Subject: [PATCH 05/26] fix(cli): restore border around input bar --- packages/cli/src/tui/components/input-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/tui/components/input-bar.tsx b/packages/cli/src/tui/components/input-bar.tsx index ab02c2f..39f2e07 100644 --- a/packages/cli/src/tui/components/input-bar.tsx +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -27,7 +27,7 @@ export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) { : 'message mosaic…'; return ( - + {'❯ '} -- 2.49.1 From 16409ff42d320594769efa9c7b3f731dbc3b3c5e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 13:59:48 -0500 Subject: [PATCH 06/26] =?UTF-8?q?feat(cli):=20simplify=20top=20bar=20?= =?UTF-8?q?=E2=80=94=20title=20+=20host=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show 'Mosaic Stack TUI' on left, gateway host on right - Remove connection status from top bar (lives in footer) - Remove model/conversation from top bar (lives in footer) --- packages/cli/src/tui/components/top-bar.tsx | 45 +++------------------ 1 file changed, 5 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index e4f4be5..fff5133 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -2,54 +2,19 @@ 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'; - +export function TopBar({ gatewayUrl }: TopBarProps) { // Strip protocol for compact display const host = gatewayUrl.replace(/^https?:\/\//, ''); return ( - - - mosaic - - - {indicator} - {statusLabel} - · {host} - - - {modelName && ( - <> - model: - {modelName} - - - )} - {conversationId && ( - <> - conv: - {conversationId.slice(0, 8)} - - )} - + + Mosaic Stack TUI + + {host} ); } -- 2.49.1 From 06887e59c79fc433e5932e455f4fc2ea749122f0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:01:25 -0500 Subject: [PATCH 07/26] =?UTF-8?q?fix(cli):=20use=20=E2=86=91=E2=86=93=20ar?= =?UTF-8?q?rows=20for=20token=20usage=20in=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/tui/components/bottom-bar.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx index 9729a73..1fa3c63 100644 --- a/packages/cli/src/tui/components/bottom-bar.tsx +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -59,9 +59,9 @@ export function BottomBar({ {hasTokens ? ( <> - ^{formatTokens(tokenUsage.input)} + ↑{formatTokens(tokenUsage.input)} {' '} - v{formatTokens(tokenUsage.output)} + ↓{formatTokens(tokenUsage.output)} {tokenUsage.cacheRead > 0 && ( <> {' '} @@ -90,7 +90,7 @@ export function BottomBar({ )} ) : ( - ^0 v0 $0.000 + ↑0 ↓0 $0.000 )} -- 2.49.1 From f0b31d9983f810bf48faf9bb7ad3e01726f1d450 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:05:33 -0500 Subject: [PATCH 08/26] feat(gateway,cli,types): wire token usage, model info, and thinking levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway: - Emit session:info on session creation with provider, model, thinking level - Include SessionUsagePayload in agent:end with token stats, cost, context usage - Handle set:thinking client event to cycle thinking levels - Respond with updated session:info after thinking level change Types (@mosaic/types): - Add SessionUsagePayload (tokens, cost, context) to AgentEndPayload - Add SessionInfoPayload (provider, model, thinking level, available levels) - Add SetThinkingPayload and set:thinking to ClientToServerEvents - Add session:info to ServerToClientEvents CLI TUI: - useSocket now tracks tokenUsage, modelName, providerName, thinkingLevel - Updates from both session:info and agent:end usage payload - Ctrl+T cycles thinking level via set:thinking socket event - Footer shows thinking level next to model (e.g. 'claude-opus-4-6 • medium') - Token stats populate with real ↑in ↓out Rcache Wcache $cost ctx% --- apps/gateway/src/chat/chat.gateway.ts | 79 ++++++++++++++++++- .../cli/src/tui/components/bottom-bar.tsx | 3 + packages/cli/src/tui/hooks/use-socket.ts | 74 +++++++++++++---- packages/types/src/chat/events.ts | 37 +++++++++ packages/types/src/chat/index.ts | 3 + 5 files changed, 180 insertions(+), 16 deletions(-) diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 0f802c3..01969eb 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -12,6 +12,7 @@ import { import { Server, Socket } from 'socket.io'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import type { Auth } from '@mosaic/auth'; +import type { SetThinkingPayload } from '@mosaic/types'; import { AgentService } from '../agent/agent.service.js'; import { AUTH } from '../auth/auth.tokens.js'; import { v4 as uuid } from 'uuid'; @@ -112,6 +113,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // Track channel connection this.agentService.addChannel(conversationId, `websocket:${client.id}`); + // Send session info so the client knows the model/provider + { + const agentSession = this.agentService.getSession(conversationId); + if (agentSession) { + const piSession = agentSession.piSession; + client.emit('session:info', { + conversationId, + provider: agentSession.provider, + modelId: agentSession.modelId, + thinkingLevel: piSession.thinkingLevel, + availableThinkingLevels: piSession.getAvailableThinkingLevels(), + }); + } + } + // Send acknowledgment client.emit('message:ack', { conversationId, messageId: uuid() }); @@ -130,6 +146,43 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + @SubscribeMessage('set:thinking') + handleSetThinking( + @ConnectedSocket() client: Socket, + @MessageBody() data: SetThinkingPayload, + ): void { + const session = this.agentService.getSession(data.conversationId); + if (!session) { + client.emit('error', { + conversationId: data.conversationId, + error: 'No active session for this conversation.', + }); + return; + } + + const validLevels = session.piSession.getAvailableThinkingLevels(); + if (!validLevels.includes(data.level as never)) { + client.emit('error', { + conversationId: data.conversationId, + error: `Invalid thinking level "${data.level}". Available: ${validLevels.join(', ')}`, + }); + return; + } + + session.piSession.setThinkingLevel(data.level as never); + this.logger.log( + `Thinking level set to "${data.level}" for conversation ${data.conversationId}`, + ); + + client.emit('session:info', { + conversationId: data.conversationId, + provider: session.provider, + modelId: session.modelId, + thinkingLevel: session.piSession.thinkingLevel, + availableThinkingLevels: session.piSession.getAvailableThinkingLevels(), + }); + } + private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { if (!client.connected) { this.logger.warn( @@ -143,9 +196,31 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.emit('agent:start', { conversationId }); break; - case 'agent_end': - client.emit('agent:end', { conversationId }); + case 'agent_end': { + // Gather usage stats from the Pi session + const agentSession = this.agentService.getSession(conversationId); + const piSession = agentSession?.piSession; + const stats = piSession?.getSessionStats(); + const contextUsage = piSession?.getContextUsage(); + + client.emit('agent:end', { + conversationId, + usage: stats + ? { + provider: agentSession?.provider ?? 'unknown', + modelId: agentSession?.modelId ?? 'unknown', + thinkingLevel: piSession?.thinkingLevel ?? 'off', + tokens: stats.tokens, + cost: stats.cost, + context: { + percent: contextUsage?.percent ?? null, + window: contextUsage?.contextWindow ?? 0, + }, + } + : undefined, + }); break; + } case 'message_update': { const assistantEvent = event.assistantMessageEvent; diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx index 1fa3c63..cccb50b 100644 --- a/packages/cli/src/tui/components/bottom-bar.tsx +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -10,6 +10,7 @@ export interface BottomBarProps { connecting: boolean; modelName: string | null; providerName: string | null; + thinkingLevel: string; } function formatTokens(n: number): string { @@ -34,6 +35,7 @@ export function BottomBar({ connecting, modelName, providerName, + thinkingLevel, }: BottomBarProps) { const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected'; const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red'; @@ -97,6 +99,7 @@ export function BottomBar({ {providerName ? `(${providerName}) ` : ''} {modelName ?? 'awaiting model'} + {thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''} diff --git a/packages/cli/src/tui/hooks/use-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts index 31cd210..933a255 100644 --- a/packages/cli/src/tui/hooks/use-socket.ts +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -4,10 +4,12 @@ import type { ServerToClientEvents, ClientToServerEvents, MessageAckPayload, + AgentEndPayload, AgentTextPayload, AgentThinkingPayload, ToolStartPayload, ToolEndPayload, + SessionInfoPayload, ErrorPayload, } from '@mosaic/types'; @@ -53,12 +55,26 @@ export interface UseSocketReturn { tokenUsage: TokenUsage; modelName: string | null; providerName: string | null; + thinkingLevel: string; + availableThinkingLevels: string[]; sendMessage: (content: string) => void; + setThinkingLevel: (level: string) => 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 } = opts; @@ -70,22 +86,16 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { 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, - cacheRead: 0, - cacheWrite: 0, - cost: 0, - contextPercent: 0, - contextWindow: 0, - }; - const modelName: string | null = null; - const providerName: string | null = null; + 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`, { @@ -121,6 +131,13 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { 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(''); @@ -153,7 +170,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { ); }); - socket.on('agent:end', () => { + socket.on('agent:end', (data: AgentEndPayload) => { setCurrentStreamText((prev) => { if (prev) { setMessages((msgs) => [ @@ -166,6 +183,23 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { 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) => { @@ -196,6 +230,15 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { [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, + }); + }, []); + return { connected, connecting, @@ -208,7 +251,10 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { tokenUsage, modelName, providerName, + thinkingLevel, + availableThinkingLevels, sendMessage, + setThinkingLevel, connectionError, }; } diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 1f61358..074c96e 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 { @@ -44,6 +64,21 @@ export interface ChatMessagePayload { content: 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 */ export interface ServerToClientEvents { 'message:ack': (payload: MessageAckPayload) => void; @@ -53,10 +88,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, -- 2.49.1 From fb40ed0af0de8fb2375271f0219aa48035feb8b4 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:28:28 -0500 Subject: [PATCH 09/26] feat(cli): branded top bar with mosaic windmill icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ASCII art mosaic windmill: 4 colored tiles (blue, purple, teal, amber) with pink center, matching the Mosaic Stack brand - 3-line info block (Claude Code style): Line 1: 'Mosaic Stack v0.0.0' Line 2: model (context) · thinking · agent name Line 3: ● host connection status - Remove bordered box in favor of open layout with icon --- packages/cli/src/tui/components/top-bar.tsx | 90 +++++++++++++++++++-- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index fff5133..c492e57 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -3,18 +3,94 @@ 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; } -export function TopBar({ gatewayUrl }: TopBarProps) { - // Strip protocol for compact display - const host = gatewayUrl.replace(/^https?:\/\//, ''); +/** 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 windmill icon — 4 colored tiles + pink center + * Colors from the Mosaic brand: + * TL: blue (#2f80ff) TR: purple (#8b5cf6) + * BL: amber (#f59e0b) BR: teal (#14b8a6) + * Center: pink (#ec4899) + */ +function MosaicIcon() { return ( - - - Mosaic Stack TUI + + + ██ + + ██ + + + + ██ + + + + ██ + + ██ - {host} + + ); +} + +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} + + ); } -- 2.49.1 From 6ddf3dfd997791ddb8bfd550fef35736cc0d72fa Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:34:54 -0500 Subject: [PATCH 10/26] =?UTF-8?q?fix(cli):=20tighten=20mosaic=20icon=20to?= =?UTF-8?q?=20compact=203=C3=973=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use half-block chars (▐█ █▌) for outer tiles to reduce width - Center pink tile fits snugly between the four corners - Matches the mosaic windmill proportions from the web UI --- packages/cli/src/tui/components/top-bar.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index c492e57..7278f73 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -24,7 +24,7 @@ function formatContextWindow(n: number): string { } /** - * Mosaic windmill icon — 4 colored tiles + pink center + * Mosaic windmill icon — 4 colored tiles + pink center in 3×3 grid * Colors from the Mosaic brand: * TL: blue (#2f80ff) TR: purple (#8b5cf6) * BL: amber (#f59e0b) BR: teal (#14b8a6) @@ -34,19 +34,16 @@ function MosaicIcon() { return ( - ██ - - ██ + ▐█ + █▌ ██ - - ██ - - ██ + ▐█ + █▌ ); -- 2.49.1 From c2d573c85f9aa60f716c19efc86c1263a92ffaa1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:37:24 -0500 Subject: [PATCH 11/26] =?UTF-8?q?feat(cli):=20full=203=C3=973=20mosaic=20i?= =?UTF-8?q?con=20with=209=20colored=20tiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand colors (blue, purple, teal, amber, pink) plus complementary fills (indigo, sky, rose, green) arranged in a gradient flow: blue indigo purple sky pink rose amber green teal --- packages/cli/src/tui/components/top-bar.tsx | 31 ++++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index 7278f73..f8a5091 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -24,26 +24,37 @@ function formatContextWindow(n: number): string { } /** - * Mosaic windmill icon — 4 colored tiles + pink center in 3×3 grid - * Colors from the Mosaic brand: - * TL: blue (#2f80ff) TR: purple (#8b5cf6) - * BL: amber (#f59e0b) BR: teal (#14b8a6) - * Center: pink (#ec4899) + * Mosaic 3×3 icon — full grid of colored tiles + * + * Brand colors: + * blue (#2f80ff), purple (#8b5cf6), teal (#14b8a6), + * amber (#f59e0b), pink (#ec4899) + * + * Complementary fills: + * indigo (#6366f1), sky (#38bdf8), rose (#f472b6), green (#22c55e) + * + * Layout: + * blue indigo purple + * sky pink rose + * amber green teal */ function MosaicIcon() { return ( - ▐█ - █▌ + ██ + ██ + ██ - + ██ ██ + ██ - ▐█ - █▌ + ██ + ██ + ██ ); -- 2.49.1 From 4085338918fcaf060ddb08d84d69f3bae56668c5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:39:18 -0500 Subject: [PATCH 12/26] =?UTF-8?q?feat(cli):=20mosaic=20icon=20windmill=20c?= =?UTF-8?q?ross=20=E2=80=94=20brand=20tiles=20with=20black=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blue ·· purple ·· pink ·· amber ·· teal --- packages/cli/src/tui/components/top-bar.tsx | 23 +++++++-------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index f8a5091..94fdc80 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -24,36 +24,29 @@ function formatContextWindow(n: number): string { } /** - * Mosaic 3×3 icon — full grid of colored tiles - * - * Brand colors: - * blue (#2f80ff), purple (#8b5cf6), teal (#14b8a6), - * amber (#f59e0b), pink (#ec4899) - * - * Complementary fills: - * indigo (#6366f1), sky (#38bdf8), rose (#f472b6), green (#22c55e) + * Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern) * * Layout: - * blue indigo purple - * sky pink rose - * amber green teal + * blue ·· purple + * ·· pink ·· + * amber ·· teal */ function MosaicIcon() { return ( ██ - ██ + ██ - ██ + ██ - ██ + ██ - ██ + ██ -- 2.49.1 From 78cbaf869ad8e22af857a50bfa264f2c7b680a77 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:40:33 -0500 Subject: [PATCH 13/26] fix(cli): add extra margin between mosaic icon and text --- packages/cli/src/tui/components/top-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index 94fdc80..31b6f3d 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -33,7 +33,7 @@ function formatContextWindow(n: number): string { */ function MosaicIcon() { return ( - + ██ -- 2.49.1 From c67ffe3e51a7ad617b123b5ff892b42c474075e1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:42:00 -0500 Subject: [PATCH 14/26] fix(cli): preserve two-space gaps in mosaic icon tiles Extract gap string to const to prevent prettier from collapsing the double-space literals between icon tiles. --- packages/cli/src/tui/components/top-bar.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index 31b6f3d..3fd6b56 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -31,22 +31,24 @@ function formatContextWindow(n: number): string { * ·· pink ·· * amber ·· teal */ +// Two-space gap between tiles (extracted to avoid prettier collapse) +const GAP = ' '; + function MosaicIcon() { return ( ██ - + {GAP} ██ - + {GAP} ██ - ██ - + {GAP} ██ -- 2.49.1 From 4e4058abfffb4a0555fd11aabcceaa0616fe8595 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:43:50 -0500 Subject: [PATCH 15/26] fix(cli): add bottom margin after header --- packages/cli/src/tui/components/top-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index 3fd6b56..549320a 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -75,7 +75,7 @@ export function TopBar({ const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : ''; return ( - + -- 2.49.1 From 2936cef88451b3bebbde2e0033b75d352ce6c30b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:45:44 -0500 Subject: [PATCH 16/26] fix(cli): prevent header text artifacts on re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add flexGrow={1} to the text column so it fills the full terminal width. This ensures Ink clears the entire line when content changes (e.g. 'awaiting model' → 'llama3.2 (8k)'), preventing leftover characters from previous renders. --- packages/cli/src/tui/components/top-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/tui/components/top-bar.tsx b/packages/cli/src/tui/components/top-bar.tsx index 549320a..1c28c39 100644 --- a/packages/cli/src/tui/components/top-bar.tsx +++ b/packages/cli/src/tui/components/top-bar.tsx @@ -77,7 +77,7 @@ export function TopBar({ return ( - + Mosaic Stack -- 2.49.1 From 9e6479d7a81ba7ba40421bb48c058ad4232c5a5f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:48:42 -0500 Subject: [PATCH 17/26] feat(cli): show session/conversation ID in footer Adds a third line to the footer, right-aligned beneath Gateway status: 'session: a1b2c3d4' (first 8 chars) or 'no session' when not connected. --- packages/cli/src/tui/components/bottom-bar.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx index cccb50b..f7eac28 100644 --- a/packages/cli/src/tui/components/bottom-bar.tsx +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -11,6 +11,7 @@ export interface BottomBarProps { modelName: string | null; providerName: string | null; thinkingLevel: string; + conversationId: string | undefined; } function formatTokens(n: number): string { @@ -36,6 +37,7 @@ export function BottomBar({ modelName, providerName, thinkingLevel, + conversationId, }: BottomBarProps) { const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected'; const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red'; @@ -56,7 +58,17 @@ export function BottomBar({ - {/* Line 2: token stats ····· (provider) model */} + {/* Line 2: token stats ····· session id */} + + + + + {conversationId ? `session: ${conversationId.slice(0, 8)}` : 'no session'} + + + + + {/* Line 3: token stats ····· (provider) model */} {hasTokens ? ( -- 2.49.1 From 3d2ad87ccd74fd0b0e214c5762d65f0dc1a6bc9a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:50:50 -0500 Subject: [PATCH 18/26] =?UTF-8?q?fix(cli):=20rearrange=20footer=20?= =?UTF-8?q?=E2=80=94=20gateway=20top-right,=20cwd=20on=20line=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. (blank) ··· Gateway: Connected 2. ~/path (branch) ··· Session: a1b2c3d4 3. ↑5k ↓72 $0.12 ··· (provider) model • thinking --- packages/cli/src/tui/components/bottom-bar.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx index f7eac28..e9151a4 100644 --- a/packages/cli/src/tui/components/bottom-bar.tsx +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -46,24 +46,24 @@ export function BottomBar({ return ( - {/* Line 1: path (branch) ····· Gateway: Status */} + {/* Line 1: blank ····· Gateway: Status */} - - {compactCwd(gitInfo.cwd)} - {gitInfo.branch && ({gitInfo.branch})} - + Gateway: {gatewayStatus} - {/* Line 2: token stats ····· session id */} + {/* Line 2: cwd (branch) ····· Session: id */} - + + {compactCwd(gitInfo.cwd)} + {gitInfo.branch && ({gitInfo.branch})} + - {conversationId ? `session: ${conversationId.slice(0, 8)}` : 'no session'} + {conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'} -- 2.49.1 From 9628fe5a82ba004cf5ec48ffe100086f8c6ae094 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:52:54 -0500 Subject: [PATCH 19/26] docs: update TUI task tracker with handoff notes for Wave 2 --- docs/TASKS-TUI_Improvements.md | 74 +++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/docs/TASKS-TUI_Improvements.md b/docs/TASKS-TUI_Improvements.md index 3fec7ae..3502e39 100644 --- a/docs/TASKS-TUI_Improvements.md +++ b/docs/TASKS-TUI_Improvements.md @@ -1,23 +1,26 @@ # Tasks: TUI Improvements **Branch:** `feat/p7-tui-improvements` +**Worktree:** `/home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements` **PRD:** [PRD-TUI_Improvements.md](./PRD-TUI_Improvements.md) --- -## Wave 1 — Status Bar & Polish +## Wave 1 — Status Bar & Polish ✅ -| ID | Task | Status | Notes | -| ------- | ------------------------------------------------------------------------------------------------- | ------- | ------- | -| TUI-001 | Component architecture — split `app.tsx` into `StatusBar`, `MessageList`, `InputBar`, `App` shell | ✅ done | 79ff308 | -| TUI-002 | Top status bar — connection indicator (●/○), gateway URL, agent model | ✅ done | 79ff308 | -| TUI-003 | Bottom status bar — cwd, git branch, token usage | ✅ done | 79ff308 | -| TUI-004 | Message formatting — timestamps, role colors, markdown-lite rendering, word wrap | ✅ done | 79ff308 | -| TUI-005 | Quiet disconnect — suppress error flood, single-line reconnecting indicator | ✅ done | 79ff308 | -| TUI-006 | Tool call display — inline spinner + tool name during execution, ✓/✗ on completion | ✅ done | 79ff308 | -| TUI-007 | Thinking/reasoning display — dimmed collapsible block for `agent:thinking` events | ✅ done | 79ff308 | +| ID | Task | Status | Notes | +| -------- | ----------------------------------------------------------------------------------------------------- | ------- | ------- | +| TUI-001 | Component architecture — split `app.tsx` into `TopBar`, `BottomBar`, `MessageList`, `InputBar`, hooks | ✅ done | 79ff308 | +| TUI-002 | Top status bar — branded mosaic icon, version, model, connection indicator | ✅ done | 6c2b01e | +| TUI-003 | Bottom status bar — cwd, git branch, token usage, session ID, gateway status | ✅ done | e8d7ab8 | +| TUI-004 | Message formatting — timestamps, role colors (❯ you / ◆ assistant), word wrap | ✅ done | 79ff308 | +| TUI-005 | Quiet disconnect — single indicator, auto-reconnect, no error flood | ✅ done | 79ff308 | +| TUI-006 | Tool call display — inline spinner + tool name during execution, ✓/✗ on completion | ✅ done | 79ff308 | +| TUI-007 | Thinking/reasoning display — dimmed 💭 block for `agent:thinking` events | ✅ done | 79ff308 | +| TUI-007b | Wire token usage, model info, thinking levels end-to-end (gateway → types → TUI) | ✅ done | a061a64 | +| TUI-007c | Ctrl+T to cycle thinking levels via `set:thinking` socket event | ✅ done | a061a64 | -## Wave 2 — Layout & Navigation +## Wave 2 — Layout & Navigation (next) | ID | Task | Status | Notes | | ------- | --------------------------------------------------------- | ----------- | ---------------- | @@ -34,3 +37,52 @@ | TUI-013 | Agent status monitoring | not-started | | | TUI-014 | Settings/config screen | not-started | | | TUI-015 | Multiple agent sessions | not-started | | + +--- + +## Handoff Notes + +### File Structure + +``` +packages/cli/src/tui/ +├── app.tsx ← Thin shell composing all components +├── components/ +│ ├── top-bar.tsx ← Mosaic icon + version + model + connection +│ ├── bottom-bar.tsx ← 3-line footer: gateway, cwd+session, tokens+model +│ ├── message-list.tsx ← Messages, tool calls, thinking, streaming +│ └── input-bar.tsx ← Bordered prompt with context-aware placeholder +└── hooks/ + ├── use-socket.ts ← Typed Socket.IO (all ServerToClient/ClientToServer events) + └── use-git-info.ts ← Reads cwd + git branch at startup +``` + +### Cross-Package Changes + +- **`packages/types/src/chat/events.ts`** — Added `SessionUsagePayload`, `SessionInfoPayload`, `SetThinkingPayload`, `session:info` event, `set:thinking` event +- **`apps/gateway/src/chat/chat.gateway.ts`** — Emits `session:info` on session creation, includes `usage` in `agent:end`, handles `set:thinking` + +### Key Design Decisions + +- Footer is 3 lines: (1) gateway status right-aligned, (2) cwd+branch left / session right, (3) tokens left / provider+model+thinking right +- Mosaic icon uses brand colors in windmill cross pattern with `GAP` const to prevent prettier collapsing spaces +- `flexGrow={1}` on header text column prevents re-render artifacts +- Token/model data comes from gateway via `agent:end` payload and `session:info` events +- Thinking level cycling via Ctrl+T sends `set:thinking` to gateway, which validates and responds with `session:info` + +### How to Run + +```bash +cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements +pnpm --filter @mosaic/cli exec tsx src/cli.ts tui +# or after build: +node packages/cli/dist/cli.js tui --gateway http://localhost:4000 +``` + +### Quality Gates + +```bash +pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint +pnpm --filter @mosaic/gateway typecheck && pnpm --filter @mosaic/gateway lint +pnpm --filter @mosaic/types typecheck +``` -- 2.49.1 From 836652b868e7e371b733159ef9bf7b5af6efcfab Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 14:59:40 -0500 Subject: [PATCH 20/26] feat(cli): add scrollable message history with PgUp/PgDn (TUI-010) - Create use-viewport hook tracking scroll offset, viewport size, auto-follow - Update MessageList to slice visible messages and show scroll indicator - Wire PgUp/PgDn keybindings in app.tsx --- .../cli/src/tui/components/message-list.tsx | 22 ++++- packages/cli/src/tui/hooks/use-viewport.ts | 80 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/tui/hooks/use-viewport.ts diff --git a/packages/cli/src/tui/components/message-list.tsx b/packages/cli/src/tui/components/message-list.tsx index 7bd4dd7..3f1ff12 100644 --- a/packages/cli/src/tui/components/message-list.tsx +++ b/packages/cli/src/tui/components/message-list.tsx @@ -9,6 +9,9 @@ export interface MessageListProps { currentStreamText: string; currentThinkingText: string; activeToolCalls: ToolCall[]; + scrollOffset?: number; + viewportSize?: number; + isScrolledUp?: boolean; } function formatTime(date: Date): string { @@ -68,17 +71,32 @@ export function MessageList({ currentStreamText, currentThinkingText, activeToolCalls, + scrollOffset, + viewportSize, + isScrolledUp, }: 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. )} - {messages.map((msg, i) => ( - + {visibleMessages.map((msg, i) => ( + ))} {/* Active thinking */} 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, + }; +} -- 2.49.1 From b900e0625b624723a3d1d99173933d7db2af8887 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 15:02:46 -0500 Subject: [PATCH 21/26] feat(cli): conversation sidebar with list/create/switch/delete (TUI-008) --- packages/cli/src/tui/components/input-bar.tsx | 20 ++- packages/cli/src/tui/components/sidebar.tsx | 143 ++++++++++++++++++ packages/cli/src/tui/hooks/use-app-mode.ts | 37 +++++ .../cli/src/tui/hooks/use-conversations.ts | 139 +++++++++++++++++ packages/cli/src/tui/hooks/use-socket.ts | 20 +++ 5 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/tui/components/sidebar.tsx create mode 100644 packages/cli/src/tui/hooks/use-app-mode.ts create mode 100644 packages/cli/src/tui/hooks/use-conversations.ts diff --git a/packages/cli/src/tui/components/input-bar.tsx b/packages/cli/src/tui/components/input-bar.tsx index 39f2e07..ea40a4e 100644 --- a/packages/cli/src/tui/components/input-bar.tsx +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -6,9 +6,15 @@ export interface InputBarProps { onSubmit: (value: string) => void; isStreaming: boolean; connected: boolean; + placeholder?: string; } -export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) { +export function InputBar({ + onSubmit, + isStreaming, + connected, + placeholder: placeholderOverride, +}: InputBarProps) { const [input, setInput] = useState(''); const handleSubmit = useCallback( @@ -20,11 +26,13 @@ export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) { [onSubmit, isStreaming, connected], ); - const placeholder = !connected - ? 'disconnected — waiting for gateway…' - : isStreaming - ? 'waiting for response…' - : 'message mosaic…'; + const placeholder = + placeholderOverride ?? + (!connected + ? 'disconnected — waiting for gateway…' + : isStreaming + ? 'waiting for response…' + : 'message mosaic…'); return ( 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/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-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts index 933a255..47cc60c 100644 --- a/packages/cli/src/tui/hooks/use-socket.ts +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -59,6 +59,8 @@ export interface UseSocketReturn { availableThinkingLevels: string[]; sendMessage: (content: string) => void; setThinkingLevel: (level: string) => void; + switchConversation: (id: string) => void; + clearMessages: () => void; connectionError: string | null; } @@ -239,6 +241,22 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { }); }, []); + const clearMessages = useCallback(() => { + setMessages([]); + setCurrentStreamText(''); + setCurrentThinkingText(''); + setActiveToolCalls([]); + setIsStreaming(false); + }, []); + + const switchConversation = useCallback( + (id: string) => { + clearMessages(); + setConversationId(id); + }, + [clearMessages], + ); + return { connected, connecting, @@ -255,6 +273,8 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { availableThinkingLevels, sendMessage, setThinkingLevel, + switchConversation, + clearMessages, connectionError, }; } -- 2.49.1 From d1ae3ae1a081e7113b66579e74292cccd2cf943c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 15:05:00 -0500 Subject: [PATCH 22/26] =?UTF-8?q?feat(cli):=20keybinding=20system=20?= =?UTF-8?q?=E2=80=94=20Ctrl+L=20sidebar,=20Ctrl+N=20new,=20Ctrl+K=20search?= =?UTF-8?q?=20(TUI-009)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/tui/components/bottom-bar.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx index e9151a4..53c80fe 100644 --- a/packages/cli/src/tui/components/bottom-bar.tsx +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -46,6 +46,11 @@ export function BottomBar({ return ( + {/* Line 0: keybinding hints */} + + ^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll + + {/* Line 1: blank ····· Gateway: Status */} -- 2.49.1 From 9e8f9f4896078ed8581e626f3a7e88dd89039f61 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 15:07:31 -0500 Subject: [PATCH 23/26] feat(cli): message search with highlighting (TUI-011) --- .../cli/src/tui/components/message-list.tsx | 61 +++++++++++---- .../cli/src/tui/components/search-bar.tsx | 60 +++++++++++++++ packages/cli/src/tui/hooks/use-search.ts | 76 +++++++++++++++++++ 3 files changed, 181 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/tui/components/search-bar.tsx create mode 100644 packages/cli/src/tui/hooks/use-search.ts diff --git a/packages/cli/src/tui/components/message-list.tsx b/packages/cli/src/tui/components/message-list.tsx index 3f1ff12..beb09fb 100644 --- a/packages/cli/src/tui/components/message-list.tsx +++ b/packages/cli/src/tui/components/message-list.tsx @@ -12,6 +12,8 @@ export interface MessageListProps { scrollOffset?: number; viewportSize?: number; isScrolledUp?: boolean; + highlightedMessageIndices?: Set; + currentHighlightIndex?: number; } function formatTime(date: Date): string { @@ -22,24 +24,42 @@ function formatTime(date: Date): string { }); } -function MessageBubble({ msg }: { msg: Message }) { +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 ( - - - - {prefix}{' '} - - - {isUser ? 'you' : 'assistant'} - - {formatTime(msg.timestamp)} - - - {msg.content} + + {borderIndicator} + + + + {prefix}{' '} + + + {isUser ? 'you' : 'assistant'} + + {formatTime(msg.timestamp)} + + + {msg.content} + ); @@ -74,6 +94,8 @@ export function MessageList({ scrollOffset, viewportSize, isScrolledUp, + highlightedMessageIndices, + currentHighlightIndex, }: MessageListProps) { const useSlicing = scrollOffset != null && viewportSize != null; const visibleMessages = useSlicing @@ -95,9 +117,16 @@ export function MessageList({ )} - {visibleMessages.map((msg, i) => ( - - ))} + {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 && ( 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/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, + }; +} -- 2.49.1 From 9789db5633210bb359530a79f231e7c72bf268e3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 15:09:02 -0500 Subject: [PATCH 24/26] docs: mark Wave 2 tasks complete, update file structure and design decisions --- docs/TASKS-TUI_Improvements.md | 43 +- .../2026-03-15-wave2-tui-layout-navigation.md | 1000 +++++++++++++++++ 2 files changed, 1030 insertions(+), 13 deletions(-) create mode 100644 docs/plans/2026-03-15-wave2-tui-layout-navigation.md diff --git a/docs/TASKS-TUI_Improvements.md b/docs/TASKS-TUI_Improvements.md index 3502e39..59540f8 100644 --- a/docs/TASKS-TUI_Improvements.md +++ b/docs/TASKS-TUI_Improvements.md @@ -20,14 +20,14 @@ | TUI-007b | Wire token usage, model info, thinking levels end-to-end (gateway → types → TUI) | ✅ done | a061a64 | | TUI-007c | Ctrl+T to cycle thinking levels via `set:thinking` socket event | ✅ done | a061a64 | -## Wave 2 — Layout & Navigation (next) +## 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 | +| ID | Task | Status | Notes | +| ------- | --------------------------------------------------------- | ------- | ------- | +| TUI-010 | Scrollable message history — viewport with PgUp/PgDn | ✅ done | 4d4ad38 | +| TUI-008 | Conversation sidebar — list, create, switch conversations | ✅ done | 9ef578c | +| TUI-009 | Keybinding system — Ctrl+L, Ctrl+N, Ctrl+K, Escape | ✅ done | 9f38f5a | +| TUI-011 | Message search — find in current conversation | ✅ done | 8627827 | ## Wave 3 — Advanced Features @@ -46,15 +46,21 @@ ``` packages/cli/src/tui/ -├── app.tsx ← Thin shell composing all components +├── app.tsx ← Shell composing all components + global keybindings ├── components/ │ ├── top-bar.tsx ← Mosaic icon + version + model + connection -│ ├── bottom-bar.tsx ← 3-line footer: gateway, cwd+session, tokens+model -│ ├── message-list.tsx ← Messages, tool calls, thinking, streaming -│ └── input-bar.tsx ← Bordered prompt with context-aware placeholder +│ ├── bottom-bar.tsx ← Keybinding hints + 3-line footer: gateway, cwd, tokens +│ ├── message-list.tsx ← Messages, tool calls, thinking, streaming, search highlights +│ ├── input-bar.tsx ← Bordered prompt with context-aware placeholder +│ ├── sidebar.tsx ← Conversation list with keyboard navigation +│ └── search-bar.tsx ← Message search input with match count + navigation └── hooks/ - ├── use-socket.ts ← Typed Socket.IO (all ServerToClient/ClientToServer events) - └── use-git-info.ts ← Reads cwd + git branch at startup + ├── use-socket.ts ← Typed Socket.IO + switchConversation/clearMessages + ├── use-git-info.ts ← Reads cwd + git branch at startup + ├── use-viewport.ts ← Scrollable viewport with auto-follow + PgUp/PgDn + ├── use-app-mode.ts ← Panel focus state machine (chat/sidebar/search) + ├── use-conversations.ts ← REST client for conversation CRUD + └── use-search.ts ← Message search with match cycling ``` ### Cross-Package Changes @@ -64,12 +70,23 @@ packages/cli/src/tui/ ### Key Design Decisions +#### Wave 1 + - Footer is 3 lines: (1) gateway status right-aligned, (2) cwd+branch left / session right, (3) tokens left / provider+model+thinking right - Mosaic icon uses brand colors in windmill cross pattern with `GAP` const to prevent prettier collapsing spaces - `flexGrow={1}` on header text column prevents re-render artifacts - Token/model data comes from gateway via `agent:end` payload and `session:info` events - Thinking level cycling via Ctrl+T sends `set:thinking` to gateway, which validates and responds with `session:info` +#### Wave 2 + +- `useViewport` calculates scroll offset from terminal rows; auto-follow snaps to bottom on new messages +- `useAppMode` state machine manages focus: only the active panel handles keyboard input via `useInput({ isActive })` +- Sidebar fetches conversations via REST (`GET /api/conversations`), not socket events +- `switchConversation` in `useSocket` clears all local state (messages, streaming, tool calls) +- Search uses `useMemo` for reactive match computation; viewport auto-scrolls to current match +- Keybinding hints shown in bottom bar: `^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll` + ### How to Run ```bash diff --git a/docs/plans/2026-03-15-wave2-tui-layout-navigation.md b/docs/plans/2026-03-15-wave2-tui-layout-navigation.md new file mode 100644 index 0000000..cb33414 --- /dev/null +++ b/docs/plans/2026-03-15-wave2-tui-layout-navigation.md @@ -0,0 +1,1000 @@ +# Wave 2 — TUI Layout & Navigation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add conversation sidebar, keybindings, scrollable message history, and search to the Mosaic TUI. + +**Architecture:** The TUI gains a sidebar panel for conversation management (list/create/switch) fetched via REST from the gateway. A `useConversations` hook manages REST calls. A `useScrollableViewport` hook wraps the message list with virtual viewport logic. An app-level focus/mode state machine (`useAppMode`) controls which panel receives input. All new socket events for conversation listing use the existing REST API (`GET /api/conversations`). + +**Tech Stack:** Ink 5, React 18, socket.io-client, fetch (for REST), @mosaic/types + +--- + +## Dependency Graph + +``` +TUI-010 (scrollable history) ← TUI-011 (search) +TUI-008 (sidebar) ← TUI-009 (keybindings) +``` + +TUI-008 and TUI-010 are independent — can be built in parallel. +TUI-009 depends on TUI-008. TUI-011 depends on TUI-010. + +--- + +## Task 1: TUI-010 — Scrollable Message History + +### 1A: Create `use-viewport` hook + +**Files:** + +- Create: `packages/cli/src/tui/hooks/use-viewport.ts` + +**Step 1: Write the hook** + +This hook tracks a scroll offset and viewport height for the message list. +Ink's `useStdout` gives us terminal rows. We calculate visible slice. + +```ts +import { useState, useCallback, useMemo } from 'react'; +import { useStdout } from 'ink'; + +export interface UseViewportOptions { + /** Total number of renderable lines (message count as proxy) */ + totalItems: number; + /** Lines reserved for chrome (top bar, input bar, bottom bar) */ + reservedLines?: number; +} + +export interface UseViewportReturn { + /** Index of first visible item (0-based) */ + scrollOffset: number; + /** Number of items that fit in viewport */ + viewportSize: number; + /** Whether user has scrolled up from bottom */ + isScrolledUp: boolean; + /** Scroll to bottom (auto-follow mode) */ + scrollToBottom: () => void; + /** Scroll by delta (negative = up, positive = down) */ + scrollBy: (delta: number) => void; + /** Scroll to a specific offset */ + scrollTo: (offset: number) => void; + /** Whether we can scroll up/down */ + canScrollUp: boolean; + canScrollDown: boolean; +} + +export function useViewport(opts: UseViewportOptions): UseViewportReturn { + const { totalItems, reservedLines = 10 } = opts; + const { stdout } = useStdout(); + const terminalRows = stdout?.rows ?? 24; + + // Viewport = terminal height minus chrome + const viewportSize = Math.max(1, terminalRows - reservedLines); + + const maxOffset = Math.max(0, totalItems - viewportSize); + + const [scrollOffset, setScrollOffset] = useState(0); + // Track if user explicitly scrolled up + const [autoFollow, setAutoFollow] = useState(true); + + // Effective offset: if auto-following, always show latest + const effectiveOffset = autoFollow ? maxOffset : Math.min(scrollOffset, maxOffset); + + const scrollBy = useCallback( + (delta: number) => { + setAutoFollow(false); + setScrollOffset((prev) => { + const next = Math.max(0, Math.min(prev + delta, maxOffset)); + // If scrolled to bottom, re-enable auto-follow + if (next >= maxOffset) { + setAutoFollow(true); + } + return next; + }); + }, + [maxOffset], + ); + + const scrollToBottom = useCallback(() => { + setAutoFollow(true); + setScrollOffset(maxOffset); + }, [maxOffset]); + + const scrollTo = useCallback( + (offset: number) => { + const clamped = Math.max(0, Math.min(offset, maxOffset)); + setAutoFollow(clamped >= maxOffset); + setScrollOffset(clamped); + }, + [maxOffset], + ); + + return { + scrollOffset: effectiveOffset, + viewportSize, + isScrolledUp: !autoFollow, + scrollToBottom, + scrollBy, + scrollTo, + canScrollUp: effectiveOffset > 0, + canScrollDown: effectiveOffset < maxOffset, + }; +} +``` + +**Step 2: Typecheck** + +Run: `pnpm --filter @mosaic/cli typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add packages/cli/src/tui/hooks/use-viewport.ts +git commit -m "feat(cli): add use-viewport hook for scrollable message history" +``` + +### 1B: Integrate viewport into MessageList and wire PgUp/PgDn + +**Files:** + +- Modify: `packages/cli/src/tui/components/message-list.tsx` +- Modify: `packages/cli/src/tui/app.tsx` + +**Step 1: Update MessageList to accept viewport props and slice messages** + +In `message-list.tsx`, add viewport props and render only the visible slice. Add a scroll indicator when scrolled up. + +```tsx +// Add to MessageListProps: +export interface MessageListProps { + messages: Message[]; + isStreaming: boolean; + currentStreamText: string; + currentThinkingText: string; + activeToolCalls: ToolCall[]; + // New viewport props + scrollOffset: number; + viewportSize: number; + isScrolledUp: boolean; +} +``` + +In the component body, slice messages: + +```tsx +const visibleMessages = messages.slice(scrollOffset, scrollOffset + viewportSize); +``` + +Replace `messages.map(...)` with `visibleMessages.map(...)`. Add a scroll-up indicator at the top: + +```tsx +{ + isScrolledUp && ( + + ↑ {scrollOffset} more messages ↑ + + ); +} +``` + +**Step 2: Wire viewport hook + keybindings in app.tsx** + +In `app.tsx`: + +1. Import and call `useViewport({ totalItems: socket.messages.length })` +2. Pass viewport props to `` +3. Add PgUp/PgDn/Home/End keybindings in the existing `useInput`: + - `key.pageUp` → `viewport.scrollBy(-viewport.viewportSize)` + - `key.pageDown` → `viewport.scrollBy(viewport.viewportSize)` + - Shift+Up → `viewport.scrollBy(-1)` (line scroll) + - Shift+Down → `viewport.scrollBy(1)` (line scroll) + +Note: Ink's `useInput` key object supports `pageUp`, `pageDown`. For Home/End, check `key.meta && ch === '<'` / `key.meta && ch === '>'` as Ink doesn't have built-in home/end. + +**Step 3: Typecheck and lint** + +Run: `pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint` +Expected: PASS + +**Step 4: Commit** + +```bash +git add packages/cli/src/tui/components/message-list.tsx packages/cli/src/tui/app.tsx +git commit -m "feat(cli): scrollable message history with PgUp/PgDn viewport" +``` + +--- + +## Task 2: TUI-008 — Conversation Sidebar + +### 2A: Create `use-conversations` hook (REST client) + +**Files:** + +- Create: `packages/cli/src/tui/hooks/use-conversations.ts` + +**Step 1: Write the hook** + +This hook fetches conversations from the gateway REST API and provides create/switch actions. + +```ts +import { useState, useEffect, useCallback, useRef } from 'react'; + +export interface ConversationSummary { + id: string; + title: string | null; + archived: boolean; + createdAt: string; + updatedAt: string; +} + +export interface UseConversationsOptions { + gatewayUrl: string; + sessionCookie?: string; + /** Currently active conversation ID from socket */ + activeConversationId: string | undefined; +} + +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(false); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + const headers: Record = { + 'Content-Type': 'application/json', + ...(sessionCookie ? { Cookie: sessionCookie } : {}), + }; + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`${gatewayUrl}/api/conversations`, { 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 : String(err)); + } + } finally { + if (mountedRef.current) setLoading(false); + } + }, [gatewayUrl, sessionCookie]); + + const createConversation = useCallback( + async (title?: string): Promise => { + try { + const res = await fetch(`${gatewayUrl}/api/conversations`, { + method: 'POST', + headers, + body: JSON.stringify({ title: title ?? 'New Conversation' }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const conv = (await res.json()) as ConversationSummary; + await refresh(); + return conv; + } catch { + return null; + } + }, + [gatewayUrl, sessionCookie, refresh], + ); + + const deleteConversation = useCallback( + async (id: string): Promise => { + try { + const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, { + method: 'DELETE', + headers, + }); + if (!res.ok) return false; + await refresh(); + return true; + } catch { + return false; + } + }, + [gatewayUrl, sessionCookie, refresh], + ); + + const renameConversation = useCallback( + async (id: string, title: string): Promise => { + try { + const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ title }), + }); + if (!res.ok) return false; + await refresh(); + return true; + } catch { + return false; + } + }, + [gatewayUrl, sessionCookie, refresh], + ); + + useEffect(() => { + mountedRef.current = true; + void refresh(); + return () => { + mountedRef.current = false; + }; + }, []); + + return { + conversations, + loading, + error, + refresh, + createConversation, + deleteConversation, + renameConversation, + }; +} +``` + +**Step 2: Typecheck** + +Run: `pnpm --filter @mosaic/cli typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add packages/cli/src/tui/hooks/use-conversations.ts +git commit -m "feat(cli): add use-conversations hook for REST conversation management" +``` + +### 2B: Create `use-app-mode` hook (focus/mode state machine) + +**Files:** + +- Create: `packages/cli/src/tui/hooks/use-app-mode.ts` + +**Step 1: Write the hook** + +This manages which panel has focus and the current UI mode. + +```ts +import { useState, useCallback } from 'react'; + +export type AppMode = 'chat' | 'sidebar' | 'search'; + +export interface UseAppModeReturn { + mode: AppMode; + setMode: (mode: AppMode) => void; + toggleSidebar: () => void; + /** Whether sidebar panel should be visible */ + sidebarOpen: boolean; +} + +export function useAppMode(): UseAppModeReturn { + const [mode, setModeState] = useState('chat'); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const setMode = useCallback((m: AppMode) => { + setModeState(m); + if (m === 'sidebar') setSidebarOpen(true); + }, []); + + const toggleSidebar = useCallback(() => { + setSidebarOpen((prev) => { + const next = !prev; + if (!next) { + // Closing sidebar → return to chat mode + setModeState('chat'); + } else { + setModeState('sidebar'); + } + return next; + }); + }, []); + + return { mode, setMode, toggleSidebar, sidebarOpen }; +} +``` + +**Step 2: Typecheck** + +Run: `pnpm --filter @mosaic/cli typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add packages/cli/src/tui/hooks/use-app-mode.ts +git commit -m "feat(cli): add use-app-mode hook for panel focus state machine" +``` + +### 2C: Create `Sidebar` component + +**Files:** + +- Create: `packages/cli/src/tui/components/sidebar.tsx` + +**Step 1: Write the component** + +The sidebar shows a scrollable list of conversations with the active one highlighted. It handles keyboard navigation when focused. + +```tsx +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 truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 1) + '…'; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 0) { + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); + } + if (diffDays < 7) return `${diffDays}d ago`; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export function Sidebar({ + conversations, + activeConversationId, + selectedIndex, + onSelectIndex, + onSwitchConversation, + onDeleteConversation, + loading, + focused, + width, +}: SidebarProps) { + useInput( + (ch, key) => { + if (!focused) return; + + if (key.upArrow) { + onSelectIndex(Math.max(0, selectedIndex - 1)); + } else if (key.downArrow) { + onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1)); + } else if (key.return) { + const conv = conversations[selectedIndex]; + if (conv) onSwitchConversation(conv.id); + } else if (ch === 'd' || ch === 'D') { + const conv = conversations[selectedIndex]; + if (conv && conv.id !== activeConversationId) { + onDeleteConversation(conv.id); + } + } + }, + { isActive: focused }, + ); + + const titleWidth = width - 4; // padding + borders + + return ( + + + + Conversations + + {loading && } + + + {conversations.length === 0 && ( + + No conversations + + )} + + {conversations.map((conv, i) => { + const isActive = conv.id === activeConversationId; + const isSelected = i === selectedIndex && focused; + const title = conv.title ?? `Untitled (${conv.id.slice(0, 6)})`; + const displayTitle = truncate(title, titleWidth); + + return ( + + + {isActive ? '● ' : ' '} + {displayTitle} + + + ); + })} + + {focused && ( + + ↑↓ navigate · ↵ switch · d delete + + )} + + ); +} +``` + +**Step 2: Typecheck** + +Run: `pnpm --filter @mosaic/cli typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add packages/cli/src/tui/components/sidebar.tsx +git commit -m "feat(cli): add conversation sidebar component" +``` + +### 2D: Wire sidebar + conversation switching into app.tsx + +**Files:** + +- Modify: `packages/cli/src/tui/app.tsx` +- Modify: `packages/cli/src/tui/hooks/use-socket.ts` + +**Step 1: Add `switchConversation` to useSocket** + +In `use-socket.ts`, add a method to switch conversations. When switching, clear local messages and set the new conversation ID. The socket will pick up the new conversation on next `message` emit. + +Add to `UseSocketReturn`: + +```ts +switchConversation: (id: string) => void; +clearMessages: () => void; +``` + +Implementation: + +```ts +const switchConversation = useCallback((id: string) => { + setConversationId(id); + setMessages([]); + setIsStreaming(false); + setCurrentStreamText(''); + setCurrentThinkingText(''); + setActiveToolCalls([]); +}, []); + +const clearMessages = useCallback(() => { + setMessages([]); +}, []); +``` + +**Step 2: Update app.tsx layout to include sidebar** + +1. Import `useAppMode`, `useConversations`, `Sidebar` +2. Add `useAppMode()` call +3. Add `useConversations({ gatewayUrl, sessionCookie, activeConversationId: socket.conversationId })` +4. Track `sidebarSelectedIndex` state +5. Wrap the main content area in a horizontal ``: + +```tsx + + {appMode.sidebarOpen && ( + { + socket.switchConversation(id); + appMode.setMode('chat'); + }} + onDeleteConversation={(id) => void convos.deleteConversation(id)} + loading={convos.loading} + focused={appMode.mode === 'sidebar'} + width={30} + /> + )} + + + + +``` + +6. InputBar should be disabled (or readonly placeholder) when mode is not 'chat' + +**Step 3: Typecheck and lint** + +Run: `pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint` +Expected: PASS + +**Step 4: Commit** + +```bash +git add packages/cli/src/tui/app.tsx packages/cli/src/tui/hooks/use-socket.ts +git commit -m "feat(cli): wire conversation sidebar with create/switch/delete" +``` + +--- + +## Task 3: TUI-009 — Keybinding System + +**Files:** + +- Modify: `packages/cli/src/tui/app.tsx` + +**Step 1: Add global keybindings in the existing useInput** + +Add these bindings to the `useInput` in `app.tsx`: + +| Binding | Action | +| ----------- | ----------------------------------------- | +| `Ctrl+L` | Toggle sidebar visibility | +| `Ctrl+N` | Create new conversation + switch to it | +| `Ctrl+K` | Toggle search mode (TUI-011) | +| `Escape` | Return to chat mode from any panel | +| `Ctrl+T` | Cycle thinking level (already exists) | +| `PgUp/PgDn` | Scroll viewport (already wired in Task 1) | + +```ts +useInput((ch, key) => { + if (key.ctrl && ch === 'c') { + exit(); + return; + } + + // Global keybindings (work in any mode) + if (key.ctrl && ch === 'l') { + appMode.toggleSidebar(); + if (!appMode.sidebarOpen) void convos.refresh(); + return; + } + + if (key.ctrl && ch === 'n') { + void convos.createConversation().then((conv) => { + if (conv) { + socket.switchConversation(conv.id); + appMode.setMode('chat'); + } + }); + return; + } + + if (key.ctrl && ch === 'k') { + appMode.setMode(appMode.mode === 'search' ? 'chat' : 'search'); + return; + } + + if (key.escape) { + if (appMode.mode !== 'chat') { + appMode.setMode('chat'); + return; + } + // In chat mode, Escape could scroll to bottom + viewport.scrollToBottom(); + return; + } + + // Ctrl+T: cycle thinking (existing) + 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); + } + return; + } + + // Viewport scrolling (only in chat mode) + if (appMode.mode === 'chat') { + if (key.pageUp) { + viewport.scrollBy(-viewport.viewportSize); + } else if (key.pageDown) { + viewport.scrollBy(viewport.viewportSize); + } + } +}); +``` + +**Step 2: Add keybinding hints to bottom bar** + +In `bottom-bar.tsx`, add a hints line above the status lines (or integrate into line 1): + +```tsx +^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll +``` + +**Step 3: Typecheck and lint** + +Run: `pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint` +Expected: PASS + +**Step 4: Commit** + +```bash +git add packages/cli/src/tui/app.tsx packages/cli/src/tui/components/bottom-bar.tsx +git commit -m "feat(cli): keybinding system — Ctrl+L sidebar, Ctrl+N new, Ctrl+K search, Escape" +``` + +--- + +## Task 4: TUI-011 — Message Search + +**Files:** + +- Create: `packages/cli/src/tui/components/search-bar.tsx` +- Create: `packages/cli/src/tui/hooks/use-search.ts` +- Modify: `packages/cli/src/tui/app.tsx` +- Modify: `packages/cli/src/tui/components/message-list.tsx` + +### 4A: Create `use-search` hook + +**Step 1: Write the hook** + +```ts +import { useState, useCallback, useMemo } from 'react'; +import type { Message } from './use-socket.js'; + +export interface SearchMatch { + messageIndex: number; + /** Character offset within message content */ + charOffset: number; +} + +export interface UseSearchReturn { + query: string; + setQuery: (q: string) => void; + matches: SearchMatch[]; + currentMatchIndex: number; + nextMatch: () => void; + prevMatch: () => void; + clear: () => void; + /** Total match count */ + totalMatches: number; +} + +export function useSearch(messages: Message[]): UseSearchReturn { + const [query, setQuery] = useState(''); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + + const matches = useMemo(() => { + if (!query || query.length < 2) return []; + const q = query.toLowerCase(); + const result: SearchMatch[] = []; + for (let i = 0; i < messages.length; i++) { + const content = messages[i]!.content.toLowerCase(); + let pos = 0; + while ((pos = content.indexOf(q, pos)) !== -1) { + result.push({ messageIndex: i, charOffset: pos }); + pos += q.length; + } + } + return result; + }, [query, messages]); + + 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: matches.length > 0 ? currentMatchIndex % matches.length : 0, + nextMatch, + prevMatch, + clear, + totalMatches: matches.length, + }; +} +``` + +**Step 2: Commit** + +```bash +git add packages/cli/src/tui/hooks/use-search.ts +git commit -m "feat(cli): add use-search hook for message search" +``` + +### 4B: Create SearchBar component + +**Step 1: Write the component** + +```tsx +import React from 'react'; +import { Box, Text } 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, + onClose, + focused, +}: SearchBarProps) { + return ( + + 🔍 + + + {query.length >= 2 ? ( + + {totalMatches > 0 ? `${currentMatch + 1}/${totalMatches}` : 'no matches'} + {' · ↑↓ navigate · Esc close'} + + ) : ( + type to search… + )} + + + ); +} +``` + +**Step 2: Commit** + +```bash +git add packages/cli/src/tui/components/search-bar.tsx +git commit -m "feat(cli): add search bar component" +``` + +### 4C: Wire search into app.tsx and message-list + +**Step 1: Integrate** + +In `app.tsx`: + +1. Import `useSearch` and `SearchBar` +2. Call `useSearch(socket.messages)` +3. When mode is 'search', render `` above `` +4. In search mode, Up/Down arrows call `search.nextMatch()`/`search.prevMatch()` and scroll the viewport to the matched message +5. Pass `searchHighlights` to `MessageList` — the set of message indices that match + +In `message-list.tsx`: + +1. Add optional `highlightedMessageIndices?: Set` and `currentHighlightIndex?: number` props +2. Highlighted messages get a yellow left border or background tint +3. The current match gets a brighter highlight + +**Step 2: Typecheck and lint** + +Run: `pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint` +Expected: PASS + +**Step 3: Commit** + +```bash +git add packages/cli/src/tui/app.tsx packages/cli/src/tui/components/message-list.tsx packages/cli/src/tui/components/search-bar.tsx +git commit -m "feat(cli): wire message search with highlight and viewport scroll" +``` + +--- + +## Task 5: Final Integration & Quality Gates + +**Files:** + +- Modify: `docs/TASKS-TUI_Improvements.md` (update status) + +**Step 1: Full typecheck across all affected packages** + +```bash +pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint +pnpm --filter @mosaic/types typecheck +``` + +Expected: All PASS + +**Step 2: Manual smoke test** + +```bash +cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements +docker compose up -d +pnpm --filter @mosaic/cli exec tsx src/cli.ts tui +``` + +Verify: + +- [ ] Messages scroll with PgUp/PgDn +- [ ] Ctrl+L opens/closes sidebar +- [ ] Sidebar shows conversations from REST API +- [ ] Arrow keys navigate sidebar when focused +- [ ] Enter switches conversation, clears messages +- [ ] Ctrl+N creates new conversation +- [ ] Ctrl+K opens search bar +- [ ] Typing in search highlights matches +- [ ] Up/Down in search mode cycles through matches +- [ ] Escape returns to chat from any mode +- [ ] Ctrl+T still cycles thinking levels +- [ ] Auto-scroll follows new messages at bottom + +**Step 3: Update task tracker** + +Mark TUI-008, TUI-009, TUI-010, TUI-011 as ✅ done in `docs/TASKS-TUI_Improvements.md` + +**Step 4: Commit and push** + +```bash +git add -A +git commit -m "docs: mark Wave 2 tasks complete" +git push +``` + +--- + +## File Summary + +| Action | Path | +| ------ | -------------------------------------------------- | +| Create | `packages/cli/src/tui/hooks/use-viewport.ts` | +| Create | `packages/cli/src/tui/hooks/use-conversations.ts` | +| Create | `packages/cli/src/tui/hooks/use-app-mode.ts` | +| Create | `packages/cli/src/tui/hooks/use-search.ts` | +| Create | `packages/cli/src/tui/components/sidebar.tsx` | +| Create | `packages/cli/src/tui/components/search-bar.tsx` | +| Modify | `packages/cli/src/tui/app.tsx` | +| Modify | `packages/cli/src/tui/hooks/use-socket.ts` | +| Modify | `packages/cli/src/tui/components/message-list.tsx` | +| Modify | `packages/cli/src/tui/components/bottom-bar.tsx` | +| Modify | `docs/TASKS-TUI_Improvements.md` | -- 2.49.1 From 4259829d657997b4554b2b20146ff5b0d2b102fe Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 16:41:23 -0500 Subject: [PATCH 25/26] fix(cli): restore componentized app.tsx after rebase, accept initialModel/initialProvider props --- packages/cli/src/tui/app.tsx | 552 +++++++++++++---------------------- 1 file changed, 201 insertions(+), 351 deletions(-) diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 4174322..832a187 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -1,16 +1,19 @@ -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 { fetchAvailableModels, type ModelInfo } from './gateway-api.js'; +import React, { useState, useCallback, useEffect, useMemo } 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 { Sidebar } from './components/sidebar.js'; +import { SearchBar } from './components/search-bar.js'; +import { useSocket } from './hooks/use-socket.js'; +import { useGitInfo } from './hooks/use-git-info.js'; +import { useViewport } from './hooks/use-viewport.js'; +import { useAppMode } from './hooks/use-app-mode.js'; +import { useConversations } from './hooks/use-conversations.js'; +import { useSearch } from './hooks/use-search.js'; -interface Message { - role: 'user' | 'assistant' | 'system'; - content: string; -} - -interface TuiAppProps { +export interface TuiAppProps { gatewayUrl: string; conversationId?: string; sessionCookie?: string; @@ -18,375 +21,222 @@ interface TuiAppProps { initialProvider?: string; } -/** - * Parse a slash command from user input. - * Returns null if the input is not a slash command. - */ -function parseSlashCommand(value: string): { command: string; args: string[] } | null { - const trimmed = value.trim(); - if (!trimmed.startsWith('/')) return null; - const parts = trimmed.slice(1).split(/\s+/); - const command = parts[0]?.toLowerCase() ?? ''; - const args = parts.slice(1); - return { command, args }; -} - export function TuiApp({ gatewayUrl, - conversationId: initialConversationId, + conversationId, sessionCookie, - initialModel, - initialProvider, + initialModel: _initialModel, + initialProvider: _initialProvider, }: 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 gitInfo = useGitInfo(); + const appMode = useAppMode(); - // Model/provider state - const [currentModel, setCurrentModel] = useState(initialModel); - const [currentProvider, setCurrentProvider] = useState(initialProvider); - const [availableModels, setAvailableModels] = useState([]); + const socket = useSocket({ + gatewayUrl, + sessionCookie, + initialConversationId: conversationId, + }); - const socketRef = useRef(null); - const currentStreamTextRef = useRef(''); + const conversations = useConversations({ gatewayUrl, sessionCookie }); - // Fetch available models on mount + const viewport = useViewport({ totalItems: socket.messages.length }); + + const search = useSearch(socket.messages); + + // Scroll to current match when it changes + const currentMatch = search.matches[search.currentMatchIndex]; useEffect(() => { - fetchAvailableModels(gatewayUrl, sessionCookie) - .then((models) => { - setAvailableModels(models); - // If no model/provider specified and models are available, show the default - if (!initialModel && !initialProvider && models.length > 0) { - const first = models[0]; - if (first) { - setCurrentModel(first.id); - setCurrentProvider(first.provider); - } - } - }) - .catch(() => { - // Non-fatal: TUI works without model list - }); - }, [gatewayUrl, sessionCookie, initialModel, initialProvider]); + if (currentMatch && appMode.mode === 'search') { + viewport.scrollTo(currentMatch.messageIndex); + } + }, [currentMatch, appMode.mode, viewport]); - useEffect(() => { - const socket = io(`${gatewayUrl}/chat`, { - transports: ['websocket'], - extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined, - }); + // Compute highlighted message indices for MessageList + const highlightedMessageIndices = useMemo(() => { + if (search.matches.length === 0) return undefined; + return new Set(search.matches.map((m) => m.messageIndex)); + }, [search.matches]); - socketRef.current = socket; + const currentHighlightIndex = currentMatch?.messageIndex; - 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}.`, - }, - ]); - }); + const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0); - socket.on('message:ack', (data: { conversationId: string }) => { - setConversationId(data.conversationId); - }); - - socket.on('agent:start', () => { - setIsStreaming(true); - currentStreamTextRef.current = ''; - setCurrentStreamText(''); - }); - - socket.on('agent:text', (data: { text: string }) => { - currentStreamTextRef.current += data.text; - setCurrentStreamText(currentStreamTextRef.current); - }); - - socket.on('agent:end', () => { - const finalText = currentStreamTextRef.current; - currentStreamTextRef.current = ''; - setCurrentStreamText(''); - if (finalText) { - setMessages((msgs) => [...msgs, { role: 'assistant', content: finalText }]); - } - setIsStreaming(false); - }); - - socket.on('error', (data: { error: string }) => { - setMessages((msgs) => [...msgs, { role: 'assistant', content: `Error: ${data.error}` }]); - setIsStreaming(false); - }); - - return () => { - socket.disconnect(); - }; - }, [gatewayUrl]); - - /** - * Handle /model and /provider slash commands. - * Returns true if the input was a handled slash command (should not be sent to gateway). - */ - const handleSlashCommand = useCallback( - (value: string): boolean => { - const parsed = parseSlashCommand(value); - if (!parsed) return false; - - const { command, args } = parsed; - - if (command === 'model') { - if (args.length === 0) { - // List available models - if (availableModels.length === 0) { - setMessages((msgs) => [ - ...msgs, - { - role: 'system', - content: - 'No models available (could not reach gateway). Use /model to set one manually.', - }, - ]); - } else { - const lines = availableModels.map( - (m) => - ` ${m.provider}/${m.id}${m.id === currentModel && m.provider === currentProvider ? ' (active)' : ''}`, - ); - setMessages((msgs) => [ - ...msgs, - { - role: 'system', - content: `Available models:\n${lines.join('\n')}`, - }, - ]); - } - } else { - // Switch model: /model or /model / - const arg = args[0]!; - const slashIdx = arg.indexOf('/'); - let newProvider: string | undefined; - let newModelId: string; - - if (slashIdx !== -1) { - newProvider = arg.slice(0, slashIdx); - newModelId = arg.slice(slashIdx + 1); - } else { - newModelId = arg; - // Try to find provider from available models list - const match = availableModels.find((m) => m.id === newModelId); - newProvider = match?.provider ?? currentProvider; - } - - setCurrentModel(newModelId); - if (newProvider) setCurrentProvider(newProvider); - - setMessages((msgs) => [ - ...msgs, - { - role: 'system', - content: `Switched to model: ${newProvider ? `${newProvider}/` : ''}${newModelId}. Takes effect on next message.`, - }, - ]); - } - return true; - } - - if (command === 'provider') { - if (args.length === 0) { - // List providers from available models - const providers = [...new Set(availableModels.map((m) => m.provider))]; - if (providers.length === 0) { - setMessages((msgs) => [ - ...msgs, - { - role: 'system', - content: - 'No providers available (could not reach gateway). Use /provider to set one manually.', - }, - ]); - } else { - const lines = providers.map((p) => ` ${p}${p === currentProvider ? ' (active)' : ''}`); - setMessages((msgs) => [ - ...msgs, - { - role: 'system', - content: `Available providers:\n${lines.join('\n')}`, - }, - ]); - } - } else { - const newProvider = args[0]!; - setCurrentProvider(newProvider); - // If switching provider, auto-select first model for that provider - const providerModels = availableModels.filter((m) => m.provider === newProvider); - if (providerModels.length > 0 && providerModels[0]) { - setCurrentModel(providerModels[0].id); - setMessages((msgs) => [ - ...msgs, - { - role: 'system', - content: `Switched to provider: ${newProvider} (model: ${providerModels[0]!.id}). Takes effect on next message.`, - }, - ]); - } else { - setMessages((msgs) => [ - ...msgs, - { - role: 'system', - content: `Switched to provider: ${newProvider}. Takes effect on next message.`, - }, - ]); - } - } - return true; - } - - if (command === 'help') { - setMessages((msgs) => [ - ...msgs, - { - role: 'system', - content: [ - 'Available commands:', - ' /model — list available models', - ' /model — 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(); + } }); }, - [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'); + } + }); + } + // 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... - - )} - - - - - {'> '} - - - + ); } -- 2.49.1 From 28860c9f42c943f9f01330fb04d18110c5c46f91 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 17:16:42 -0500 Subject: [PATCH 26/26] fix(cli): wire initialModel/initialProvider through useSocket, add error handling - Pass initialModel/initialProvider from CLI flags into useSocket hook - Include provider/modelId in socket message emit (restores PR #144 functionality) - Add provider/modelId optional fields to ChatMessagePayload type - Add .catch() to floating promises in createConversation/deleteConversation Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/tui/app.tsx | 34 +++++++++++++++--------- packages/cli/src/tui/hooks/use-socket.ts | 6 ++++- packages/types/src/chat/events.ts | 2 ++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 832a187..9effec4 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -25,8 +25,8 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie, - initialModel: _initialModel, - initialProvider: _initialProvider, + initialModel, + initialProvider, }: TuiAppProps) { const { exit } = useApp(); const gitInfo = useGitInfo(); @@ -36,6 +36,8 @@ export function TuiApp({ gatewayUrl, sessionCookie, initialConversationId: conversationId, + initialModel, + initialProvider, }); const conversations = useConversations({ gatewayUrl, sessionCookie }); @@ -72,11 +74,14 @@ export function TuiApp({ const handleDeleteConversation = useCallback( (id: string) => { - void conversations.deleteConversation(id).then((ok) => { - if (ok && id === socket.conversationId) { - socket.clearMessages(); - } - }); + void conversations + .deleteConversation(id) + .then((ok) => { + if (ok && id === socket.conversationId) { + socket.clearMessages(); + } + }) + .catch(() => {}); }, [conversations, socket], ); @@ -95,12 +100,15 @@ export function TuiApp({ } // 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'); - } - }); + 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') { diff --git a/packages/cli/src/tui/hooks/use-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts index 47cc60c..7ce9d55 100644 --- a/packages/cli/src/tui/hooks/use-socket.ts +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -41,6 +41,8 @@ export interface UseSocketOptions { gatewayUrl: string; sessionCookie?: string; initialConversationId?: string; + initialModel?: string; + initialProvider?: string; } export interface UseSocketReturn { @@ -78,7 +80,7 @@ const EMPTY_USAGE: TokenUsage = { }; export function useSocket(opts: UseSocketOptions): UseSocketReturn { - const { gatewayUrl, sessionCookie, initialConversationId } = opts; + const { gatewayUrl, sessionCookie, initialConversationId, initialModel, initialProvider } = opts; const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(true); @@ -227,6 +229,8 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { socketRef.current.emit('message', { conversationId, content, + ...(initialProvider ? { provider: initialProvider } : {}), + ...(initialModel ? { modelId: initialModel } : {}), }); }, [conversationId, isStreaming], diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 074c96e..0cf999d 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -62,6 +62,8 @@ export interface ErrorPayload { export interface ChatMessagePayload { conversationId?: string; content: string; + provider?: string; + modelId?: string; } /** Session info pushed when session is created or model changes */ -- 2.49.1