36 KiB
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:
"ink": "^6.0.0",
"ink-text-input": "^6.0.0",
"react": "^19.0.0"
Add to devDependencies:
"@types/react": "^19.0.0"
Step 2: Install dependencies
Run: pnpm install
Step 3: Commit
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:
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:
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:
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
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:
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:
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:
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
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:
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:
"tui": "tsx src/tui.ts",
"tui:dev": "tsx watch src/tui.ts"
Step 3: Run build to verify
Run: pnpm build
Step 4: Commit
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:
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:
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:
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:
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:
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
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:
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:
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:
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:
"tui": "tsx src/tui.ts",
"tui:fs": "tsx src/tui.ts --fullscreen"
Step 5: Run build to verify
Run: pnpm build
Step 6: Commit
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:
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:
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:
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
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:
pnpm buildsucceeds with no errorspnpm test:runpasses all testspnpm tuistarts minimal readline modepnpm tui --fullscreenorpnpm tui:fsstarts Ink fullscreen mode- Messages persist between TUI sessions
/transfer telegramtransfers session to Telegram/fullscreenswitches from minimal to fullscreen mode- Esc key exits fullscreen mode
Manual Testing Steps
-
Start TUI in minimal mode:
pnpm tui -
Send a few messages, verify responses
-
Type
/status- verify session info shown -
Type
/fullscreen- verify switch to Ink UI -
Press Esc to exit fullscreen
-
Start TUI again - verify messages persisted
-
Type
/transfer telegram- verify session transferred -
Start daemon (
pnpm dev) and check Telegram for transferred messages -
Test fullscreen mode directly:
pnpm tui:fs