feat(cli): message search with highlighting (TUI-011)

This commit is contained in:
2026-03-15 15:07:31 -05:00
parent d1ae3ae1a0
commit 9e8f9f4896
3 changed files with 181 additions and 16 deletions

View File

@@ -12,6 +12,8 @@ export interface MessageListProps {
scrollOffset?: number; scrollOffset?: number;
viewportSize?: number; viewportSize?: number;
isScrolledUp?: boolean; isScrolledUp?: boolean;
highlightedMessageIndices?: Set<number>;
currentHighlightIndex?: number;
} }
function formatTime(date: Date): string { function formatTime(date: Date): string {
@@ -22,13 +24,30 @@ 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 isUser = msg.role === 'user';
const prefix = isUser ? '' : '◆'; const prefix = isUser ? '' : '◆';
const color = isUser ? 'green' : 'cyan'; const color = isUser ? 'green' : 'cyan';
const borderIndicator =
highlight === 'current' ? (
<Text color="yellowBright" bold>
{' '}
</Text>
) : highlight === 'match' ? (
<Text color="yellow"> </Text>
) : null;
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="row" marginBottom={1}>
{borderIndicator}
<Box flexDirection="column">
<Box> <Box>
<Text bold color={color}> <Text bold color={color}>
{prefix}{' '} {prefix}{' '}
@@ -42,6 +61,7 @@ function MessageBubble({ msg }: { msg: Message }) {
<Text wrap="wrap">{msg.content}</Text> <Text wrap="wrap">{msg.content}</Text>
</Box> </Box>
</Box> </Box>
</Box>
); );
} }
@@ -74,6 +94,8 @@ export function MessageList({
scrollOffset, scrollOffset,
viewportSize, viewportSize,
isScrolledUp, isScrolledUp,
highlightedMessageIndices,
currentHighlightIndex,
}: MessageListProps) { }: MessageListProps) {
const useSlicing = scrollOffset != null && viewportSize != null; const useSlicing = scrollOffset != null && viewportSize != null;
const visibleMessages = useSlicing const visibleMessages = useSlicing
@@ -95,9 +117,16 @@ export function MessageList({
</Box> </Box>
)} )}
{visibleMessages.map((msg, i) => ( {visibleMessages.map((msg, i) => {
<MessageBubble key={hiddenAbove + i} msg={msg} /> const globalIndex = hiddenAbove + i;
))} const highlight =
globalIndex === currentHighlightIndex
? ('current' as const)
: highlightedMessageIndices?.has(globalIndex)
? ('match' as const)
: undefined;
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
})}
{/* Active thinking */} {/* Active thinking */}
{isStreaming && currentThinkingText && ( {isStreaming && currentThinkingText && (

View File

@@ -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 (
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
<Text>🔍</Text>
<Box flexGrow={1}>
<TextInput value={query} onChange={onQueryChange} focus={focused} />
</Box>
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
<Text dimColor> navigate · Esc close</Text>
</Box>
);
}

View File

@@ -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<SearchMatch[]>(() => {
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,
};
}