From 9e8f9f4896078ed8581e626f3a7e88dd89039f61 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) --- .../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 +++++++++++++++++++ 3 files changed, 181 insertions(+), 16 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/components/message-list.tsx b/packages/cli/src/tui/components/message-list.tsx index 3f1ff12..beb09fb 100644 --- a/packages/cli/src/tui/components/message-list.tsx +++ b/packages/cli/src/tui/components/message-list.tsx @@ -12,6 +12,8 @@ export interface MessageListProps { scrollOffset?: number; viewportSize?: number; isScrolledUp?: boolean; + highlightedMessageIndices?: Set; + 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, + }; +}