feat(cli): TUI complete overhaul — components, sidebar, search, branding (#157)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #157.
This commit is contained in:
2026-03-15 22:17:19 +00:00
committed by jason.woltje
parent d31070177c
commit 82c10a7b33
21 changed files with 2808 additions and 355 deletions

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Message, ToolCall } from '../hooks/use-socket.js';
export interface MessageListProps {
messages: Message[];
isStreaming: boolean;
currentStreamText: string;
currentThinkingText: string;
activeToolCalls: ToolCall[];
scrollOffset?: number;
viewportSize?: number;
isScrolledUp?: boolean;
highlightedMessageIndices?: Set<number>;
currentHighlightIndex?: number;
}
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
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="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>
);
}
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
const color =
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
return (
<Box marginLeft={2}>
{toolCall.status === 'running' ? (
<Text color="yellow">
<Spinner type="dots" />
</Text>
) : (
<Text color={color}>{icon}</Text>
)}
<Text dimColor> tool: </Text>
<Text color={color}>{toolCall.toolName}</Text>
</Box>
);
}
export function MessageList({
messages,
isStreaming,
currentStreamText,
currentThinkingText,
activeToolCalls,
scrollOffset,
viewportSize,
isScrolledUp,
highlightedMessageIndices,
currentHighlightIndex,
}: MessageListProps) {
const useSlicing = scrollOffset != null && viewportSize != null;
const visibleMessages = useSlicing
? messages.slice(scrollOffset, scrollOffset + viewportSize)
: messages;
const hiddenAbove = useSlicing ? scrollOffset : 0;
return (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{isScrolledUp && hiddenAbove > 0 && (
<Box justifyContent="center">
<Text dimColor> {hiddenAbove} more messages </Text>
</Box>
)}
{messages.length === 0 && !isStreaming && (
<Box justifyContent="center" marginY={1}>
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
</Box>
)}
{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 && (
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
<Text dimColor italic>
💭 {currentThinkingText}
</Text>
</Box>
)}
{/* Active tool calls */}
{activeToolCalls.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
{activeToolCalls.map((tc) => (
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
))}
</Box>
)}
{/* Streaming response */}
{isStreaming && currentStreamText && (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold color="cyan">
{' '}
</Text>
<Text bold color="cyan">
assistant
</Text>
</Box>
<Box marginLeft={2}>
<Text wrap="wrap">{currentStreamText}</Text>
</Box>
</Box>
)}
{/* Waiting spinner */}
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
<Box marginLeft={2}>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text dimColor> thinking</Text>
</Box>
)}
</Box>
);
}