# 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 && (
    <Box justifyContent="center">
      <Text dimColor>↑ {scrollOffset} more messages ↑</Text>
    </Box>
  );
}
```

**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 `<MessageList>`
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<void>;
  createConversation: (title?: string) => Promise<ConversationSummary | null>;
  deleteConversation: (id: string) => Promise<boolean>;
  renameConversation: (id: string, title: string) => Promise<boolean>;
}

export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
  const { gatewayUrl, sessionCookie } = opts;
  const [conversations, setConversations] = useState<ConversationSummary[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const mountedRef = useRef(true);

  const headers: Record<string, string> = {
    '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<ConversationSummary | null> => {
      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<boolean> => {
      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<boolean> => {
      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<AppMode>('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 (
    <Box
      flexDirection="column"
      width={width}
      borderStyle="single"
      borderColor={focused ? 'cyan' : 'gray'}
    >
      <Box paddingX={1}>
        <Text bold color={focused ? 'cyan' : undefined}>
          Conversations
        </Text>
        {loading && <Text dimColor> …</Text>}
      </Box>

      {conversations.length === 0 && (
        <Box paddingX={1}>
          <Text dimColor>No conversations</Text>
        </Box>
      )}

      {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 (
          <Box key={conv.id} paddingX={1}>
            <Text
              bold={isActive}
              color={isSelected ? 'cyan' : isActive ? 'green' : undefined}
              inverse={isSelected}
            >
              {isActive ? '● ' : '  '}
              {displayTitle}
            </Text>
          </Box>
        );
      })}

      {focused && (
        <Box paddingX={1} marginTop={1}>
          <Text dimColor>↑↓ navigate · ↵ switch · d delete</Text>
        </Box>
      )}
    </Box>
  );
}
```

**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 `<Box>`:

```tsx
<Box flexDirection="row" flexGrow={1}>
  {appMode.sidebarOpen && (
    <Sidebar
      conversations={convos.conversations}
      activeConversationId={socket.conversationId}
      selectedIndex={sidebarSelectedIndex}
      onSelectIndex={setSidebarSelectedIndex}
      onSwitchConversation={(id) => {
        socket.switchConversation(id);
        appMode.setMode('chat');
      }}
      onDeleteConversation={(id) => void convos.deleteConversation(id)}
      loading={convos.loading}
      focused={appMode.mode === 'sidebar'}
      width={30}
    />
  )}
  <Box flexDirection="column" flexGrow={1}>
    <MessageList ... />
  </Box>
</Box>
```

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
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
```

**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 (
    <Box paddingX={1} borderStyle="single" borderColor={focused ? 'yellow' : 'gray'}>
      <Text color="yellow">🔍 </Text>
      <TextInput
        value={query}
        onChange={onQueryChange}
        placeholder="search messages…"
        focus={focused}
      />
      <Box marginLeft={1}>
        {query.length >= 2 ? (
          <Text dimColor>
            {totalMatches > 0 ? `${currentMatch + 1}/${totalMatches}` : 'no matches'}
            {' · ↑↓ navigate · Esc close'}
          </Text>
        ) : (
          <Text dimColor>type to search…</Text>
        )}
      </Box>
    </Box>
  );
}
```

**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 `<SearchBar>` above `<InputBar>`
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<number>` 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`                   |
