# 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), @mosaicstack/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 @mosaicstack/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 @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/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 @mosaicstack/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 @mosaicstack/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 @mosaicstack/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 @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/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 @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/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 @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/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 @mosaicstack/cli typecheck && pnpm --filter @mosaicstack/cli lint pnpm --filter @mosaicstack/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 @mosaicstack/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` |