feat(cli): message search with highlighting (TUI-011)
This commit is contained in:
@@ -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,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 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}>
|
||||||
<Box>
|
{borderIndicator}
|
||||||
<Text bold color={color}>
|
<Box flexDirection="column">
|
||||||
{prefix}{' '}
|
<Box>
|
||||||
</Text>
|
<Text bold color={color}>
|
||||||
<Text bold color={color}>
|
{prefix}{' '}
|
||||||
{isUser ? 'you' : 'assistant'}
|
</Text>
|
||||||
</Text>
|
<Text bold color={color}>
|
||||||
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
{isUser ? 'you' : 'assistant'}
|
||||||
</Box>
|
</Text>
|
||||||
<Box marginLeft={2}>
|
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||||
<Text wrap="wrap">{msg.content}</Text>
|
</Box>
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<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 && (
|
||||||
|
|||||||
60
packages/cli/src/tui/components/search-bar.tsx
Normal file
60
packages/cli/src/tui/components/search-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
packages/cli/src/tui/hooks/use-search.ts
Normal file
76
packages/cli/src/tui/hooks/use-search.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user