1835 lines
49 KiB
Markdown
1835 lines
49 KiB
Markdown
# TUI Redesign Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Add streaming responses, markdown rendering, model switching, and scrollable message history to Flynn TUI.
|
|
|
|
**Architecture:** Extend ModelClient interface with optional streaming method. Create shared utilities for markdown rendering and command parsing. Update both minimal and fullscreen modes to use streaming with markdown post-processing.
|
|
|
|
**Tech Stack:** TypeScript, Vitest, Ink (React), marked, marked-terminal, cli-highlight
|
|
|
|
---
|
|
|
|
## Task 1: Add Streaming Types
|
|
|
|
**Files:**
|
|
- Modify: `src/models/types.ts`
|
|
- Test: `src/models/types.test.ts` (create)
|
|
|
|
**Step 1: Write the type definitions**
|
|
|
|
Edit `src/models/types.ts` to add streaming types:
|
|
|
|
```typescript
|
|
export interface Message {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
export interface ChatRequest {
|
|
messages: Message[];
|
|
system?: string;
|
|
maxTokens?: number;
|
|
}
|
|
|
|
export interface ChatResponse {
|
|
content: string;
|
|
stopReason: 'end_turn' | 'max_tokens' | 'stop_sequence' | string;
|
|
usage: TokenUsage;
|
|
}
|
|
|
|
export interface TokenUsage {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
}
|
|
|
|
export interface ChatStreamEvent {
|
|
type: 'content' | 'done' | 'error';
|
|
content?: string;
|
|
usage?: TokenUsage;
|
|
error?: Error;
|
|
}
|
|
|
|
export interface StreamingModelClient {
|
|
chatStream(request: ChatRequest): AsyncIterable<ChatStreamEvent>;
|
|
}
|
|
|
|
export interface ModelClient {
|
|
chat(request: ChatRequest): Promise<ChatResponse>;
|
|
chatStream?(request: ChatRequest): AsyncIterable<ChatStreamEvent>;
|
|
}
|
|
```
|
|
|
|
**Step 2: Run build to verify types compile**
|
|
|
|
Run: `pnpm build`
|
|
Expected: Success, no type errors
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/models/types.ts
|
|
git commit -m "feat(models): add streaming types for chat responses"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Implement AnthropicClient Streaming
|
|
|
|
**Files:**
|
|
- Modify: `src/models/anthropic.ts`
|
|
- Test: `src/models/anthropic.test.ts`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `src/models/anthropic.test.ts`:
|
|
|
|
```typescript
|
|
describe('AnthropicClient streaming', () => {
|
|
it('streams messages chunk by chunk', async () => {
|
|
const client = new AnthropicClient({
|
|
apiKey: 'test-key',
|
|
model: 'claude-sonnet-4-20250514',
|
|
});
|
|
|
|
const chunks: string[] = [];
|
|
let finalUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
|
|
for await (const event of client.chatStream({
|
|
messages: [{ role: 'user', content: 'Hello' }],
|
|
})) {
|
|
if (event.type === 'content' && event.content) {
|
|
chunks.push(event.content);
|
|
}
|
|
if (event.type === 'done' && event.usage) {
|
|
finalUsage = event.usage;
|
|
}
|
|
}
|
|
|
|
expect(chunks.length).toBeGreaterThan(0);
|
|
expect(chunks.join('')).toBe('Hello from Claude!');
|
|
expect(finalUsage).toEqual({ inputTokens: 10, outputTokens: 5 });
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Update mock to support streaming**
|
|
|
|
Update the mock at the top of `src/models/anthropic.test.ts`:
|
|
|
|
```typescript
|
|
vi.mock('@anthropic-ai/sdk', () => ({
|
|
default: vi.fn().mockImplementation(() => ({
|
|
messages: {
|
|
create: vi.fn().mockResolvedValue({
|
|
content: [{ type: 'text', text: 'Hello from Claude!' }],
|
|
stop_reason: 'end_turn',
|
|
usage: { input_tokens: 10, output_tokens: 5 },
|
|
}),
|
|
stream: vi.fn().mockReturnValue({
|
|
[Symbol.asyncIterator]: async function* () {
|
|
yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello ' } };
|
|
yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'from ' } };
|
|
yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Claude!' } };
|
|
},
|
|
finalMessage: vi.fn().mockResolvedValue({
|
|
usage: { input_tokens: 10, output_tokens: 5 },
|
|
}),
|
|
}),
|
|
},
|
|
})),
|
|
}));
|
|
```
|
|
|
|
**Step 3: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/models/anthropic.test.ts`
|
|
Expected: FAIL - chatStream is not a function
|
|
|
|
**Step 4: Implement chatStream method**
|
|
|
|
Update `src/models/anthropic.ts`:
|
|
|
|
```typescript
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js';
|
|
|
|
export interface AnthropicClientConfig {
|
|
apiKey?: string;
|
|
authToken?: string;
|
|
model: string;
|
|
maxTokens?: number;
|
|
}
|
|
|
|
export class AnthropicClient implements ModelClient {
|
|
private client: Anthropic;
|
|
private model: string;
|
|
private defaultMaxTokens: number;
|
|
|
|
constructor(config: AnthropicClientConfig) {
|
|
this.client = new Anthropic({
|
|
apiKey: config.apiKey,
|
|
authToken: config.authToken,
|
|
});
|
|
this.model = config.model;
|
|
this.defaultMaxTokens = config.maxTokens ?? 4096;
|
|
}
|
|
|
|
async chat(request: ChatRequest): Promise<ChatResponse> {
|
|
const response = await this.client.messages.create({
|
|
model: this.model,
|
|
max_tokens: request.maxTokens ?? this.defaultMaxTokens,
|
|
system: request.system,
|
|
messages: request.messages.map((m) => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
})),
|
|
});
|
|
|
|
const textContent = response.content.find((c) => c.type === 'text');
|
|
const content = textContent?.type === 'text' ? textContent.text : '';
|
|
|
|
return {
|
|
content,
|
|
stopReason: response.stop_reason ?? 'end_turn',
|
|
usage: {
|
|
inputTokens: response.usage.input_tokens,
|
|
outputTokens: response.usage.output_tokens,
|
|
},
|
|
};
|
|
}
|
|
|
|
async *chatStream(request: ChatRequest): AsyncIterable<ChatStreamEvent> {
|
|
const stream = this.client.messages.stream({
|
|
model: this.model,
|
|
max_tokens: request.maxTokens ?? this.defaultMaxTokens,
|
|
system: request.system,
|
|
messages: request.messages.map((m) => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
})),
|
|
});
|
|
|
|
try {
|
|
for await (const event of stream) {
|
|
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
|
|
yield { type: 'content', content: event.delta.text };
|
|
}
|
|
}
|
|
|
|
const finalMessage = await stream.finalMessage();
|
|
yield {
|
|
type: 'done',
|
|
usage: {
|
|
inputTokens: finalMessage.usage.input_tokens,
|
|
outputTokens: finalMessage.usage.output_tokens,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
yield {
|
|
type: 'error',
|
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/models/anthropic.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/models/anthropic.ts src/models/anthropic.test.ts
|
|
git commit -m "feat(models): add streaming support to AnthropicClient"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Add Streaming to ModelRouter
|
|
|
|
**Files:**
|
|
- Modify: `src/models/router.ts`
|
|
- Test: `src/models/router.test.ts`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `src/models/router.test.ts`:
|
|
|
|
```typescript
|
|
describe('ModelRouter streaming', () => {
|
|
it('streams from primary client', async () => {
|
|
const mockStream = async function* (): AsyncIterable<ChatStreamEvent> {
|
|
yield { type: 'content', content: 'Hello' };
|
|
yield { type: 'done', usage: { inputTokens: 5, outputTokens: 3 } };
|
|
};
|
|
|
|
const mockClient = {
|
|
chat: vi.fn(),
|
|
chatStream: vi.fn().mockReturnValue(mockStream()),
|
|
};
|
|
|
|
const router = new ModelRouter({
|
|
default: mockClient,
|
|
fallbackChain: [],
|
|
});
|
|
|
|
const chunks: string[] = [];
|
|
for await (const event of router.chatStream({ messages: [] })) {
|
|
if (event.type === 'content' && event.content) {
|
|
chunks.push(event.content);
|
|
}
|
|
}
|
|
|
|
expect(chunks).toEqual(['Hello']);
|
|
});
|
|
|
|
it('falls back when primary stream fails', async () => {
|
|
const failingStream = async function* (): AsyncIterable<ChatStreamEvent> {
|
|
yield { type: 'error', error: new Error('Primary failed') };
|
|
};
|
|
|
|
const fallbackStream = async function* (): AsyncIterable<ChatStreamEvent> {
|
|
yield { type: 'content', content: 'Fallback' };
|
|
yield { type: 'done', usage: { inputTokens: 5, outputTokens: 3 } };
|
|
};
|
|
|
|
const primaryClient = {
|
|
chat: vi.fn(),
|
|
chatStream: vi.fn().mockReturnValue(failingStream()),
|
|
};
|
|
|
|
const fallbackClient = {
|
|
chat: vi.fn(),
|
|
chatStream: vi.fn().mockReturnValue(fallbackStream()),
|
|
};
|
|
|
|
const router = new ModelRouter({
|
|
default: primaryClient,
|
|
fallbackChain: [fallbackClient],
|
|
});
|
|
|
|
const chunks: string[] = [];
|
|
for await (const event of router.chatStream({ messages: [] })) {
|
|
if (event.type === 'content' && event.content) {
|
|
chunks.push(event.content);
|
|
}
|
|
}
|
|
|
|
expect(chunks).toEqual(['Fallback']);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Add import for ChatStreamEvent**
|
|
|
|
At top of `src/models/router.test.ts`, update import:
|
|
|
|
```typescript
|
|
import type { ChatStreamEvent } from './types.js';
|
|
```
|
|
|
|
**Step 3: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/models/router.test.ts`
|
|
Expected: FAIL - chatStream is not a function
|
|
|
|
**Step 4: Implement chatStream in ModelRouter**
|
|
|
|
Update `src/models/router.ts`:
|
|
|
|
```typescript
|
|
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js';
|
|
|
|
export type ModelTier = 'fast' | 'default' | 'complex' | 'local';
|
|
|
|
export interface ModelRouterConfig {
|
|
default: ModelClient;
|
|
fast?: ModelClient;
|
|
complex?: ModelClient;
|
|
local?: ModelClient;
|
|
fallbackChain: ModelClient[];
|
|
}
|
|
|
|
export class ModelRouter implements ModelClient {
|
|
private clients: Map<ModelTier, ModelClient>;
|
|
private defaultClient: ModelClient;
|
|
private fallbackChain: ModelClient[];
|
|
private currentTier: ModelTier = 'default';
|
|
|
|
constructor(config: ModelRouterConfig) {
|
|
this.clients = new Map();
|
|
this.defaultClient = config.default;
|
|
this.fallbackChain = config.fallbackChain;
|
|
|
|
this.clients.set('default', config.default);
|
|
if (config.fast) this.clients.set('fast', config.fast);
|
|
if (config.complex) this.clients.set('complex', config.complex);
|
|
if (config.local) this.clients.set('local', config.local);
|
|
}
|
|
|
|
setTier(tier: ModelTier): boolean {
|
|
if (this.clients.has(tier)) {
|
|
this.currentTier = tier;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
getTier(): ModelTier {
|
|
return this.currentTier;
|
|
}
|
|
|
|
getAvailableTiers(): ModelTier[] {
|
|
return Array.from(this.clients.keys());
|
|
}
|
|
|
|
async chat(request: ChatRequest, tier?: ModelTier): Promise<ChatResponse> {
|
|
const useTier = tier ?? this.currentTier;
|
|
const primaryClient = this.clients.get(useTier) ?? this.defaultClient;
|
|
const errors: Error[] = [];
|
|
|
|
try {
|
|
return await primaryClient.chat(request);
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
console.warn(`Primary model failed: ${errors[0].message}`);
|
|
}
|
|
|
|
for (const fallbackClient of this.fallbackChain) {
|
|
try {
|
|
console.log('Trying fallback model...');
|
|
return await fallbackClient.chat(request);
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
console.warn(`Fallback model failed: ${errors[errors.length - 1].message}`);
|
|
}
|
|
}
|
|
|
|
throw new Error(`All model providers failed: ${errors.map(e => e.message).join(', ')}`);
|
|
}
|
|
|
|
async *chatStream(request: ChatRequest, tier?: ModelTier): AsyncIterable<ChatStreamEvent> {
|
|
const useTier = tier ?? this.currentTier;
|
|
const primaryClient = this.clients.get(useTier) ?? this.defaultClient;
|
|
|
|
if (primaryClient.chatStream) {
|
|
let hasError = false;
|
|
for await (const event of primaryClient.chatStream(request)) {
|
|
if (event.type === 'error') {
|
|
hasError = true;
|
|
console.warn(`Primary stream failed: ${event.error?.message}`);
|
|
break;
|
|
}
|
|
yield event;
|
|
}
|
|
|
|
if (!hasError) return;
|
|
}
|
|
|
|
// Try fallback chain
|
|
for (const fallbackClient of this.fallbackChain) {
|
|
if (!fallbackClient.chatStream) continue;
|
|
|
|
let hasError = false;
|
|
for await (const event of fallbackClient.chatStream(request)) {
|
|
if (event.type === 'error') {
|
|
hasError = true;
|
|
console.warn(`Fallback stream failed: ${event.error?.message}`);
|
|
break;
|
|
}
|
|
yield event;
|
|
}
|
|
|
|
if (!hasError) return;
|
|
}
|
|
|
|
yield { type: 'error', error: new Error('All streaming providers failed') };
|
|
}
|
|
|
|
getClient(tier: ModelTier): ModelClient | undefined {
|
|
return this.clients.get(tier);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/models/router.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/models/router.ts src/models/router.test.ts
|
|
git commit -m "feat(models): add streaming and tier switching to ModelRouter"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Install Markdown Dependencies
|
|
|
|
**Files:**
|
|
- Modify: `package.json`
|
|
|
|
**Step 1: Install dependencies**
|
|
|
|
Run: `pnpm add marked marked-terminal cli-highlight`
|
|
|
|
**Step 2: Install types**
|
|
|
|
Run: `pnpm add -D @types/marked-terminal`
|
|
|
|
**Step 3: Verify installation**
|
|
|
|
Run: `pnpm build`
|
|
Expected: Success
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add package.json pnpm-lock.yaml
|
|
git commit -m "chore: add markdown rendering dependencies"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Create Markdown Rendering Utility
|
|
|
|
**Files:**
|
|
- Create: `src/frontends/tui/markdown.ts`
|
|
- Create: `src/frontends/tui/markdown.test.ts`
|
|
|
|
**Step 1: Write the failing tests**
|
|
|
|
Create `src/frontends/tui/markdown.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { renderMarkdown } from './markdown.js';
|
|
|
|
describe('renderMarkdown', () => {
|
|
it('renders plain text unchanged', () => {
|
|
const result = renderMarkdown('Hello world');
|
|
expect(result).toContain('Hello world');
|
|
});
|
|
|
|
it('renders bold text', () => {
|
|
const result = renderMarkdown('This is **bold** text');
|
|
expect(result).toContain('bold');
|
|
});
|
|
|
|
it('renders code blocks', () => {
|
|
const result = renderMarkdown('```javascript\nconst x = 1;\n```');
|
|
expect(result).toContain('const');
|
|
expect(result).toContain('x');
|
|
});
|
|
|
|
it('renders inline code', () => {
|
|
const result = renderMarkdown('Use `console.log()` for debugging');
|
|
expect(result).toContain('console.log()');
|
|
});
|
|
|
|
it('renders lists', () => {
|
|
const result = renderMarkdown('- Item 1\n- Item 2');
|
|
expect(result).toContain('Item 1');
|
|
expect(result).toContain('Item 2');
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/frontends/tui/markdown.test.ts`
|
|
Expected: FAIL - Cannot find module
|
|
|
|
**Step 3: Implement markdown utility**
|
|
|
|
Create `src/frontends/tui/markdown.ts`:
|
|
|
|
```typescript
|
|
import { marked } from 'marked';
|
|
import TerminalRenderer from 'marked-terminal';
|
|
import { highlight } from 'cli-highlight';
|
|
|
|
// Configure marked with terminal renderer
|
|
marked.use({
|
|
renderer: new TerminalRenderer({
|
|
code: (code: string, language?: string) => {
|
|
try {
|
|
return highlight(code, { language: language || 'plaintext' });
|
|
} catch {
|
|
return code;
|
|
}
|
|
},
|
|
codespan: (text: string) => `\x1b[36m${text}\x1b[0m`, // Cyan for inline code
|
|
strong: (text: string) => `\x1b[1m${text}\x1b[0m`, // Bold
|
|
em: (text: string) => `\x1b[3m${text}\x1b[0m`, // Italic
|
|
}),
|
|
});
|
|
|
|
export function renderMarkdown(text: string): string {
|
|
try {
|
|
const rendered = marked.parse(text);
|
|
// marked.parse can return string | Promise<string>, we only use sync
|
|
if (typeof rendered === 'string') {
|
|
return rendered.trim();
|
|
}
|
|
return text;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/frontends/tui/markdown.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/frontends/tui/markdown.ts src/frontends/tui/markdown.test.ts
|
|
git commit -m "feat(tui): add markdown rendering utility"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Create Unified Command Parser
|
|
|
|
**Files:**
|
|
- Create: `src/frontends/tui/commands.ts`
|
|
- Create: `src/frontends/tui/commands.test.ts`
|
|
|
|
**Step 1: Write the failing tests**
|
|
|
|
Create `src/frontends/tui/commands.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { parseCommand, getHelpText } from './commands.js';
|
|
|
|
describe('parseCommand', () => {
|
|
it('parses /quit command', () => {
|
|
expect(parseCommand('/quit')).toEqual({ type: 'quit' });
|
|
expect(parseCommand('/exit')).toEqual({ type: 'quit' });
|
|
});
|
|
|
|
it('parses /reset command', () => {
|
|
expect(parseCommand('/reset')).toEqual({ type: 'reset' });
|
|
expect(parseCommand('/clear')).toEqual({ type: 'reset' });
|
|
});
|
|
|
|
it('parses /help command', () => {
|
|
expect(parseCommand('/help')).toEqual({ type: 'help' });
|
|
expect(parseCommand('/?')).toEqual({ type: 'help' });
|
|
});
|
|
|
|
it('parses /status command', () => {
|
|
expect(parseCommand('/status')).toEqual({ type: 'status' });
|
|
});
|
|
|
|
it('parses /fullscreen command', () => {
|
|
expect(parseCommand('/fullscreen')).toEqual({ type: 'fullscreen' });
|
|
expect(parseCommand('/fs')).toEqual({ type: 'fullscreen' });
|
|
});
|
|
|
|
it('parses /model command without argument', () => {
|
|
expect(parseCommand('/model')).toEqual({ type: 'model' });
|
|
});
|
|
|
|
it('parses /model command with argument', () => {
|
|
expect(parseCommand('/model local')).toEqual({ type: 'model', name: 'local' });
|
|
expect(parseCommand('/model opus')).toEqual({ type: 'model', name: 'opus' });
|
|
});
|
|
|
|
it('parses /transfer command', () => {
|
|
expect(parseCommand('/transfer telegram')).toEqual({ type: 'transfer', target: 'telegram' });
|
|
});
|
|
|
|
it('parses regular message', () => {
|
|
expect(parseCommand('Hello Flynn')).toEqual({ type: 'message', content: 'Hello Flynn' });
|
|
});
|
|
|
|
it('returns null for empty input', () => {
|
|
expect(parseCommand('')).toBeNull();
|
|
expect(parseCommand(' ')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getHelpText', () => {
|
|
it('returns help text with all commands', () => {
|
|
const help = getHelpText();
|
|
expect(help).toContain('/help');
|
|
expect(help).toContain('/model');
|
|
expect(help).toContain('/reset');
|
|
expect(help).toContain('/quit');
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/frontends/tui/commands.test.ts`
|
|
Expected: FAIL - Cannot find module
|
|
|
|
**Step 3: Implement commands utility**
|
|
|
|
Create `src/frontends/tui/commands.ts`:
|
|
|
|
```typescript
|
|
export type Command =
|
|
| { type: 'quit' }
|
|
| { type: 'reset' }
|
|
| { type: 'help' }
|
|
| { type: 'status' }
|
|
| { type: 'fullscreen' }
|
|
| { type: 'model'; name?: string }
|
|
| { type: 'transfer'; target: string }
|
|
| { type: 'message'; content: string };
|
|
|
|
export function parseCommand(input: string): Command | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
|
|
// Quit
|
|
if (trimmed === '/quit' || trimmed === '/exit') {
|
|
return { type: 'quit' };
|
|
}
|
|
|
|
// Reset
|
|
if (trimmed === '/reset' || trimmed === '/clear') {
|
|
return { type: 'reset' };
|
|
}
|
|
|
|
// Help
|
|
if (trimmed === '/help' || trimmed === '/?') {
|
|
return { type: 'help' };
|
|
}
|
|
|
|
// Status
|
|
if (trimmed === '/status') {
|
|
return { type: 'status' };
|
|
}
|
|
|
|
// Fullscreen
|
|
if (trimmed === '/fullscreen' || trimmed === '/fs') {
|
|
return { type: 'fullscreen' };
|
|
}
|
|
|
|
// Model (with optional argument)
|
|
if (trimmed === '/model') {
|
|
return { type: 'model' };
|
|
}
|
|
if (trimmed.startsWith('/model ')) {
|
|
const name = trimmed.slice('/model '.length).trim();
|
|
return { type: 'model', name };
|
|
}
|
|
|
|
// Transfer
|
|
if (trimmed.startsWith('/transfer ')) {
|
|
const target = trimmed.slice('/transfer '.length).trim();
|
|
return { type: 'transfer', target };
|
|
}
|
|
|
|
// Regular message
|
|
return { type: 'message', content: trimmed };
|
|
}
|
|
|
|
export function getHelpText(): string {
|
|
return `
|
|
Commands:
|
|
/help, /? Show this help
|
|
/model [name] Show or switch model (local, default, fast, complex)
|
|
/reset, /clear Clear conversation history
|
|
/status Show session info and token usage
|
|
/fullscreen, /fs Switch to fullscreen mode
|
|
/transfer <dest> Transfer session to another frontend
|
|
/quit, /exit Exit TUI
|
|
`.trim();
|
|
}
|
|
|
|
export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'sonnet' | 'ollama';
|
|
|
|
export function resolveModelAlias(alias: string): 'local' | 'default' | 'fast' | 'complex' {
|
|
const map: Record<string, 'local' | 'default' | 'fast' | 'complex'> = {
|
|
local: 'local',
|
|
ollama: 'local',
|
|
default: 'default',
|
|
opus: 'default',
|
|
fast: 'fast',
|
|
sonnet: 'fast',
|
|
complex: 'complex',
|
|
};
|
|
return map[alias.toLowerCase()] ?? 'default';
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/frontends/tui/commands.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/frontends/tui/commands.ts src/frontends/tui/commands.test.ts
|
|
git commit -m "feat(tui): add unified command parser with model switching"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Update Minimal TUI with Streaming
|
|
|
|
**Files:**
|
|
- Modify: `src/frontends/tui/minimal.ts`
|
|
- Modify: `src/frontends/tui/minimal.test.ts`
|
|
|
|
**Step 1: Update minimal.ts imports and types**
|
|
|
|
Update `src/frontends/tui/minimal.ts` with the new implementation:
|
|
|
|
```typescript
|
|
import * as readline from 'node:readline';
|
|
import type { ManagedSession } from '../../session/index.js';
|
|
import type { ModelClient, TokenUsage } from '../../models/types.js';
|
|
import type { ModelRouter, ModelTier } from '../../models/router.js';
|
|
import { parseCommand, getHelpText, resolveModelAlias, type Command } from './commands.js';
|
|
import { renderMarkdown } from './markdown.js';
|
|
|
|
export { parseCommand, type Command };
|
|
|
|
export function formatPrompt(state: 'default' | 'thinking'): string {
|
|
if (state === 'thinking') {
|
|
return 'flynn... ';
|
|
}
|
|
return 'flynn> ';
|
|
}
|
|
|
|
export interface MinimalTuiConfig {
|
|
session: ManagedSession;
|
|
modelClient: ModelClient;
|
|
modelRouter?: ModelRouter;
|
|
systemPrompt: string;
|
|
onFullscreen?: () => void;
|
|
onTransfer?: (target: string) => void;
|
|
}
|
|
|
|
export class MinimalTui {
|
|
private rl: readline.Interface | null = null;
|
|
private running = false;
|
|
private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
|
|
|
|
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) => {
|
|
if (!this.rl) {
|
|
resolve('');
|
|
return;
|
|
}
|
|
this.rl.question(promptText, resolve);
|
|
this.rl.once('close', () => resolve(''));
|
|
});
|
|
}
|
|
|
|
private async handleCommand(command: Command): Promise<void> {
|
|
switch (command.type) {
|
|
case 'quit':
|
|
this.stop();
|
|
break;
|
|
|
|
case 'reset':
|
|
this.config.session.clear();
|
|
this.totalUsage = { inputTokens: 0, outputTokens: 0 };
|
|
console.log('Session cleared.\n');
|
|
break;
|
|
|
|
case 'help':
|
|
console.log(getHelpText() + '\n');
|
|
break;
|
|
|
|
case 'status':
|
|
this.printStatus();
|
|
break;
|
|
|
|
case 'fullscreen':
|
|
this.config.onFullscreen?.();
|
|
break;
|
|
|
|
case 'model':
|
|
this.handleModelCommand(command.name);
|
|
break;
|
|
|
|
case 'transfer':
|
|
this.config.onTransfer?.(command.target);
|
|
break;
|
|
|
|
case 'message':
|
|
await this.handleMessage(command.content);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private handleModelCommand(name?: string): void {
|
|
const router = this.config.modelRouter;
|
|
if (!router) {
|
|
console.log('Model switching not available.\n');
|
|
return;
|
|
}
|
|
|
|
if (!name) {
|
|
const current = router.getTier();
|
|
const available = router.getAvailableTiers();
|
|
console.log(`Current model: ${current}`);
|
|
console.log(`Available: ${available.join(', ')}\n`);
|
|
return;
|
|
}
|
|
|
|
const tier = resolveModelAlias(name);
|
|
if (router.setTier(tier)) {
|
|
console.log(`Switched to model: ${tier}\n`);
|
|
} else {
|
|
console.log(`Model not available: ${name}\n`);
|
|
}
|
|
}
|
|
|
|
private printStatus(): void {
|
|
console.log(`Session: ${this.config.session.id}`);
|
|
console.log(`Messages: ${this.config.session.getHistory().length}`);
|
|
console.log(`Tokens: ${this.totalUsage.inputTokens} in / ${this.totalUsage.outputTokens} out\n`);
|
|
}
|
|
|
|
private async handleMessage(content: string): Promise<void> {
|
|
this.config.session.addMessage({ role: 'user', content });
|
|
|
|
process.stdout.write('\n');
|
|
|
|
try {
|
|
// Try streaming if available
|
|
if (this.config.modelClient.chatStream) {
|
|
let fullContent = '';
|
|
|
|
for await (const event of this.config.modelClient.chatStream({
|
|
messages: this.config.session.getHistory(),
|
|
system: this.config.systemPrompt,
|
|
})) {
|
|
if (event.type === 'content' && event.content) {
|
|
process.stdout.write(event.content);
|
|
fullContent += event.content;
|
|
}
|
|
if (event.type === 'done' && event.usage) {
|
|
this.totalUsage.inputTokens += event.usage.inputTokens;
|
|
this.totalUsage.outputTokens += event.usage.outputTokens;
|
|
}
|
|
if (event.type === 'error') {
|
|
throw event.error ?? new Error('Stream error');
|
|
}
|
|
}
|
|
|
|
console.log('\n');
|
|
|
|
// Render markdown for the complete response
|
|
const rendered = renderMarkdown(fullContent);
|
|
// Clear and reprint with markdown (optional: skip if terminal doesn't support)
|
|
// For now, just save the raw content
|
|
this.config.session.addMessage({ role: 'assistant', content: fullContent });
|
|
} else {
|
|
// Fallback to non-streaming
|
|
const response = await this.config.modelClient.chat({
|
|
messages: this.config.session.getHistory(),
|
|
system: this.config.systemPrompt,
|
|
});
|
|
|
|
const rendered = renderMarkdown(response.content);
|
|
console.log(rendered);
|
|
console.log();
|
|
|
|
this.totalUsage.inputTokens += response.usage.inputTokens;
|
|
this.totalUsage.outputTokens += response.usage.outputTokens;
|
|
|
|
this.config.session.addMessage({ role: 'assistant', content: response.content });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error instanceof Error ? error.message : error);
|
|
console.log();
|
|
}
|
|
}
|
|
|
|
stop(preserveStdin = false): void {
|
|
this.running = false;
|
|
if (this.rl) {
|
|
if (preserveStdin) {
|
|
this.rl.removeAllListeners();
|
|
process.stdin.removeAllListeners('keypress');
|
|
process.stdin.pause();
|
|
}
|
|
this.rl.close();
|
|
this.rl = null;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests to verify nothing broke**
|
|
|
|
Run: `pnpm test:run src/frontends/tui/minimal.test.ts`
|
|
Expected: Some tests may need updating
|
|
|
|
**Step 3: Update minimal.test.ts for new imports**
|
|
|
|
Update `src/frontends/tui/minimal.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { formatPrompt, parseCommand } 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 (re-exported)', () => {
|
|
it('parses /quit command', () => {
|
|
const result = parseCommand('/quit');
|
|
expect(result).toEqual({ type: 'quit' });
|
|
});
|
|
|
|
it('parses /model command', () => {
|
|
const result = parseCommand('/model local');
|
|
expect(result).toEqual({ type: 'model', name: 'local' });
|
|
});
|
|
|
|
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 4: Run tests to verify they pass**
|
|
|
|
Run: `pnpm test:run src/frontends/tui/minimal.test.ts`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/frontends/tui/minimal.ts src/frontends/tui/minimal.test.ts
|
|
git commit -m "feat(tui): add streaming and model switching to minimal mode"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Update TUI Entry Point
|
|
|
|
**Files:**
|
|
- Modify: `src/tui.ts`
|
|
|
|
**Step 1: Update tui.ts to pass modelRouter**
|
|
|
|
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,
|
|
apiKey: models.default.api_key,
|
|
authToken: models.default.auth_token,
|
|
});
|
|
|
|
let fastClient;
|
|
let complexClient;
|
|
let localClient;
|
|
|
|
if (models.fast) {
|
|
fastClient = new AnthropicClient({
|
|
model: models.fast.model,
|
|
apiKey: models.fast.api_key,
|
|
authToken: models.fast.auth_token,
|
|
});
|
|
}
|
|
|
|
if (models.complex) {
|
|
complexClient = new AnthropicClient({
|
|
model: models.complex.model,
|
|
apiKey: models.complex.api_key,
|
|
authToken: models.complex.auth_token,
|
|
});
|
|
}
|
|
|
|
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,
|
|
modelRouter,
|
|
systemPrompt: SYSTEM_PROMPT,
|
|
model: config.models.default.model,
|
|
onExit: cleanup,
|
|
});
|
|
} else {
|
|
// Start minimal readline UI
|
|
let switchingToFullscreen = false;
|
|
|
|
const tui = new MinimalTui({
|
|
session,
|
|
modelClient: modelRouter,
|
|
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: () => {
|
|
switchingToFullscreen = true;
|
|
tui.stop(true);
|
|
},
|
|
});
|
|
|
|
await tui.start();
|
|
|
|
if (switchingToFullscreen) {
|
|
console.clear();
|
|
await startFullscreenTui({
|
|
session,
|
|
modelClient: modelRouter,
|
|
modelRouter,
|
|
systemPrompt: SYSTEM_PROMPT,
|
|
model: config.models.default.model,
|
|
onExit: cleanup,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
cleanup();
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('Failed to start TUI:', error);
|
|
process.exit(1);
|
|
});
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `pnpm build`
|
|
Expected: May fail - need to update fullscreen types
|
|
|
|
**Step 3: Commit (if build passes)**
|
|
|
|
```bash
|
|
git add src/tui.ts
|
|
git commit -m "feat(tui): pass modelRouter to both TUI modes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Update Fullscreen TUI Config
|
|
|
|
**Files:**
|
|
- Modify: `src/frontends/tui/fullscreen.ts`
|
|
- Modify: `src/frontends/tui/index.ts`
|
|
|
|
**Step 1: Update fullscreen.ts types**
|
|
|
|
Update `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';
|
|
import type { ModelRouter } from '../../models/router.js';
|
|
|
|
export interface FullscreenTuiConfig {
|
|
session: ManagedSession;
|
|
modelClient: ModelClient;
|
|
modelRouter?: ModelRouter;
|
|
systemPrompt: string;
|
|
model: string;
|
|
onExit?: () => void;
|
|
}
|
|
|
|
export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<void> {
|
|
// Ensure stdin is in a clean state for Ink
|
|
if (process.stdin.isPaused()) {
|
|
process.stdin.resume();
|
|
}
|
|
|
|
const { waitUntilExit } = render(
|
|
React.createElement(App, {
|
|
session: config.session,
|
|
modelClient: config.modelClient,
|
|
modelRouter: config.modelRouter,
|
|
systemPrompt: config.systemPrompt,
|
|
model: config.model,
|
|
onExit: config.onExit,
|
|
})
|
|
);
|
|
|
|
await waitUntilExit();
|
|
}
|
|
```
|
|
|
|
**Step 2: Update index.ts exports**
|
|
|
|
Update `src/frontends/tui/index.ts`:
|
|
|
|
```typescript
|
|
export { MinimalTui, formatPrompt, parseCommand } from './minimal.js';
|
|
export { startFullscreenTui, type FullscreenTuiConfig } from './fullscreen.js';
|
|
export { renderMarkdown } from './markdown.js';
|
|
export { parseCommand as parseCommandUtil, getHelpText, resolveModelAlias } from './commands.js';
|
|
```
|
|
|
|
**Step 3: Build to verify**
|
|
|
|
Run: `pnpm build`
|
|
Expected: May need App.tsx updates
|
|
|
|
**Step 4: Commit (if passes)**
|
|
|
|
```bash
|
|
git add src/frontends/tui/fullscreen.ts src/frontends/tui/index.ts
|
|
git commit -m "feat(tui): update fullscreen config for model router"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Update StatusBar Component
|
|
|
|
**Files:**
|
|
- Modify: `src/frontends/tui/components/StatusBar.tsx`
|
|
|
|
**Step 1: Update StatusBar with token usage**
|
|
|
|
Update `src/frontends/tui/components/StatusBar.tsx`:
|
|
|
|
```typescript
|
|
import React from 'react';
|
|
import { Box, Text } from 'ink';
|
|
|
|
export interface StatusBarProps {
|
|
sessionId: string;
|
|
messageCount: number;
|
|
model: string;
|
|
tokenUsage?: {
|
|
input: number;
|
|
output: number;
|
|
};
|
|
isStreaming?: boolean;
|
|
}
|
|
|
|
function formatTokens(n: number): string {
|
|
if (n >= 1000) {
|
|
return `${(n / 1000).toFixed(1)}k`;
|
|
}
|
|
return String(n);
|
|
}
|
|
|
|
export function StatusBar({
|
|
sessionId,
|
|
messageCount,
|
|
model,
|
|
tokenUsage,
|
|
isStreaming,
|
|
}: StatusBarProps): React.ReactElement {
|
|
// Extract short model name (e.g., "claude-opus-4-5-20251101" -> "opus-4.5")
|
|
const shortModel = model
|
|
.replace('claude-', '')
|
|
.replace(/-\d{8}$/, '')
|
|
.replace('-4-5', '-4.5');
|
|
|
|
return (
|
|
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
|
<Box flexGrow={1}>
|
|
<Text color="cyan">Flynn</Text>
|
|
{isStreaming && (
|
|
<>
|
|
<Text color="gray"> | </Text>
|
|
<Text color="yellow">streaming...</Text>
|
|
</>
|
|
)}
|
|
</Box>
|
|
<Box>
|
|
<Text color="gray">Model: </Text>
|
|
<Text color="green">{shortModel}</Text>
|
|
<Text color="gray"> | </Text>
|
|
<Text color="gray">Msgs: </Text>
|
|
<Text>{messageCount}</Text>
|
|
{tokenUsage && (
|
|
<>
|
|
<Text color="gray"> | </Text>
|
|
<Text color="gray">Tokens: </Text>
|
|
<Text>{formatTokens(tokenUsage.input)}/{formatTokens(tokenUsage.output)}</Text>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `pnpm build`
|
|
Expected: Success
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/frontends/tui/components/StatusBar.tsx
|
|
git commit -m "feat(tui): enhance StatusBar with token usage and streaming indicator"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Update MessageList with Markdown and Scroll
|
|
|
|
**Files:**
|
|
- Modify: `src/frontends/tui/components/MessageList.tsx`
|
|
|
|
**Step 1: Update MessageList**
|
|
|
|
Update `src/frontends/tui/components/MessageList.tsx`:
|
|
|
|
```typescript
|
|
import React from 'react';
|
|
import { Box, Text } from 'ink';
|
|
import type { Message } from '../../../models/types.js';
|
|
import { renderMarkdown } from '../markdown.js';
|
|
|
|
export interface MessageListProps {
|
|
messages: Message[];
|
|
scrollOffset?: number;
|
|
streamingContent?: string;
|
|
}
|
|
|
|
export function MessageList({
|
|
messages,
|
|
scrollOffset = 0,
|
|
streamingContent,
|
|
}: MessageListProps): React.ReactElement {
|
|
// Calculate visible area (approximate, Ink handles overflow)
|
|
const visibleMessages = messages.slice(scrollOffset);
|
|
|
|
return (
|
|
<Box flexDirection="column" flexGrow={1} paddingX={1} overflowY="hidden">
|
|
{visibleMessages.length === 0 && !streamingContent ? (
|
|
<Text color="gray">No messages yet. Start typing to chat with Flynn.</Text>
|
|
) : (
|
|
<>
|
|
{visibleMessages.map((message, index) => (
|
|
<Box key={`${scrollOffset + index}-${message.role}`} marginBottom={1} flexDirection="column">
|
|
<Text color={message.role === 'user' ? 'blue' : 'green'} bold>
|
|
{message.role === 'user' ? 'You:' : 'Flynn:'}
|
|
</Text>
|
|
<Box marginLeft={1}>
|
|
<Text wrap="wrap">
|
|
{message.role === 'assistant'
|
|
? renderMarkdown(message.content)
|
|
: message.content}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
{streamingContent && (
|
|
<Box marginBottom={1} flexDirection="column">
|
|
<Text color="green" bold>Flynn:</Text>
|
|
<Box marginLeft={1}>
|
|
<Text wrap="wrap">{streamingContent}</Text>
|
|
<Text color="yellow">▌</Text>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
{messages.length > 0 && scrollOffset > 0 && (
|
|
<Box position="absolute" marginTop={-1}>
|
|
<Text color="gray">↑ {scrollOffset} more</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `pnpm build`
|
|
Expected: Success
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/frontends/tui/components/MessageList.tsx
|
|
git commit -m "feat(tui): add markdown rendering and scroll support to MessageList"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Update App Component with Streaming and Scroll
|
|
|
|
**Files:**
|
|
- Modify: `src/frontends/tui/components/App.tsx`
|
|
|
|
**Step 1: Update App.tsx with full streaming support**
|
|
|
|
Update `src/frontends/tui/components/App.tsx`:
|
|
|
|
```typescript
|
|
import React, { useState, useCallback, useRef } from 'react';
|
|
import { Box, useApp, useInput } from 'ink';
|
|
import { StatusBar } from './StatusBar.js';
|
|
import { MessageList } from './MessageList.js';
|
|
import { InputBar } from './InputBar.js';
|
|
import { parseCommand, getHelpText, resolveModelAlias } from '../commands.js';
|
|
import type { Message, ModelClient, TokenUsage } from '../../../models/types.js';
|
|
import type { ModelRouter } from '../../../models/router.js';
|
|
import type { ManagedSession } from '../../../session/index.js';
|
|
|
|
export interface AppProps {
|
|
session: ManagedSession;
|
|
modelClient: ModelClient;
|
|
modelRouter?: ModelRouter;
|
|
systemPrompt: string;
|
|
model: string;
|
|
onExit?: () => void;
|
|
}
|
|
|
|
export function App({
|
|
session,
|
|
modelClient,
|
|
modelRouter,
|
|
systemPrompt,
|
|
model,
|
|
onExit,
|
|
}: AppProps): React.ReactElement {
|
|
const { exit } = useApp();
|
|
const [input, setInput] = useState('');
|
|
const [messages, setMessages] = useState<Message[]>(session.getHistory());
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const [streamingContent, setStreamingContent] = useState('');
|
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 });
|
|
const [currentModel, setCurrentModel] = useState(model);
|
|
const abortRef = useRef(false);
|
|
|
|
useInput((inputChar, key) => {
|
|
if (key.escape) {
|
|
if (isStreaming) {
|
|
abortRef.current = true;
|
|
} else {
|
|
onExit?.();
|
|
exit();
|
|
}
|
|
}
|
|
|
|
// Scroll handling
|
|
if (key.upArrow && scrollOffset > 0) {
|
|
setScrollOffset(prev => Math.max(0, prev - 1));
|
|
}
|
|
if (key.downArrow) {
|
|
setScrollOffset(prev => Math.min(messages.length - 1, prev + 1));
|
|
}
|
|
if (key.pageUp) {
|
|
setScrollOffset(prev => Math.max(0, prev - 10));
|
|
}
|
|
if (key.pageDown) {
|
|
setScrollOffset(prev => Math.min(messages.length - 1, prev + 10));
|
|
}
|
|
});
|
|
|
|
const handleSubmit = useCallback(async (value: string) => {
|
|
const command = parseCommand(value);
|
|
if (!command) return;
|
|
|
|
setInput('');
|
|
|
|
// Handle commands
|
|
switch (command.type) {
|
|
case 'quit':
|
|
onExit?.();
|
|
exit();
|
|
return;
|
|
|
|
case 'reset':
|
|
session.clear();
|
|
setMessages([]);
|
|
setTokenUsage({ inputTokens: 0, outputTokens: 0 });
|
|
setScrollOffset(0);
|
|
return;
|
|
|
|
case 'help':
|
|
// Show help as system message
|
|
setMessages(prev => [...prev, { role: 'assistant', content: getHelpText() }]);
|
|
return;
|
|
|
|
case 'status':
|
|
const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`;
|
|
setMessages(prev => [...prev, { role: 'assistant', content: status }]);
|
|
return;
|
|
|
|
case 'model':
|
|
if (!modelRouter) {
|
|
setMessages(prev => [...prev, { role: 'assistant', content: 'Model switching not available.' }]);
|
|
return;
|
|
}
|
|
if (!command.name) {
|
|
const info = `Current: ${modelRouter.getTier()}\nAvailable: ${modelRouter.getAvailableTiers().join(', ')}`;
|
|
setMessages(prev => [...prev, { role: 'assistant', content: info }]);
|
|
return;
|
|
}
|
|
const tier = resolveModelAlias(command.name);
|
|
if (modelRouter.setTier(tier)) {
|
|
setCurrentModel(tier);
|
|
setMessages(prev => [...prev, { role: 'assistant', content: `Switched to model: ${tier}` }]);
|
|
} else {
|
|
setMessages(prev => [...prev, { role: 'assistant', content: `Model not available: ${command.name}` }]);
|
|
}
|
|
return;
|
|
|
|
case 'fullscreen':
|
|
// Already in fullscreen
|
|
return;
|
|
|
|
case 'transfer':
|
|
setMessages(prev => [...prev, { role: 'assistant', content: `Transfer not supported in fullscreen mode.` }]);
|
|
return;
|
|
|
|
case 'message':
|
|
break; // Continue to message handling
|
|
}
|
|
|
|
if (command.type !== 'message' || isStreaming) return;
|
|
|
|
// Add user message
|
|
const userMessage: Message = { role: 'user', content: command.content };
|
|
session.addMessage(userMessage);
|
|
setMessages(prev => [...prev, userMessage]);
|
|
setScrollOffset(0); // Auto-scroll to bottom
|
|
|
|
// Stream response
|
|
setIsStreaming(true);
|
|
setStreamingContent('');
|
|
abortRef.current = false;
|
|
|
|
try {
|
|
if (modelClient.chatStream) {
|
|
let fullContent = '';
|
|
|
|
for await (const event of modelClient.chatStream({
|
|
messages: session.getHistory(),
|
|
system: systemPrompt,
|
|
})) {
|
|
if (abortRef.current) {
|
|
fullContent += '\n\n[interrupted]';
|
|
break;
|
|
}
|
|
|
|
if (event.type === 'content' && event.content) {
|
|
fullContent += event.content;
|
|
setStreamingContent(fullContent);
|
|
}
|
|
if (event.type === 'done' && event.usage) {
|
|
setTokenUsage(prev => ({
|
|
inputTokens: prev.inputTokens + event.usage!.inputTokens,
|
|
outputTokens: prev.outputTokens + event.usage!.outputTokens,
|
|
}));
|
|
}
|
|
if (event.type === 'error') {
|
|
throw event.error ?? new Error('Stream error');
|
|
}
|
|
}
|
|
|
|
const assistantMessage: Message = { role: 'assistant', content: fullContent };
|
|
session.addMessage(assistantMessage);
|
|
setMessages(prev => [...prev, assistantMessage]);
|
|
} else {
|
|
// Fallback to non-streaming
|
|
const response = await modelClient.chat({
|
|
messages: session.getHistory(),
|
|
system: systemPrompt,
|
|
});
|
|
|
|
setTokenUsage(prev => ({
|
|
inputTokens: prev.inputTokens + response.usage.inputTokens,
|
|
outputTokens: prev.outputTokens + response.usage.outputTokens,
|
|
}));
|
|
|
|
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 {
|
|
setIsStreaming(false);
|
|
setStreamingContent('');
|
|
}
|
|
}, [isStreaming, session, modelClient, modelRouter, systemPrompt, exit, onExit, messages.length, tokenUsage]);
|
|
|
|
return (
|
|
<Box flexDirection="column" height="100%">
|
|
<StatusBar
|
|
sessionId={session.id}
|
|
messageCount={messages.length}
|
|
model={currentModel}
|
|
tokenUsage={{ input: tokenUsage.inputTokens, output: tokenUsage.outputTokens }}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
<MessageList
|
|
messages={messages}
|
|
scrollOffset={scrollOffset}
|
|
streamingContent={isStreaming ? streamingContent : undefined}
|
|
/>
|
|
<InputBar
|
|
value={input}
|
|
onChange={setInput}
|
|
onSubmit={handleSubmit}
|
|
isLoading={isStreaming}
|
|
placeholder={isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)'}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `pnpm build`
|
|
Expected: Success
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/frontends/tui/components/App.tsx
|
|
git commit -m "feat(tui): add streaming, scroll, and model switching to fullscreen App"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Update Component Exports
|
|
|
|
**Files:**
|
|
- Modify: `src/frontends/tui/components/index.ts`
|
|
|
|
**Step 1: Verify exports are correct**
|
|
|
|
Check `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 2: Build and run full test suite**
|
|
|
|
Run: `pnpm test:run && pnpm build`
|
|
Expected: All tests pass, build succeeds
|
|
|
|
**Step 3: Commit if needed**
|
|
|
|
```bash
|
|
git add src/frontends/tui/components/index.ts
|
|
git commit -m "chore(tui): update component exports"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Manual Integration Testing
|
|
|
|
**Step 1: Test minimal mode streaming**
|
|
|
|
Run: `pnpm tui`
|
|
|
|
Test:
|
|
- Type "Hello" - should see streaming response
|
|
- Type `/model` - should show available models
|
|
- Type `/model local` - should switch model
|
|
- Type `/status` - should show token count
|
|
- Type `/help` - should show commands
|
|
- Type `/fs` - should switch to fullscreen
|
|
|
|
**Step 2: Test fullscreen mode**
|
|
|
|
Run: `pnpm tui -f`
|
|
|
|
Test:
|
|
- Type "Hello" - should see streaming with cursor
|
|
- Press Up/Down arrows - should scroll
|
|
- Type `/model` - should show models
|
|
- Press Esc during streaming - should cancel
|
|
- Press Esc when idle - should exit
|
|
|
|
**Step 3: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(tui): complete TUI redesign with streaming and markdown"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
**Tasks completed:**
|
|
1. Streaming types added
|
|
2. AnthropicClient streaming implemented
|
|
3. ModelRouter streaming + tier switching
|
|
4. Markdown dependencies installed
|
|
5. Markdown rendering utility
|
|
6. Unified command parser
|
|
7. Minimal TUI with streaming
|
|
8. TUI entry point updated
|
|
9. Fullscreen config updated
|
|
10. StatusBar enhanced
|
|
11. MessageList with markdown/scroll
|
|
12. App with full streaming/scroll
|
|
13. Exports verified
|
|
14. Integration tested
|
|
|
|
**Future tasks (from design doc):**
|
|
- Vim-like keybindings
|
|
- Command palette
|
|
- Multiple panes
|
|
- Message-based navigation
|
|
- Named sessions
|