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` |