feat(cli): message search with highlighting (TUI-011)
This commit is contained in:
@@ -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 = (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<MessageList
|
||||
@@ -127,8 +157,26 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
||||
scrollOffset={viewport.scrollOffset}
|
||||
viewportSize={viewport.viewportSize}
|
||||
isScrolledUp={viewport.isScrolledUp}
|
||||
highlightedMessageIndices={highlightedMessageIndices}
|
||||
currentHighlightIndex={currentHighlightIndex}
|
||||
/>
|
||||
|
||||
{isSearchMode && (
|
||||
<SearchBar
|
||||
query={search.query}
|
||||
onQueryChange={search.setQuery}
|
||||
totalMatches={search.totalMatches}
|
||||
currentMatch={search.currentMatchIndex}
|
||||
onNext={search.nextMatch}
|
||||
onPrev={search.prevMatch}
|
||||
onClose={() => {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
}}
|
||||
focused={isSearchMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputBar
|
||||
onSubmit={socket.sendMessage}
|
||||
isStreaming={socket.isStreaming}
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface MessageListProps {
|
||||
scrollOffset?: number;
|
||||
viewportSize?: number;
|
||||
isScrolledUp?: boolean;
|
||||
highlightedMessageIndices?: Set<number>;
|
||||
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' ? (
|
||||
<Text color="yellowBright" bold>
|
||||
▌{' '}
|
||||
</Text>
|
||||
) : highlight === 'match' ? (
|
||||
<Text color="yellow">▌ </Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold color={color}>
|
||||
{prefix}{' '}
|
||||
</Text>
|
||||
<Text bold color={color}>
|
||||
{isUser ? 'you' : 'assistant'}
|
||||
</Text>
|
||||
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{msg.content}</Text>
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{borderIndicator}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={color}>
|
||||
{prefix}{' '}
|
||||
</Text>
|
||||
<Text bold color={color}>
|
||||
{isUser ? 'you' : 'assistant'}
|
||||
</Text>
|
||||
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{msg.content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -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({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((msg, i) => (
|
||||
<MessageBubble key={hiddenAbove + i} msg={msg} />
|
||||
))}
|
||||
{visibleMessages.map((msg, i) => {
|
||||
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 */}
|
||||
{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