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

This commit is contained in:
2026-03-15 15:07:31 -05:00
parent 9f38f5ab31
commit 8627827c4b
4 changed files with 232 additions and 19 deletions

View File

@@ -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}

View File

@@ -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 && (

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,
};
}