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>
193 lines
5.1 KiB
TypeScript
193 lines
5.1 KiB
TypeScript
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 SystemMessageBubble({ msg }: { msg: Message }) {
|
||
return (
|
||
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
|
||
<Text dimColor>{'⚙ '}</Text>
|
||
<Text dimColor wrap="wrap">
|
||
{msg.content}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function MessageBubble({
|
||
msg,
|
||
highlight,
|
||
}: {
|
||
msg: Message;
|
||
highlight?: 'match' | 'current' | undefined;
|
||
}) {
|
||
if (msg.role === 'system') {
|
||
return <SystemMessageBubble msg={msg} />;
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|