Files
flynn/docs/plans/2026-02-05-flynn-phase3-implementation.md
T

1449 lines
36 KiB
Markdown

# Flynn Phase 3 Implementation Plan
> **Archived (2026-02-18):** Historical implementation checklist. Canonical status is tracked in `docs/plans/state.json`; unchecked boxes here are not active backlog unless explicitly re-opened.
> **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<string, ManagedSession> = 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<void> {
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<void> {
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<string> {
return new Promise((resolve) => {
this.rl?.question(promptText, resolve);
});
}
private async handleCommand(command: TuiCommand): Promise<void> {
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<void> {
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 <dest> 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 (
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Box flexGrow={1}>
<Text color="cyan">Flynn</Text>
<Text color="gray"> | </Text>
<Text color="gray">Session: </Text>
<Text>{sessionId}</Text>
</Box>
<Box>
<Text color="gray">Messages: </Text>
<Text>{messageCount}</Text>
<Text color="gray"> | </Text>
<Text color="gray">Model: </Text>
<Text color="green">{model}</Text>
</Box>
</Box>
);
}
```
**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 (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{visibleMessages.length === 0 ? (
<Text color="gray">No messages yet. Start typing to chat with Flynn.</Text>
) : (
visibleMessages.map((message, index) => (
<Box key={index} marginBottom={1}>
<Text color={message.role === 'user' ? 'blue' : 'green'}>
{message.role === 'user' ? 'You: ' : 'Flynn: '}
</Text>
<Text wrap="wrap">{message.content}</Text>
</Box>
))
)}
</Box>
);
}
```
**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 (
<Box borderStyle="single" borderColor="blue" paddingX={1}>
<Text color="blue">{'> '}</Text>
{isLoading ? (
<Text color="gray">Thinking...</Text>
) : (
<TextInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
placeholder={placeholder}
/>
)}
</Box>
);
}
```
**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<Message[]>(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 (
<Box flexDirection="column" height="100%">
<StatusBar
sessionId={session.id}
messageCount={messages.length}
model={model}
/>
<MessageList messages={messages} />
<InputBar
value={input}
onChange={setInput}
onSubmit={handleSubmit}
isLoading={isLoading}
placeholder="Type a message... (Esc to exit)"
/>
</Box>
);
}
```
**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<void> {
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<string> {
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<DaemonContext> {
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
```