From 934be021ab14debe3fbbdf64918f0374500b736b Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 00:31:28 -0800 Subject: [PATCH] docs: add Phase 3 TUI implementation plan Covers: - SessionManager for multi-frontend support - Minimal readline TUI - Fullscreen Ink-based TUI - Mode switching and session transfer Co-Authored-By: Claude Opus 4.5 --- .../2026-02-05-flynn-phase3-implementation.md | 1442 +++++++++++++++++ 1 file changed, 1442 insertions(+) create mode 100644 docs/plans/2026-02-05-flynn-phase3-implementation.md diff --git a/docs/plans/2026-02-05-flynn-phase3-implementation.md b/docs/plans/2026-02-05-flynn-phase3-implementation.md new file mode 100644 index 0000000..1c020ed --- /dev/null +++ b/docs/plans/2026-02-05-flynn-phase3-implementation.md @@ -0,0 +1,1442 @@ +# Flynn Phase 3 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a Terminal User Interface (TUI) frontend with minimal readline mode and full-screen panel mode, sharing sessions with the daemon. + +**Architecture:** The TUI connects to the same daemon components as Telegram. It has two modes: minimal (simple prompt with streaming) and fullscreen (Ink-based React UI with conversation pane and status bar). Sessions are separate per frontend but can be transferred via `/transfer` command. + +**Tech Stack:** TypeScript, Ink 6.x (React for CLI), ink-text-input, readline/node:readline + +--- + +## Task 1: Add TUI Dependencies + +**Files:** +- Modify: `package.json` + +**Step 1: Add dependencies** + +Add to package.json dependencies: +```json +"ink": "^6.0.0", +"ink-text-input": "^6.0.0", +"react": "^19.0.0" +``` + +Add to devDependencies: +```json +"@types/react": "^19.0.0" +``` + +**Step 2: Install dependencies** + +Run: `pnpm install` + +**Step 3: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "chore: add dependencies for TUI (ink, react)" +``` + +--- + +## Task 2: Session Manager (Multi-Frontend Support) + +**Files:** +- Create: `src/session/manager.ts` +- Modify: `src/session/index.ts` +- Test: `src/session/manager.test.ts` + +**Step 1: Write failing test** + +Create `src/session/manager.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SessionManager } from './manager.js'; +import { SessionStore } from './store.js'; +import { unlinkSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('SessionManager', () => { + const dbPath = join(tmpdir(), 'flynn-test-manager.db'); + let store: SessionStore; + let manager: SessionManager; + + beforeEach(() => { + store = new SessionStore(dbPath); + manager = new SessionManager(store); + }); + + afterEach(() => { + store.close(); + if (existsSync(dbPath)) { + unlinkSync(dbPath); + } + }); + + it('creates sessions for different frontends', () => { + const telegramSession = manager.getSession('telegram', 'user-123'); + const tuiSession = manager.getSession('tui', 'local'); + + expect(telegramSession.id).toBe('telegram:user-123'); + expect(tuiSession.id).toBe('tui:local'); + }); + + it('returns same session for same frontend and user', () => { + const session1 = manager.getSession('telegram', 'user-123'); + const session2 = manager.getSession('telegram', 'user-123'); + + expect(session1).toBe(session2); + }); + + it('transfers session history between frontends', () => { + const telegramSession = manager.getSession('telegram', 'user-123'); + telegramSession.addMessage({ role: 'user', content: 'Hello from Telegram' }); + telegramSession.addMessage({ role: 'assistant', content: 'Hi!' }); + + const tuiSession = manager.getSession('tui', 'local'); + manager.transferSession('telegram', 'user-123', 'tui', 'local'); + + const tuiMessages = tuiSession.getHistory(); + expect(tuiMessages).toHaveLength(2); + expect(tuiMessages[0].content).toBe('Hello from Telegram'); + }); + + it('lists active sessions', () => { + manager.getSession('telegram', 'user-123'); + manager.getSession('tui', 'local'); + + const sessions = manager.listSessions(); + expect(sessions).toContain('telegram:user-123'); + expect(sessions).toContain('tui:local'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/session/manager.test.ts` + +**Step 3: Implement SessionManager** + +Create `src/session/manager.ts`: + +```typescript +import type { Message } from '../models/types.js'; +import type { SessionStore } from './store.js'; + +export interface Session { + id: string; + addMessage(message: Message): void; + getHistory(): Message[]; + clear(): void; +} + +export class ManagedSession implements Session { + constructor( + public readonly id: string, + private store: SessionStore, + private history: Message[] = [] + ) {} + + addMessage(message: Message): void { + this.history.push(message); + this.store.addMessage(this.id, message); + } + + getHistory(): Message[] { + return [...this.history]; + } + + clear(): void { + this.history = []; + this.store.clearSession(this.id); + } + + setHistory(messages: Message[]): void { + this.history = [...messages]; + } +} + +export class SessionManager { + private sessions: Map = new Map(); + + constructor(private store: SessionStore) {} + + private makeSessionId(frontend: string, userId: string): string { + return `${frontend}:${userId}`; + } + + getSession(frontend: string, userId: string): ManagedSession { + const id = this.makeSessionId(frontend, userId); + + let session = this.sessions.get(id); + if (!session) { + const history = this.store.getMessages(id); + session = new ManagedSession(id, this.store, history); + this.sessions.set(id, session); + } + + return session; + } + + transferSession( + fromFrontend: string, + fromUserId: string, + toFrontend: string, + toUserId: string + ): void { + const fromSession = this.getSession(fromFrontend, fromUserId); + const toSession = this.getSession(toFrontend, toUserId); + + const history = fromSession.getHistory(); + + // Clear target and copy history + toSession.clear(); + for (const message of history) { + toSession.addMessage(message); + } + } + + listSessions(): string[] { + return Array.from(this.sessions.keys()); + } + + closeSession(frontend: string, userId: string): void { + const id = this.makeSessionId(frontend, userId); + this.sessions.delete(id); + } +} +``` + +**Step 4: Update session index** + +Add to `src/session/index.ts`: + +```typescript +export { SessionManager, ManagedSession, type Session } from './manager.js'; +``` + +**Step 5: Run test to verify it passes** + +Run: `pnpm test:run src/session/manager.test.ts` + +**Step 6: Commit** + +```bash +git add src/session/manager.ts src/session/manager.test.ts src/session/index.ts +git commit -m "feat: add SessionManager for multi-frontend session handling" +``` + +--- + +## Task 3: Minimal TUI (Readline Mode) + +**Files:** +- Create: `src/frontends/tui/minimal.ts` +- Create: `src/frontends/tui/index.ts` +- Test: `src/frontends/tui/minimal.test.ts` + +**Step 1: Write failing test** + +Create `src/frontends/tui/minimal.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { formatPrompt, parseCommand, type TuiCommand } from './minimal.js'; + +describe('formatPrompt', () => { + it('formats default prompt', () => { + const prompt = formatPrompt('default'); + expect(prompt).toBe('flynn> '); + }); + + it('formats thinking prompt', () => { + const prompt = formatPrompt('thinking'); + expect(prompt).toContain('...'); + }); +}); + +describe('parseCommand', () => { + it('parses /quit command', () => { + const result = parseCommand('/quit'); + expect(result).toEqual({ type: 'quit' }); + }); + + it('parses /reset command', () => { + const result = parseCommand('/reset'); + expect(result).toEqual({ type: 'reset' }); + }); + + it('parses /transfer command with target', () => { + const result = parseCommand('/transfer telegram'); + expect(result).toEqual({ type: 'transfer', target: 'telegram' }); + }); + + it('parses /fullscreen command', () => { + const result = parseCommand('/fullscreen'); + expect(result).toEqual({ type: 'fullscreen' }); + }); + + it('parses regular message', () => { + const result = parseCommand('Hello, Flynn!'); + expect(result).toEqual({ type: 'message', content: 'Hello, Flynn!' }); + }); + + it('returns null for empty input', () => { + const result = parseCommand(''); + expect(result).toBeNull(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/frontends/tui/minimal.test.ts` + +**Step 3: Implement minimal TUI helpers** + +Create `src/frontends/tui/minimal.ts`: + +```typescript +import * as readline from 'node:readline'; +import type { ManagedSession } from '../../session/index.js'; +import type { ModelClient } from '../../models/types.js'; + +export type TuiCommand = + | { type: 'quit' } + | { type: 'reset' } + | { type: 'transfer'; target: string } + | { type: 'fullscreen' } + | { type: 'status' } + | { type: 'help' } + | { type: 'message'; content: string }; + +export function formatPrompt(state: 'default' | 'thinking'): string { + if (state === 'thinking') { + return 'flynn... '; + } + return 'flynn> '; +} + +export function parseCommand(input: string): TuiCommand | null { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + + if (trimmed === '/quit' || trimmed === '/exit') { + return { type: 'quit' }; + } + + if (trimmed === '/reset' || trimmed === '/clear') { + return { type: 'reset' }; + } + + if (trimmed.startsWith('/transfer ')) { + const target = trimmed.slice('/transfer '.length).trim(); + return { type: 'transfer', target }; + } + + if (trimmed === '/fullscreen' || trimmed === '/fs') { + return { type: 'fullscreen' }; + } + + if (trimmed === '/status') { + return { type: 'status' }; + } + + if (trimmed === '/help' || trimmed === '/?') { + return { type: 'help' }; + } + + return { type: 'message', content: trimmed }; +} + +export interface MinimalTuiConfig { + session: ManagedSession; + modelClient: ModelClient; + systemPrompt: string; + onFullscreen?: () => void; + onTransfer?: (target: string) => void; +} + +export class MinimalTui { + private rl: readline.Interface | null = null; + private running = false; + + constructor(private config: MinimalTuiConfig) {} + + async start(): Promise { + this.running = true; + + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log('Flynn TUI (minimal mode)'); + console.log('Type /help for commands, /fullscreen for panel mode\n'); + + await this.promptLoop(); + } + + private async promptLoop(): Promise { + while (this.running && this.rl) { + const input = await this.prompt(formatPrompt('default')); + const command = parseCommand(input); + + if (!command) { + continue; + } + + await this.handleCommand(command); + } + } + + private prompt(promptText: string): Promise { + return new Promise((resolve) => { + this.rl?.question(promptText, resolve); + }); + } + + private async handleCommand(command: TuiCommand): Promise { + switch (command.type) { + case 'quit': + this.stop(); + break; + + case 'reset': + this.config.session.clear(); + console.log('Session cleared.\n'); + break; + + case 'transfer': + this.config.onTransfer?.(command.target); + break; + + case 'fullscreen': + this.config.onFullscreen?.(); + break; + + case 'status': + console.log(`Session: ${this.config.session.id}`); + console.log(`Messages: ${this.config.session.getHistory().length}\n`); + break; + + case 'help': + this.printHelp(); + break; + + case 'message': + await this.handleMessage(command.content); + break; + } + } + + private async handleMessage(content: string): Promise { + this.config.session.addMessage({ role: 'user', content }); + + process.stdout.write('\n'); + + try { + const response = await this.config.modelClient.chat({ + messages: this.config.session.getHistory(), + system: this.config.systemPrompt, + }); + + console.log(response.content); + console.log(); + + this.config.session.addMessage({ role: 'assistant', content: response.content }); + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + console.log(); + } + } + + private printHelp(): void { + console.log(` +Commands: + /help, /? Show this help + /reset, /clear Clear conversation history + /status Show session info + /fullscreen, /fs Switch to fullscreen mode + /transfer Transfer session to another frontend + /quit, /exit Exit TUI +`); + } + + stop(): void { + this.running = false; + this.rl?.close(); + this.rl = null; + } +} +``` + +**Step 4: Create TUI index** + +Create `src/frontends/tui/index.ts`: + +```typescript +export { + MinimalTui, + formatPrompt, + parseCommand, + type TuiCommand, + type MinimalTuiConfig, +} from './minimal.js'; +``` + +**Step 5: Run test to verify it passes** + +Run: `pnpm test:run src/frontends/tui/minimal.test.ts` + +**Step 6: Commit** + +```bash +git add src/frontends/tui/ +git commit -m "feat: add minimal TUI with readline interface" +``` + +--- + +## Task 4: TUI Entry Point + +**Files:** +- Create: `src/tui.ts` +- Modify: `package.json` + +**Step 1: Create TUI entry point** + +Create `src/tui.ts`: + +```typescript +import { loadConfig } from './config/index.js'; +import { SessionStore, SessionManager } from './session/index.js'; +import { AnthropicClient, OpenAIClient, OllamaClient, ModelRouter } from './models/index.js'; +import { MinimalTui } from './frontends/tui/index.js'; +import type { Config } from './config/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; +import { existsSync, mkdirSync } from 'fs'; + +const CONFIG_PATH = process.env.FLYNN_CONFIG + ?? resolve(homedir(), '.config/flynn/config.yaml'); + +const SYSTEM_PROMPT = `You are Flynn, a helpful personal AI assistant. You are direct, concise, and helpful. You can help with a variety of tasks including answering questions, providing information, and having conversations. + +Keep responses focused and avoid unnecessary verbosity. Use markdown formatting when it improves readability.`; + +function createModelRouter(config: Config): ModelRouter { + const models = config.models; + + const defaultClient = new AnthropicClient({ + model: models.default.model, + }); + + let fastClient; + let complexClient; + let localClient; + + if (models.fast) { + fastClient = new AnthropicClient({ model: models.fast.model }); + } + + if (models.complex) { + complexClient = new AnthropicClient({ model: models.complex.model }); + } + + if (models.local) { + if (models.local.provider === 'ollama') { + localClient = new OllamaClient({ + model: models.local.model, + host: models.local.endpoint, + }); + } + } + + const fallbackChain = []; + for (const providerName of models.fallback_chain) { + if (providerName === 'openai') { + fallbackChain.push(new OpenAIClient({ model: 'gpt-4o' })); + } else if (providerName === 'local' && localClient) { + fallbackChain.push(localClient); + } + } + + return new ModelRouter({ + default: defaultClient, + fast: fastClient, + complex: complexClient, + local: localClient, + fallbackChain, + }); +} + +async function main() { + console.log('Flynn TUI starting...'); + + if (!existsSync(CONFIG_PATH)) { + console.error(`Config file not found: ${CONFIG_PATH}`); + console.error('Copy config/default.yaml to ~/.config/flynn/config.yaml and configure it.'); + process.exit(1); + } + + const config = loadConfig(CONFIG_PATH); + + // Ensure data directory exists + const dataDir = resolve(homedir(), '.local/share/flynn'); + mkdirSync(dataDir, { recursive: true }); + + // Initialize components + const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); + const sessionManager = new SessionManager(sessionStore); + const modelRouter = createModelRouter(config); + + // Get TUI session + const session = sessionManager.getSession('tui', 'local'); + + // Create and start minimal TUI + const tui = new MinimalTui({ + session, + modelClient: modelRouter, + systemPrompt: SYSTEM_PROMPT, + onTransfer: (target) => { + if (target === 'telegram') { + const telegramUserId = String(config.telegram.allowed_chat_ids[0]); + sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId); + console.log(`Session transferred to Telegram (${telegramUserId})\n`); + } else { + console.log(`Unknown transfer target: ${target}\n`); + } + }, + onFullscreen: () => { + console.log('Fullscreen mode not yet implemented.\n'); + }, + }); + + // Handle shutdown + process.on('SIGINT', () => { + tui.stop(); + sessionStore.close(); + process.exit(0); + }); + + await tui.start(); + + // Cleanup + sessionStore.close(); +} + +main().catch((error) => { + console.error('Failed to start TUI:', error); + process.exit(1); +}); +``` + +**Step 2: Add TUI script to package.json** + +Add to scripts section of `package.json`: + +```json +"tui": "tsx src/tui.ts", +"tui:dev": "tsx watch src/tui.ts" +``` + +**Step 3: Run build to verify** + +Run: `pnpm build` + +**Step 4: Commit** + +```bash +git add src/tui.ts package.json +git commit -m "feat: add TUI entry point with minimal readline mode" +``` + +--- + +## Task 5: Fullscreen TUI Components (Ink) + +**Files:** +- Create: `src/frontends/tui/components/App.tsx` +- Create: `src/frontends/tui/components/MessageList.tsx` +- Create: `src/frontends/tui/components/InputBar.tsx` +- Create: `src/frontends/tui/components/StatusBar.tsx` +- Create: `src/frontends/tui/components/index.ts` + +**Step 1: Create StatusBar component** + +Create `src/frontends/tui/components/StatusBar.tsx`: + +```tsx +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} + + + ); +} +``` + +**Step 2: Create MessageList component** + +Create `src/frontends/tui/components/MessageList.tsx`: + +```tsx +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} + + )) + )} + + ); +} +``` + +**Step 3: Create InputBar component** + +Create `src/frontends/tui/components/InputBar.tsx`: + +```tsx +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... + ) : ( + + )} + + ); +} +``` + +**Step 4: Create main App component** + +Create `src/frontends/tui/components/App.tsx`: + +```tsx +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 ( + + + + + + ); +} +``` + +**Step 5: Create components index** + +Create `src/frontends/tui/components/index.ts`: + +```typescript +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'; +``` + +**Step 6: Run build to verify** + +Run: `pnpm build` + +**Step 7: Commit** + +```bash +git add src/frontends/tui/components/ +git commit -m "feat: add Ink-based fullscreen TUI components" +``` + +--- + +## Task 6: Fullscreen TUI Entry + +**Files:** +- Create: `src/frontends/tui/fullscreen.ts` +- Modify: `src/frontends/tui/index.ts` +- Modify: `src/tui.ts` + +**Step 1: Create fullscreen TUI wrapper** + +Create `src/frontends/tui/fullscreen.ts`: + +```typescript +import React from 'react'; +import { render } from 'ink'; +import { App } from './components/index.js'; +import type { ManagedSession } from '../../session/index.js'; +import type { ModelClient } from '../../models/types.js'; + +export interface FullscreenTuiConfig { + session: ManagedSession; + modelClient: ModelClient; + systemPrompt: string; + model: string; + onExit?: () => void; +} + +export async function startFullscreenTui(config: FullscreenTuiConfig): Promise { + const { waitUntilExit } = render( + React.createElement(App, { + session: config.session, + modelClient: config.modelClient, + systemPrompt: config.systemPrompt, + model: config.model, + onExit: config.onExit, + }) + ); + + await waitUntilExit(); +} +``` + +**Step 2: Update TUI index** + +Update `src/frontends/tui/index.ts`: + +```typescript +export { + MinimalTui, + formatPrompt, + parseCommand, + type TuiCommand, + type MinimalTuiConfig, +} from './minimal.js'; + +export { + startFullscreenTui, + type FullscreenTuiConfig, +} from './fullscreen.js'; + +export { App, StatusBar, MessageList, InputBar } from './components/index.js'; +``` + +**Step 3: Update TUI entry point with mode switching** + +Update `src/tui.ts`: + +```typescript +import { loadConfig } from './config/index.js'; +import { SessionStore, SessionManager } from './session/index.js'; +import { AnthropicClient, OpenAIClient, OllamaClient, ModelRouter } from './models/index.js'; +import { MinimalTui, startFullscreenTui } from './frontends/tui/index.js'; +import type { Config } from './config/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; +import { existsSync, mkdirSync } from 'fs'; + +const CONFIG_PATH = process.env.FLYNN_CONFIG + ?? resolve(homedir(), '.config/flynn/config.yaml'); + +const SYSTEM_PROMPT = `You are Flynn, a helpful personal AI assistant. You are direct, concise, and helpful. You can help with a variety of tasks including answering questions, providing information, and having conversations. + +Keep responses focused and avoid unnecessary verbosity. Use markdown formatting when it improves readability.`; + +function createModelRouter(config: Config): ModelRouter { + const models = config.models; + + const defaultClient = new AnthropicClient({ + model: models.default.model, + }); + + let fastClient; + let complexClient; + let localClient; + + if (models.fast) { + fastClient = new AnthropicClient({ model: models.fast.model }); + } + + if (models.complex) { + complexClient = new AnthropicClient({ model: models.complex.model }); + } + + if (models.local) { + if (models.local.provider === 'ollama') { + localClient = new OllamaClient({ + model: models.local.model, + host: models.local.endpoint, + }); + } + } + + const fallbackChain = []; + for (const providerName of models.fallback_chain) { + if (providerName === 'openai') { + fallbackChain.push(new OpenAIClient({ model: 'gpt-4o' })); + } else if (providerName === 'local' && localClient) { + fallbackChain.push(localClient); + } + } + + return new ModelRouter({ + default: defaultClient, + fast: fastClient, + complex: complexClient, + local: localClient, + fallbackChain, + }); +} + +async function main() { + const args = process.argv.slice(2); + const fullscreenMode = args.includes('--fullscreen') || args.includes('-f'); + + console.log('Flynn TUI starting...'); + + if (!existsSync(CONFIG_PATH)) { + console.error(`Config file not found: ${CONFIG_PATH}`); + console.error('Copy config/default.yaml to ~/.config/flynn/config.yaml and configure it.'); + process.exit(1); + } + + const config = loadConfig(CONFIG_PATH); + + // Ensure data directory exists + const dataDir = resolve(homedir(), '.local/share/flynn'); + mkdirSync(dataDir, { recursive: true }); + + // Initialize components + const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); + const sessionManager = new SessionManager(sessionStore); + const modelRouter = createModelRouter(config); + + // Get TUI session + const session = sessionManager.getSession('tui', 'local'); + + const cleanup = () => { + sessionStore.close(); + }; + + process.on('SIGINT', () => { + cleanup(); + process.exit(0); + }); + + if (fullscreenMode) { + // Start fullscreen Ink UI + await startFullscreenTui({ + session, + modelClient: modelRouter, + systemPrompt: SYSTEM_PROMPT, + model: config.models.default.model, + onExit: cleanup, + }); + } else { + // Start minimal readline UI + const tui = new MinimalTui({ + session, + modelClient: modelRouter, + systemPrompt: SYSTEM_PROMPT, + onTransfer: (target) => { + if (target === 'telegram') { + const telegramUserId = String(config.telegram.allowed_chat_ids[0]); + sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId); + console.log(`Session transferred to Telegram (${telegramUserId})\n`); + } else { + console.log(`Unknown transfer target: ${target}\n`); + } + }, + onFullscreen: async () => { + tui.stop(); + console.clear(); + await startFullscreenTui({ + session, + modelClient: modelRouter, + systemPrompt: SYSTEM_PROMPT, + model: config.models.default.model, + onExit: () => { + // Return to minimal mode would require re-init + // For now, just exit + cleanup(); + process.exit(0); + }, + }); + }, + }); + + await tui.start(); + } + + cleanup(); +} + +main().catch((error) => { + console.error('Failed to start TUI:', error); + process.exit(1); +}); +``` + +**Step 4: Update package.json scripts** + +Update scripts in `package.json`: + +```json +"tui": "tsx src/tui.ts", +"tui:fs": "tsx src/tui.ts --fullscreen" +``` + +**Step 5: Run build to verify** + +Run: `pnpm build` + +**Step 6: Commit** + +```bash +git add src/frontends/tui/fullscreen.ts src/frontends/tui/index.ts src/tui.ts package.json +git commit -m "feat: add fullscreen TUI mode with Ink React components" +``` + +--- + +## Task 7: Integrate SessionManager into Daemon + +**Files:** +- Modify: `src/daemon/index.ts` +- Modify: `src/backends/native/agent.ts` + +**Step 1: Update NativeAgent to use ManagedSession** + +Modify `src/backends/native/agent.ts`: + +```typescript +import type { ModelClient, Message } from '../../models/types.js'; +import type { Session } from '../../session/index.js'; + +export interface NativeAgentConfig { + modelClient: ModelClient; + systemPrompt: string; + session?: Session; +} + +export class NativeAgent { + private modelClient: ModelClient; + private systemPrompt: string; + private session?: Session; + private inMemoryHistory: Message[] = []; + + constructor(config: NativeAgentConfig) { + this.modelClient = config.modelClient; + this.systemPrompt = config.systemPrompt; + this.session = config.session; + } + + private get history(): Message[] { + return this.session?.getHistory() ?? this.inMemoryHistory; + } + + async process(userMessage: string): Promise { + const userMsg: Message = { role: 'user', content: userMessage }; + + if (this.session) { + this.session.addMessage(userMsg); + } else { + this.inMemoryHistory.push(userMsg); + } + + const response = await this.modelClient.chat({ + messages: this.history, + system: this.systemPrompt, + }); + + const assistantMsg: Message = { role: 'assistant', content: response.content }; + + if (this.session) { + this.session.addMessage(assistantMsg); + } else { + this.inMemoryHistory.push(assistantMsg); + } + + return response.content; + } + + reset(): void { + if (this.session) { + this.session.clear(); + } else { + this.inMemoryHistory = []; + } + } + + getHistory(): Message[] { + return [...this.history]; + } +} +``` + +**Step 2: Update daemon to use SessionManager** + +Modify `src/daemon/index.ts` - update the relevant imports and sections: + +```typescript +import { Bot } from 'grammy'; +import { Lifecycle } from './lifecycle.js'; +import type { Config } from '../config/index.js'; +import { AnthropicClient, OpenAIClient, OllamaClient, ModelRouter } from '../models/index.js'; +import { NativeAgent } from '../backends/index.js'; +import { createTelegramBot } from '../frontends/telegram/index.js'; +import { SessionStore, SessionManager } from '../session/index.js'; +import { HookEngine } from '../hooks/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; +import { mkdirSync } from 'fs'; + +export interface DaemonContext { + config: Config; + lifecycle: Lifecycle; + bot: Bot; + agent: NativeAgent; + sessionStore: SessionStore; + sessionManager: SessionManager; + hookEngine: HookEngine; + modelRouter: ModelRouter; +} + +// ... (keep SYSTEM_PROMPT and createModelRouter unchanged) + +export async function startDaemon(config: Config): Promise { + const lifecycle = new Lifecycle(); + + // Ensure data directory exists + const dataDir = resolve(homedir(), '.local/share/flynn'); + mkdirSync(dataDir, { recursive: true }); + + // Initialize session store and manager + const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); + const sessionManager = new SessionManager(sessionStore); + + lifecycle.onShutdown(async () => { + sessionStore.close(); + console.log('Session store closed'); + }); + + // Initialize hook engine + const hookEngine = new HookEngine(config.hooks); + + // Initialize model router + const modelRouter = createModelRouter(config); + + // Get Telegram session + const telegramUserId = String(config.telegram.allowed_chat_ids[0]); + const session = sessionManager.getSession('telegram', telegramUserId); + + // Initialize native agent with session + const agent = new NativeAgent({ + modelClient: modelRouter, + systemPrompt: SYSTEM_PROMPT, + session, + }); + + // Initialize Telegram bot with hook engine + const bot = createTelegramBot({ + telegram: config.telegram, + agent, + hookEngine, + }); + + // ... (keep signal handlers and bot start unchanged) + + console.log('Flynn daemon started'); + + return { config, lifecycle, bot, agent, sessionStore, sessionManager, hookEngine, modelRouter }; +} + +export { Lifecycle } from './lifecycle.js'; +``` + +**Step 3: Update agent test** + +Update `src/backends/native/agent.test.ts` to work with new interface: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NativeAgent } from './agent.js'; +import type { ModelClient, ChatResponse } from '../../models/types.js'; + +describe('NativeAgent', () => { + const createMockClient = (): ModelClient => ({ + chat: vi.fn().mockResolvedValue({ + content: 'Hello!', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5 }, + } satisfies ChatResponse), + }); + + it('processes messages and maintains history', async () => { + const mockClient = createMockClient(); + const agent = new NativeAgent({ + modelClient: mockClient, + systemPrompt: 'You are helpful.', + }); + + const response = await agent.process('Hi'); + + expect(response).toBe('Hello!'); + expect(mockClient.chat).toHaveBeenCalledWith({ + messages: [{ role: 'user', content: 'Hi' }], + system: 'You are helpful.', + }); + + const history = agent.getHistory(); + expect(history).toHaveLength(2); + expect(history[0]).toEqual({ role: 'user', content: 'Hi' }); + expect(history[1]).toEqual({ role: 'assistant', content: 'Hello!' }); + }); + + it('resets conversation history', async () => { + const mockClient = createMockClient(); + const agent = new NativeAgent({ + modelClient: mockClient, + systemPrompt: 'You are helpful.', + }); + + await agent.process('Hi'); + agent.reset(); + + expect(agent.getHistory()).toHaveLength(0); + }); + + it('uses session when provided', async () => { + const mockClient = createMockClient(); + const mockSession = { + id: 'test-session', + getHistory: vi.fn().mockReturnValue([]), + addMessage: vi.fn(), + clear: vi.fn(), + }; + + const agent = new NativeAgent({ + modelClient: mockClient, + systemPrompt: 'You are helpful.', + session: mockSession, + }); + + await agent.process('Hi'); + + expect(mockSession.addMessage).toHaveBeenCalledTimes(2); + expect(mockSession.addMessage).toHaveBeenNthCalledWith(1, { role: 'user', content: 'Hi' }); + expect(mockSession.addMessage).toHaveBeenNthCalledWith(2, { role: 'assistant', content: 'Hello!' }); + }); +}); +``` + +**Step 4: Run all tests** + +Run: `pnpm test:run` + +**Step 5: Run build to verify** + +Run: `pnpm build` + +**Step 6: Commit** + +```bash +git add src/daemon/index.ts src/backends/native/agent.ts src/backends/native/agent.test.ts +git commit -m "refactor: integrate SessionManager into daemon and agent" +``` + +--- + +## Verification Checklist + +After completing all tasks, verify: + +1. [ ] `pnpm build` succeeds with no errors +2. [ ] `pnpm test:run` passes all tests +3. [ ] `pnpm tui` starts minimal readline mode +4. [ ] `pnpm tui --fullscreen` or `pnpm tui:fs` starts Ink fullscreen mode +5. [ ] Messages persist between TUI sessions +6. [ ] `/transfer telegram` transfers session to Telegram +7. [ ] `/fullscreen` switches from minimal to fullscreen mode +8. [ ] Esc key exits fullscreen mode + +## Manual Testing Steps + +1. Start TUI in minimal mode: + ```bash + pnpm tui + ``` + +2. Send a few messages, verify responses + +3. Type `/status` - verify session info shown + +4. Type `/fullscreen` - verify switch to Ink UI + +5. Press Esc to exit fullscreen + +6. Start TUI again - verify messages persisted + +7. Type `/transfer telegram` - verify session transferred + +8. Start daemon (`pnpm dev`) and check Telegram for transferred messages + +9. Test fullscreen mode directly: + ```bash + pnpm tui:fs + ```