From 9789db5633210bb359530a79f231e7c72bf268e3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 15:09:02 -0500 Subject: [PATCH] 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` |