@mosaic/mosaic is now the single package providing both: - 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.) - 'mosaic-wizard' binary (installation wizard) Changes: - Move packages/cli/src/* into packages/mosaic/src/ - Convert dynamic @mosaic/mosaic imports to static relative imports - Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic - Add jsx: react-jsx to mosaic's tsconfig - Exclude packages/cli from workspace (pnpm-workspace.yaml) - Update install.sh to install @mosaic/mosaic instead of @mosaic/cli - Bump version to 0.0.17 This eliminates the circular dependency between @mosaic/cli and @mosaic/mosaic that was blocking the build graph.
144 lines
3.9 KiB
TypeScript
144 lines
3.9 KiB
TypeScript
import React from 'react';
|
|
import { Box, Text, useInput } from 'ink';
|
|
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
|
|
|
export interface SidebarProps {
|
|
conversations: ConversationSummary[];
|
|
activeConversationId: string | undefined;
|
|
selectedIndex: number;
|
|
onSelectIndex: (index: number) => void;
|
|
onSwitchConversation: (id: string) => void;
|
|
onDeleteConversation: (id: string) => void;
|
|
loading: boolean;
|
|
focused: boolean;
|
|
width: number;
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays === 0) {
|
|
const hh = String(date.getHours()).padStart(2, '0');
|
|
const mm = String(date.getMinutes()).padStart(2, '0');
|
|
return `${hh}:${mm}`;
|
|
}
|
|
if (diffDays < 7) {
|
|
return `${diffDays}d ago`;
|
|
}
|
|
const months = [
|
|
'Jan',
|
|
'Feb',
|
|
'Mar',
|
|
'Apr',
|
|
'May',
|
|
'Jun',
|
|
'Jul',
|
|
'Aug',
|
|
'Sep',
|
|
'Oct',
|
|
'Nov',
|
|
'Dec',
|
|
];
|
|
const mon = months[date.getMonth()];
|
|
const dd = String(date.getDate()).padStart(2, '0');
|
|
return `${mon} ${dd}`;
|
|
}
|
|
|
|
function truncate(text: string, maxLen: number): string {
|
|
if (text.length <= maxLen) return text;
|
|
return text.slice(0, maxLen - 1) + '…';
|
|
}
|
|
|
|
export function Sidebar({
|
|
conversations,
|
|
activeConversationId,
|
|
selectedIndex,
|
|
onSelectIndex,
|
|
onSwitchConversation,
|
|
onDeleteConversation,
|
|
loading,
|
|
focused,
|
|
width,
|
|
}: SidebarProps) {
|
|
useInput(
|
|
(_input, key) => {
|
|
if (key.upArrow) {
|
|
onSelectIndex(Math.max(0, selectedIndex - 1));
|
|
}
|
|
if (key.downArrow) {
|
|
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
|
}
|
|
if (key.return) {
|
|
const conv = conversations[selectedIndex];
|
|
if (conv) {
|
|
onSwitchConversation(conv.id);
|
|
}
|
|
}
|
|
if (_input === 'd') {
|
|
const conv = conversations[selectedIndex];
|
|
if (conv) {
|
|
onDeleteConversation(conv.id);
|
|
}
|
|
}
|
|
},
|
|
{ isActive: focused },
|
|
);
|
|
|
|
const borderColor = focused ? 'cyan' : 'gray';
|
|
// Available width for content inside border + padding
|
|
const innerWidth = width - 4; // 2 border + 2 padding
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="column"
|
|
width={width}
|
|
borderStyle="single"
|
|
borderColor={borderColor}
|
|
paddingX={1}
|
|
>
|
|
<Text bold color="cyan">
|
|
Conversations
|
|
</Text>
|
|
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
|
{loading && conversations.length === 0 ? (
|
|
<Text dimColor>Loading…</Text>
|
|
) : conversations.length === 0 ? (
|
|
<Text dimColor>No conversations</Text>
|
|
) : (
|
|
conversations.map((conv, idx) => {
|
|
const isActive = conv.id === activeConversationId;
|
|
const isSelected = idx === selectedIndex && focused;
|
|
const marker = isActive ? '● ' : ' ';
|
|
const time = formatRelativeTime(conv.updatedAt);
|
|
const title = conv.title ?? 'Untitled';
|
|
// marker(2) + title + space(1) + time
|
|
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
|
const displayTitle = truncate(title, maxTitleLen);
|
|
|
|
return (
|
|
<Box key={conv.id}>
|
|
<Text
|
|
inverse={isSelected}
|
|
color={isActive ? 'cyan' : undefined}
|
|
dimColor={!isActive && !isSelected}
|
|
>
|
|
{marker}
|
|
{displayTitle}
|
|
{' '.repeat(
|
|
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
|
)}
|
|
{time}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
})
|
|
)}
|
|
</Box>
|
|
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
|
</Box>
|
|
);
|
|
}
|