All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
1001 lines
27 KiB
Markdown
1001 lines
27 KiB
Markdown
# 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` |
|