From 8627827c4b7bb72f62c9d7976d200140b8799a18 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 15:07:31 -0500 Subject: [PATCH] feat(cli): message search with highlighting (TUI-011) --- packages/cli/src/tui/app.tsx | 54 ++++++++++++- .../cli/src/tui/components/message-list.tsx | 61 +++++++++++---- .../cli/src/tui/components/search-bar.tsx | 60 +++++++++++++++ packages/cli/src/tui/hooks/use-search.ts | 76 +++++++++++++++++++ 4 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/tui/components/search-bar.tsx create mode 100644 packages/cli/src/tui/hooks/use-search.ts diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 850d7ed..1f4ac88 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -1,15 +1,17 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { Box, useApp, useInput } from 'ink'; import { TopBar } from './components/top-bar.js'; import { BottomBar } from './components/bottom-bar.js'; import { MessageList } from './components/message-list.js'; import { InputBar } from './components/input-bar.js'; import { Sidebar } from './components/sidebar.js'; +import { SearchBar } from './components/search-bar.js'; import { useSocket } from './hooks/use-socket.js'; import { useGitInfo } from './hooks/use-git-info.js'; import { useViewport } from './hooks/use-viewport.js'; import { useAppMode } from './hooks/use-app-mode.js'; import { useConversations } from './hooks/use-conversations.js'; +import { useSearch } from './hooks/use-search.js'; export interface TuiAppProps { gatewayUrl: string; @@ -32,6 +34,24 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp const viewport = useViewport({ totalItems: socket.messages.length }); + const search = useSearch(socket.messages); + + // Scroll to current match when it changes + const currentMatch = search.matches[search.currentMatchIndex]; + useEffect(() => { + if (currentMatch && appMode.mode === 'search') { + viewport.scrollTo(currentMatch.messageIndex); + } + }, [currentMatch, appMode.mode, viewport]); + + // Compute highlighted message indices for MessageList + const highlightedMessageIndices = useMemo(() => { + if (search.matches.length === 0) return undefined; + return new Set(search.matches.map((m) => m.messageIndex)); + }, [search.matches]); + + const currentHighlightIndex = currentMatch?.messageIndex; + const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0); const handleSwitchConversation = useCallback( @@ -76,7 +96,12 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp } // Ctrl+K: toggle search mode if (key.ctrl && ch === 'k') { - appMode.setMode(appMode.mode === 'search' ? 'chat' : 'search'); + if (appMode.mode === 'search') { + search.clear(); + appMode.setMode('chat'); + } else { + appMode.setMode('search'); + } } // Page Up / Page Down: scroll message history (only in chat mode) if (appMode.mode === 'chat') { @@ -101,7 +126,10 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp } // Escape: return to chat from sidebar/search; in chat, scroll to bottom if (key.escape) { - if (appMode.mode === 'sidebar' || appMode.mode === 'search') { + if (appMode.mode === 'search') { + search.clear(); + appMode.setMode('chat'); + } else if (appMode.mode === 'sidebar') { appMode.setMode('chat'); } else if (appMode.mode === 'chat') { viewport.scrollToBottom(); @@ -116,6 +144,8 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp ? 'search mode… press Esc to return' : undefined; + const isSearchMode = appMode.mode === 'search'; + const messageArea = ( + {isSearchMode && ( + { + search.clear(); + appMode.setMode('chat'); + }} + focused={isSearchMode} + /> + )} + ; + currentHighlightIndex?: number; } function formatTime(date: Date): string { @@ -22,24 +24,42 @@ function formatTime(date: Date): string { }); } -function MessageBubble({ msg }: { msg: Message }) { +function MessageBubble({ + msg, + highlight, +}: { + msg: Message; + highlight?: 'match' | 'current' | undefined; +}) { const isUser = msg.role === 'user'; const prefix = isUser ? '❯' : '◆'; const color = isUser ? 'green' : 'cyan'; + const borderIndicator = + highlight === 'current' ? ( + + ▌{' '} + + ) : highlight === 'match' ? ( + + ) : null; + return ( - - - - {prefix}{' '} - - - {isUser ? 'you' : 'assistant'} - - {formatTime(msg.timestamp)} - - - {msg.content} + + {borderIndicator} + + + + {prefix}{' '} + + + {isUser ? 'you' : 'assistant'} + + {formatTime(msg.timestamp)} + + + {msg.content} + ); @@ -74,6 +94,8 @@ export function MessageList({ scrollOffset, viewportSize, isScrolledUp, + highlightedMessageIndices, + currentHighlightIndex, }: MessageListProps) { const useSlicing = scrollOffset != null && viewportSize != null; const visibleMessages = useSlicing @@ -95,9 +117,16 @@ export function MessageList({ )} - {visibleMessages.map((msg, i) => ( - - ))} + {visibleMessages.map((msg, i) => { + const globalIndex = hiddenAbove + i; + const highlight = + globalIndex === currentHighlightIndex + ? ('current' as const) + : highlightedMessageIndices?.has(globalIndex) + ? ('match' as const) + : undefined; + return ; + })} {/* Active thinking */} {isStreaming && currentThinkingText && ( diff --git a/packages/cli/src/tui/components/search-bar.tsx b/packages/cli/src/tui/components/search-bar.tsx new file mode 100644 index 0000000..5d42fef --- /dev/null +++ b/packages/cli/src/tui/components/search-bar.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Box, Text, useInput } 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, + onNext, + onPrev, + onClose, + focused, +}: SearchBarProps) { + useInput( + (_input, key) => { + if (key.upArrow) { + onPrev(); + } + if (key.downArrow) { + onNext(); + } + if (key.escape) { + onClose(); + } + }, + { isActive: focused }, + ); + + const borderColor = focused ? 'yellow' : 'gray'; + + const matchDisplay = + query.length >= 2 + ? totalMatches > 0 + ? `${String(currentMatch + 1)}/${String(totalMatches)}` + : 'no matches' + : ''; + + return ( + + 🔍 + + + + {matchDisplay && {matchDisplay}} + ↑↓ navigate · Esc close + + ); +} diff --git a/packages/cli/src/tui/hooks/use-search.ts b/packages/cli/src/tui/hooks/use-search.ts new file mode 100644 index 0000000..5ede4fe --- /dev/null +++ b/packages/cli/src/tui/hooks/use-search.ts @@ -0,0 +1,76 @@ +import { useState, useMemo, useCallback } from 'react'; +import type { Message } from './use-socket.js'; + +export interface SearchMatch { + messageIndex: number; + charOffset: number; +} + +export interface UseSearchReturn { + query: string; + setQuery: (q: string) => void; + matches: SearchMatch[]; + currentMatchIndex: number; + nextMatch: () => void; + prevMatch: () => void; + clear: () => void; + totalMatches: number; +} + +export function useSearch(messages: Message[]): UseSearchReturn { + const [query, setQuery] = useState(''); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + + const matches = useMemo(() => { + if (query.length < 2) return []; + + const lowerQuery = query.toLowerCase(); + const result: SearchMatch[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (!msg) continue; + const content = msg.content.toLowerCase(); + let offset = 0; + while (true) { + const idx = content.indexOf(lowerQuery, offset); + if (idx === -1) break; + result.push({ messageIndex: i, charOffset: idx }); + offset = idx + 1; + } + } + + return result; + }, [query, messages]); + + // Reset match index when matches change + useMemo(() => { + setCurrentMatchIndex(0); + }, [matches]); + + 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, + nextMatch, + prevMatch, + clear, + totalMatches: matches.length, + }; +}