diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx new file mode 100644 index 0000000..02f5dde --- /dev/null +++ b/src/frontends/tui/components/App.tsx @@ -0,0 +1,98 @@ +import React, { useState, useCallback } from 'react'; +import { Box, useApp, useInput } from 'ink'; +import { StatusBar } from './StatusBar.js'; +import { MessageList } from './MessageList.js'; +import { InputBar } from './InputBar.js'; +import type { Message, ModelClient } from '../../../models/types.js'; +import type { ManagedSession } from '../../../session/index.js'; + +export interface AppProps { + session: ManagedSession; + modelClient: ModelClient; + systemPrompt: string; + model: string; + onExit?: () => void; +} + +export function App({ + session, + modelClient, + systemPrompt, + model, + onExit, +}: AppProps): React.ReactElement { + const { exit } = useApp(); + const [input, setInput] = useState(''); + const [messages, setMessages] = useState(session.getHistory()); + const [isLoading, setIsLoading] = useState(false); + + useInput((inputChar, key) => { + if (key.escape) { + onExit?.(); + exit(); + } + }); + + const handleSubmit = useCallback(async (value: string) => { + const trimmed = value.trim(); + if (!trimmed || isLoading) return; + + // Handle commands + if (trimmed === '/quit' || trimmed === '/exit') { + onExit?.(); + exit(); + return; + } + + if (trimmed === '/reset' || trimmed === '/clear') { + session.clear(); + setMessages([]); + setInput(''); + return; + } + + // Regular message + const userMessage: Message = { role: 'user', content: trimmed }; + session.addMessage(userMessage); + setMessages(prev => [...prev, userMessage]); + setInput(''); + setIsLoading(true); + + try { + const response = await modelClient.chat({ + messages: session.getHistory(), + system: systemPrompt, + }); + + const assistantMessage: Message = { role: 'assistant', content: response.content }; + session.addMessage(assistantMessage); + setMessages(prev => [...prev, assistantMessage]); + } catch (error) { + const errorMessage: Message = { + role: 'assistant', + content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + setMessages(prev => [...prev, errorMessage]); + } finally { + setIsLoading(false); + } + }, [isLoading, session, modelClient, systemPrompt, exit, onExit]); + + return ( + + + + + + ); +} diff --git a/src/frontends/tui/components/InputBar.tsx b/src/frontends/tui/components/InputBar.tsx new file mode 100644 index 0000000..44c0238 --- /dev/null +++ b/src/frontends/tui/components/InputBar.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import TextInput from 'ink-text-input'; + +export interface InputBarProps { + value: string; + onChange: (value: string) => void; + onSubmit: (value: string) => void; + isLoading?: boolean; + placeholder?: string; +} + +export function InputBar({ + value, + onChange, + onSubmit, + isLoading = false, + placeholder = 'Type a message...', +}: InputBarProps): React.ReactElement { + return ( + + {'> '} + {isLoading ? ( + Thinking... + ) : ( + + )} + + ); +} diff --git a/src/frontends/tui/components/MessageList.tsx b/src/frontends/tui/components/MessageList.tsx new file mode 100644 index 0000000..7d5729a --- /dev/null +++ b/src/frontends/tui/components/MessageList.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import type { Message } from '../../../models/types.js'; + +export interface MessageListProps { + messages: Message[]; + maxHeight?: number; +} + +export function MessageList({ messages, maxHeight = 20 }: MessageListProps): React.ReactElement { + // Show only recent messages that fit + const visibleMessages = messages.slice(-maxHeight); + + return ( + + {visibleMessages.length === 0 ? ( + No messages yet. Start typing to chat with Flynn. + ) : ( + visibleMessages.map((message, index) => ( + + + {message.role === 'user' ? 'You: ' : 'Flynn: '} + + {message.content} + + )) + )} + + ); +} diff --git a/src/frontends/tui/components/StatusBar.tsx b/src/frontends/tui/components/StatusBar.tsx new file mode 100644 index 0000000..e0852ec --- /dev/null +++ b/src/frontends/tui/components/StatusBar.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Box, Text } from 'ink'; + +export interface StatusBarProps { + sessionId: string; + messageCount: number; + model: string; +} + +export function StatusBar({ sessionId, messageCount, model }: StatusBarProps): React.ReactElement { + return ( + + + Flynn + | + Session: + {sessionId} + + + Messages: + {messageCount} + | + Model: + {model} + + + ); +} diff --git a/src/frontends/tui/components/index.ts b/src/frontends/tui/components/index.ts new file mode 100644 index 0000000..a8fffea --- /dev/null +++ b/src/frontends/tui/components/index.ts @@ -0,0 +1,4 @@ +export { App, type AppProps } from './App.js'; +export { StatusBar, type StatusBarProps } from './StatusBar.js'; +export { MessageList, type MessageListProps } from './MessageList.js'; +export { InputBar, type InputBarProps } from './InputBar.js'; diff --git a/tsconfig.json b/tsconfig.json index 479c240..add806e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "resolveJsonModule": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "jsx": "react-jsx" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]