Files
flynn/docs/plans/2026-02-05-flynn-phase3-implementation.md
T
William Valentin 934be021ab 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 <noreply@anthropic.com>
2026-02-05 00:31:28 -08:00

36 KiB

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:

"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:

  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:

    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:

    pnpm tui:fs