1449 lines
36 KiB
Markdown
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
|
|
```
|
|
|
|
|
|
|