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 { Box, useApp, useInput } from 'ink';
|
||||||
import { TopBar } from './components/top-bar.js';
|
import { TopBar } from './components/top-bar.js';
|
||||||
import { BottomBar } from './components/bottom-bar.js';
|
import { BottomBar } from './components/bottom-bar.js';
|
||||||
import { MessageList } from './components/message-list.js';
|
import { MessageList } from './components/message-list.js';
|
||||||
import { InputBar } from './components/input-bar.js';
|
import { InputBar } from './components/input-bar.js';
|
||||||
import { Sidebar } from './components/sidebar.js';
|
import { Sidebar } from './components/sidebar.js';
|
||||||
|
import { SearchBar } from './components/search-bar.js';
|
||||||
import { useSocket } from './hooks/use-socket.js';
|
import { useSocket } from './hooks/use-socket.js';
|
||||||
import { useGitInfo } from './hooks/use-git-info.js';
|
import { useGitInfo } from './hooks/use-git-info.js';
|
||||||
import { useViewport } from './hooks/use-viewport.js';
|
import { useViewport } from './hooks/use-viewport.js';
|
||||||
import { useAppMode } from './hooks/use-app-mode.js';
|
import { useAppMode } from './hooks/use-app-mode.js';
|
||||||
import { useConversations } from './hooks/use-conversations.js';
|
import { useConversations } from './hooks/use-conversations.js';
|
||||||
|
import { useSearch } from './hooks/use-search.js';
|
||||||
|
|
||||||
export interface TuiAppProps {
|
export interface TuiAppProps {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
@@ -32,6 +34,24 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
|||||||
|
|
||||||
const viewport = useViewport({ totalItems: socket.messages.length });
|
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 [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||||
|
|
||||||
const handleSwitchConversation = useCallback(
|
const handleSwitchConversation = useCallback(
|
||||||
@@ -76,7 +96,12 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
|||||||
}
|
}
|
||||||
// Ctrl+K: toggle search mode
|
// Ctrl+K: toggle search mode
|
||||||
if (key.ctrl && ch === 'k') {
|
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)
|
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||||
if (appMode.mode === 'chat') {
|
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
|
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||||
if (key.escape) {
|
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');
|
appMode.setMode('chat');
|
||||||
} else if (appMode.mode === 'chat') {
|
} else if (appMode.mode === 'chat') {
|
||||||
viewport.scrollToBottom();
|
viewport.scrollToBottom();
|
||||||
@@ -116,6 +144,8 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
|||||||
? 'search mode… press Esc to return'
|
? 'search mode… press Esc to return'
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const isSearchMode = appMode.mode === 'search';
|
||||||
|
|
||||||
const messageArea = (
|
const messageArea = (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
<MessageList
|
<MessageList
|
||||||
@@ -127,8 +157,26 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
|
|||||||
scrollOffset={viewport.scrollOffset}
|
scrollOffset={viewport.scrollOffset}
|
||||||
viewportSize={viewport.viewportSize}
|
viewportSize={viewport.viewportSize}
|
||||||
isScrolledUp={viewport.isScrolledUp}
|
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
|
<InputBar
|
||||||
onSubmit={socket.sendMessage}
|
onSubmit={socket.sendMessage}
|
||||||
isStreaming={socket.isStreaming}
|
isStreaming={socket.isStreaming}
|
||||||
|
|||||||
@@ -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