feat(cli): match TUI footer to reference design

- Remove borders from input bar — clean '❯ message mosaic…' prompt
- Two-line footer without borders:
  - Line 1: compact cwd (branch) | Gateway: Connected/Disconnected
  - Line 2: token stats (^in v_out R_cache W_cache $cost ctx%) | (provider) model
- Extend TokenUsage with cacheRead, cacheWrite, cost, contextPercent, contextWindow
- Add providerName to socket hook return
- Reorder layout: top bar → messages → input → footer
This commit is contained in:
2026-03-15 13:53:54 -05:00
parent 8bb56a4003
commit b8857da0e6
4 changed files with 107 additions and 39 deletions

View File

@@ -47,13 +47,20 @@ export function TuiApp({ gatewayUrl, conversationId, sessionCookie }: TuiAppProp
activeToolCalls={socket.activeToolCalls} activeToolCalls={socket.activeToolCalls}
/> />
<BottomBar gitInfo={gitInfo} tokenUsage={socket.tokenUsage} />
<InputBar <InputBar
onSubmit={socket.sendMessage} onSubmit={socket.sendMessage}
isStreaming={socket.isStreaming} isStreaming={socket.isStreaming}
connected={socket.connected} connected={socket.connected}
/> />
<BottomBar
gitInfo={gitInfo}
tokenUsage={socket.tokenUsage}
connected={socket.connected}
connecting={socket.connecting}
modelName={socket.modelName}
providerName={socket.providerName}
/>
</Box> </Box>
); );
} }

View File

@@ -6,55 +6,101 @@ import type { GitInfo } from '../hooks/use-git-info.js';
export interface BottomBarProps { export interface BottomBarProps {
gitInfo: GitInfo; gitInfo: GitInfo;
tokenUsage: TokenUsage; tokenUsage: TokenUsage;
connected: boolean;
connecting: boolean;
modelName: string | null;
providerName: string | null;
} }
function formatTokens(n: number): string { function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return String(n); return String(n);
} }
/** Compact the cwd — show ~ for home, truncate long paths */ /** Compact the cwd — replace home with ~ */
function compactCwd(cwd: string): string { function compactCwd(cwd: string): string {
const home = process.env['HOME'] ?? ''; const home = process.env['HOME'] ?? '';
if (home && cwd.startsWith(home)) { if (home && cwd.startsWith(home)) {
cwd = '~' + cwd.slice(home.length); return '~' + cwd.slice(home.length);
}
// If still very long, show last 3 segments
const parts = cwd.split('/');
if (parts.length > 4) {
return '…/' + parts.slice(-3).join('/');
} }
return cwd; return cwd;
} }
export function BottomBar({ gitInfo, tokenUsage }: BottomBarProps) { export function BottomBar({
gitInfo,
tokenUsage,
connected,
connecting,
modelName,
providerName,
}: BottomBarProps) {
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
const hasTokens = tokenUsage.total > 0; const hasTokens = tokenUsage.total > 0;
return ( return (
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between"> <Box flexDirection="column" paddingX={0} marginTop={0}>
<Box> {/* Line 1: path (branch) ····· Gateway: Status */}
<Text dimColor>cwd: </Text> <Box justifyContent="space-between">
<Text>{compactCwd(gitInfo.cwd)}</Text> <Box>
{gitInfo.branch && ( <Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
<> {gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
<Text dimColor> </Text> </Box>
<Text color="cyan">{gitInfo.branch}</Text> <Box>
</> <Text dimColor>Gateway: </Text>
)} <Text color={gatewayColor}>{gatewayStatus}</Text>
</Box>
</Box> </Box>
<Box>
{hasTokens ? ( {/* Line 2: token stats ····· (provider) model */}
<> <Box justifyContent="space-between">
<Text dimColor>tokens: </Text> <Box>
<Text color="green">{formatTokens(tokenUsage.input)}</Text> {hasTokens ? (
<Text dimColor> / </Text> <>
<Text color="yellow">{formatTokens(tokenUsage.output)}</Text> <Text dimColor>^{formatTokens(tokenUsage.input)}</Text>
<Text dimColor> ({formatTokens(tokenUsage.total)})</Text> <Text dimColor>{' '}</Text>
</> <Text dimColor>v{formatTokens(tokenUsage.output)}</Text>
) : ( {tokenUsage.cacheRead > 0 && (
<Text dimColor>tokens: </Text> <>
)} <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></Text>
)}
</Box>
<Box>
{(providerName ?? modelName) && (
<Text dimColor>
{providerName ? `(${providerName}) ` : ''}
{modelName ?? ''}
</Text>
)}
</Box>
</Box> </Box>
</Box> </Box>
); );

View File

@@ -26,12 +26,10 @@ export function InputBar({ onSubmit, isStreaming, connected }: InputBarProps) {
? 'waiting for response…' ? 'waiting for response…'
: 'message mosaic…'; : 'message mosaic…';
const promptColor = !connected ? 'red' : isStreaming ? 'yellow' : 'green';
return ( return (
<Box paddingX={1} borderStyle="single" borderColor="gray"> <Box paddingX={0}>
<Text bold color={promptColor}> <Text bold color="green">
{' '} {' '}
</Text> </Text>
<TextInput <TextInput
value={input} value={input}

View File

@@ -28,6 +28,11 @@ export interface TokenUsage {
input: number; input: number;
output: number; output: number;
total: number; total: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextPercent: number;
contextWindow: number;
} }
export interface UseSocketOptions { export interface UseSocketOptions {
@@ -47,6 +52,7 @@ export interface UseSocketReturn {
activeToolCalls: ToolCall[]; activeToolCalls: ToolCall[];
tokenUsage: TokenUsage; tokenUsage: TokenUsage;
modelName: string | null; modelName: string | null;
providerName: string | null;
sendMessage: (content: string) => void; sendMessage: (content: string) => void;
connectionError: string | null; connectionError: string | null;
} }
@@ -65,8 +71,18 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
const [currentThinkingText, setCurrentThinkingText] = useState(''); const [currentThinkingText, setCurrentThinkingText] = useState('');
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]); const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
// TODO: wire up once gateway emits token-usage and model-info events // TODO: wire up once gateway emits token-usage and model-info events
const tokenUsage: TokenUsage = { input: 0, output: 0, total: 0 }; const tokenUsage: TokenUsage = {
input: 0,
output: 0,
total: 0,
cacheRead: 0,
cacheWrite: 0,
cost: 0,
contextPercent: 0,
contextWindow: 0,
};
const modelName: string | null = null; const modelName: string | null = null;
const providerName: string | null = null;
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const socketRef = useRef<TypedSocket | null>(null); const socketRef = useRef<TypedSocket | null>(null);
@@ -191,6 +207,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
activeToolCalls, activeToolCalls,
tokenUsage, tokenUsage,
modelName, modelName,
providerName,
sendMessage, sendMessage,
connectionError, connectionError,
}; };