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,125 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { TokenUsage } from '../hooks/use-socket.js';
import type { GitInfo } from '../hooks/use-git-info.js';
export interface BottomBarProps {
gitInfo: GitInfo;
tokenUsage: TokenUsage;
connected: boolean;
connecting: boolean;
modelName: string | null;
providerName: string | null;
thinkingLevel: string;
conversationId: string | undefined;
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return String(n);
}
/** Compact the cwd — replace home with ~ */
function compactCwd(cwd: string): string {
const home = process.env['HOME'] ?? '';
if (home && cwd.startsWith(home)) {
return '~' + cwd.slice(home.length);
}
return cwd;
}
export function BottomBar({
gitInfo,
tokenUsage,
connected,
connecting,
modelName,
providerName,
thinkingLevel,
conversationId,
}: BottomBarProps) {
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
const hasTokens = tokenUsage.total > 0;
return (
<Box flexDirection="column" paddingX={0} marginTop={0}>
{/* Line 0: keybinding hints */}
<Box>
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
</Box>
{/* Line 1: blank ····· Gateway: Status */}
<Box justifyContent="space-between">
<Box />
<Box>
<Text dimColor>Gateway: </Text>
<Text color={gatewayColor}>{gatewayStatus}</Text>
</Box>
</Box>
{/* Line 2: cwd (branch) ····· Session: id */}
<Box justifyContent="space-between">
<Box>
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
</Box>
<Box>
<Text dimColor>
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
</Text>
</Box>
</Box>
{/* Line 3: token stats ····· (provider) model */}
<Box justifyContent="space-between" minHeight={1}>
<Box>
{hasTokens ? (
<>
<Text dimColor>{formatTokens(tokenUsage.input)}</Text>
<Text dimColor>{' '}</Text>
<Text dimColor>{formatTokens(tokenUsage.output)}</Text>
{tokenUsage.cacheRead > 0 && (
<>
<Text dimColor>{' '}</Text>
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
</>
)}
{tokenUsage.cacheWrite > 0 && (
<>
<Text dimColor>{' '}</Text>
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
</>
)}
{tokenUsage.cost > 0 && (
<>
<Text dimColor>{' '}</Text>
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
</>
)}
{tokenUsage.contextPercent > 0 && (
<>
<Text dimColor>{' '}</Text>
<Text dimColor>
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
</Text>
</>
)}
</>
) : (
<Text dimColor>0 0 $0.000</Text>
)}
</Box>
<Box>
<Text dimColor>
{providerName ? `(${providerName}) ` : ''}
{modelName ?? 'awaiting model'}
{thinkingLevel !== 'off' ? `${thinkingLevel}` : ''}
</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,50 @@
import React, { useState, useCallback } from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
export interface InputBarProps {
onSubmit: (value: string) => void;
isStreaming: boolean;
connected: boolean;
placeholder?: string;
}
export function InputBar({
onSubmit,
isStreaming,
connected,
placeholder: placeholderOverride,
}: InputBarProps) {
const [input, setInput] = useState('');
const handleSubmit = useCallback(
(value: string) => {
if (!value.trim() || isStreaming || !connected) return;
onSubmit(value);
setInput('');
},
[onSubmit, isStreaming, connected],
);
const placeholder =
placeholderOverride ??
(!connected
? 'disconnected — waiting for gateway…'
: isStreaming
? 'waiting for response…'
: 'message mosaic…');
return (
<Box paddingX={1} borderStyle="single" borderColor="gray">
<Text bold color="green">
{' '}
</Text>
<TextInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
placeholder={placeholder}
/>
</Box>
);
}

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

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,143 @@
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>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Box, Text } from 'ink';
export interface TopBarProps {
gatewayUrl: string;
version: string;
modelName: string | null;
thinkingLevel: string;
contextWindow: number;
agentName: string;
connected: boolean;
connecting: boolean;
}
/** Compact the URL — strip protocol */
function compactHost(url: string): string {
return url.replace(/^https?:\/\//, '');
}
function formatContextWindow(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return String(n);
}
/**
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
*
* Layout:
* blue ·· purple
* ·· pink ··
* amber ·· teal
*/
// Two-space gap between tiles (extracted to avoid prettier collapse)
const GAP = ' ';
function MosaicIcon() {
return (
<Box flexDirection="column" marginRight={2}>
<Text>
<Text color="#2f80ff"></Text>
<Text>{GAP}</Text>
<Text color="#8b5cf6"></Text>
</Text>
<Text>
<Text>{GAP}</Text>
<Text color="#ec4899"></Text>
</Text>
<Text>
<Text color="#f59e0b"></Text>
<Text>{GAP}</Text>
<Text color="#14b8a6"></Text>
</Text>
</Box>
);
}
export function TopBar({
gatewayUrl,
version,
modelName,
thinkingLevel,
contextWindow,
agentName,
connected,
connecting,
}: TopBarProps) {
const host = compactHost(gatewayUrl);
const connectionIndicator = connected ? '●' : '○';
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
// Build model description line like: "claude-opus-4-6 (1M context) · default"
const modelDisplay = modelName ?? 'awaiting model';
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
return (
<Box paddingX={1} paddingY={0} marginBottom={1}>
<MosaicIcon />
<Box flexDirection="column" flexGrow={1}>
<Text>
<Text bold color="#56a0ff">
Mosaic Stack
</Text>
<Text dimColor> v{version}</Text>
</Text>
<Text dimColor>
{modelDisplay}
{contextStr}
{thinkingStr} · {agentName}
</Text>
<Text>
<Text color={connectionColor}>{connectionIndicator}</Text>
<Text dimColor> {host}</Text>
</Text>
</Box>
</Box>
);
}