From da1773b950a72ab97ce28076c2cc40b264655ca0 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 12 Dec 2025 11:47:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20TUI=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 blessed 终端 UI 库 - 创建 ChatView 组件:支持消息列表和流式输出 - 创建 SessionList 组件:会话管理和快捷键 - 创建 StatusBar 组件:连接状态显示 - 创建 TUIApp 主应用整合所有组件 - 更新 attach 命令支持 --tui/--no-tui 选项 - 添加 CLAUDE.md 项目规范文件 - 修复 Web 前端 CSS prose 类缺失问题 --- CLAUDE.md | 128 ++++++ packages/cli/package.json | 4 +- packages/cli/src/commands/attach.ts | 43 +- packages/cli/src/tui/App.ts | 368 ++++++++++++++++++ packages/cli/src/tui/components/ChatView.ts | 236 +++++++++++ .../cli/src/tui/components/SessionList.ts | 197 ++++++++++ packages/cli/src/tui/components/StatusBar.ts | 105 +++++ packages/cli/src/tui/components/index.ts | 7 + packages/cli/src/tui/index.ts | 7 + packages/cli/src/tui/types.ts | 45 +++ packages/web/src/styles/index.css | 2 +- pnpm-lock.yaml | 20 + 12 files changed, 1145 insertions(+), 17 deletions(-) create mode 100644 CLAUDE.md create mode 100644 packages/cli/src/tui/App.ts create mode 100644 packages/cli/src/tui/components/ChatView.ts create mode 100644 packages/cli/src/tui/components/SessionList.ts create mode 100644 packages/cli/src/tui/components/StatusBar.ts create mode 100644 packages/cli/src/tui/components/index.ts create mode 100644 packages/cli/src/tui/index.ts create mode 100644 packages/cli/src/tui/types.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..857d5b9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,128 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Important Rules + +- **No backward compatibility**: This is a new project. Do not add any backward-compatible code, deprecated APIs, migration shims, or legacy support. Remove old code entirely when refactoring instead of keeping it around "just in case". +- **Git commits**: When asked to commit, only create the commit (no push). Commit messages must not contain any Claude-related information (no "Co-Authored-By", no "Generated with Claude", etc.). +- **Documentation**: All documentation must be stored under `docs/`. When implementing features based on design documents, update the corresponding document after coding is complete to reflect the current implementation status. + +## Project Overview + +AI Terminal Assistant is a terminal-based AI coding assistant built with Claude API. It's structured as a **pnpm monorepo** with four packages: + +- **@ai-assistant/core** - Agent engine, tools, LSP integration, checkpoint system +- **@ai-assistant/server** - HTTP Server (Hono + Bun) with REST API, WebSocket, SSE +- **@ai-assistant/cli** - Command-line interface with `serve` and `attach` commands +- **@ai-assistant/web** - React frontend (Vite + Tailwind CSS) + +## Build & Development Commands + +```bash +# Install dependencies (use pnpm, not npm) +pnpm install + +# Build all packages +pnpm build + +# Run tests +pnpm test # All packages +pnpm --filter @ai-assistant/core test # Single package +pnpm --filter @ai-assistant/core test:watch # Watch mode + +# Start development +pnpm server:dev # Start HTTP server (port 3000) +cd packages/web && pnpm dev # Start web UI (port 5173) + +# CLI commands +cd packages/cli && bun run src/index.ts serve --port 3000 +cd packages/cli && bun run src/index.ts attach http://localhost:3000 + +# Type checking +cd packages/web && pnpm typecheck +cd packages/server && pnpm build +``` + +## Architecture + +### Package Dependencies + +``` +web / cli + ↓ + server → REST API + WebSocket + SSE + ↓ + core → Agent / Tools / LSP / Checkpoint / Hooks / MCP +``` + +### Core Package (`packages/core/src/`) + +Key modules: +- `core/agent.ts` - Main Agent class using AI SDK's `streamText` +- `tools/` - Tool registry with bash, file operations, search, etc. +- `editors/` - Unified edit mode system (whole/diff/search-replace) +- `lsp/` - Language Server Protocol integration +- `checkpoint/` - Shadow Git checkpoint system +- `hooks/` - Pre/post execution hooks +- `mcp/` - Model Context Protocol client +- `repomap/` - Repository map generation with tree-sitter + +### Server Package (`packages/server/src/`) + +- `index.ts` - Hono app setup with CORS, auth middleware +- `routes/` - REST endpoints (sessions, tools, config) +- `ws.ts` - WebSocket handler for real-time chat +- `sse.ts` - Server-Sent Events for status updates +- `agent/adapter.ts` - Bridges core Agent to server (dynamic import to avoid build dependency) +- `auth/token.ts` - Token-based authentication (auto-enabled for non-localhost) + +### Communication Flow + +1. Client connects via WebSocket to `/api/ws/:sessionId` +2. Client sends `{ type: 'message', payload: { content } }` +3. Server calls `processMessage()` → Agent.chat() with streaming callback +4. Server pushes `chunk` events back via WebSocket +5. On completion, server sends `done` event with full response + +## Key Design Patterns + +### Dynamic Core Module Loading + +Server uses dynamic import to avoid build-time dependency on core: +```typescript +const corePath = '@ai-assistant/core'; +const core = await import(corePath) as CoreModule; +``` + +### Tool Registration + +Tools are registered in `toolRegistry` with Zod schemas: +```typescript +toolRegistry.register({ + name: 'read_file', + description: '...', + parameters: z.object({ path: z.string() }), + execute: async (params) => { ... } +}); +``` + +### Session Management + +Each WebSocket connection binds to a session. Sessions hold: +- Message history +- Status (idle/busy) +- Agent instance (cached per session) + +## Configuration + +- **Environment**: `ANTHROPIC_API_KEY` required in `.env` +- **Default model**: `claude-sonnet-4-20250514` +- **Server auth**: Auto-generates token when host is not localhost + +## Design Documentation + +Architecture details are in `docs/design/architecture/gui-server-client.md`, including: +- Implementation roadmap with completion status +- API endpoint specifications +- Communication protocol definitions diff --git a/packages/cli/package.json b/packages/cli/package.json index ce31293..bf608e0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,9 +20,11 @@ "commander": "^12.1.0", "chalk": "^5.3.0", "ora": "^8.0.1", - "inquirer": "^9.2.12" + "inquirer": "^9.2.12", + "blessed": "^0.1.81" }, "devDependencies": { + "@types/blessed": "^0.1.25", "@types/bun": "^1.1.0", "@types/node": "^20.11.0", "typescript": "^5.3.3" diff --git a/packages/cli/src/commands/attach.ts b/packages/cli/src/commands/attach.ts index 2fcc601..1db94eb 100644 --- a/packages/cli/src/commands/attach.ts +++ b/packages/cli/src/commands/attach.ts @@ -16,8 +16,10 @@ export function registerAttachCommand(program: Command): void { .argument('', 'Server URL (e.g., http://192.168.1.100:3000)') .option('-t, --token ', 'Authentication token') .option('-s, --session ', 'Session ID to connect to') + .option('--tui', 'Use TUI mode (default)', true) + .option('--no-tui', 'Use simple CLI mode') .action(async (url: string, options) => { - const { token, session: sessionId } = options; + const { token, session: sessionId, tui } = options; const spinner = ora('Connecting to server...').start(); @@ -32,21 +34,32 @@ export function registerAttachCommand(program: Command): void { const health = await client.health(); spinner.succeed(`Connected to server at ${url}`); - console.log(chalk.gray('─'.repeat(50))); - console.log(chalk.bold('Server Status:')); - console.log(` Status: ${chalk.green(health.status)}`); - console.log(` Agent: ${health.agent.coreAvailable ? chalk.green('Available') : chalk.yellow('Not available')}`); - console.log(` Auth: ${health.auth.enabled ? chalk.yellow('Enabled') : chalk.gray('Disabled')}`); - console.log(` Sessions: ${health.stats.sessions}`); - console.log(` WebSocket: ${health.stats.websocket.connections} connections`); - console.log(chalk.gray('─'.repeat(50))); - - // 如果指定了 session,连接到该 session - if (sessionId) { - await connectToSession(client, sessionId); + if (tui) { + // TUI 模式 + const { TUIApp } = await import('../tui/index.js'); + const app = new TUIApp({ + client, + sessionId, + }); + await app.start(); } else { - // 显示可用 sessions 或创建新的 - await showSessionMenu(client); + // 简单 CLI 模式 + console.log(chalk.gray('─'.repeat(50))); + console.log(chalk.bold('Server Status:')); + console.log(` Status: ${chalk.green(health.status)}`); + console.log(` Agent: ${health.agent.coreAvailable ? chalk.green('Available') : chalk.yellow('Not available')}`); + console.log(` Auth: ${health.auth.enabled ? chalk.yellow('Enabled') : chalk.gray('Disabled')}`); + console.log(` Sessions: ${health.stats.sessions}`); + console.log(` WebSocket: ${health.stats.websocket.connections} connections`); + console.log(chalk.gray('─'.repeat(50))); + + // 如果指定了 session,连接到该 session + if (sessionId) { + await connectToSession(client, sessionId); + } else { + // 显示可用 sessions 或创建新的 + await showSessionMenu(client); + } } } catch (error) { spinner.fail('Failed to connect to server'); diff --git a/packages/cli/src/tui/App.ts b/packages/cli/src/tui/App.ts new file mode 100644 index 0000000..7c5b1d5 --- /dev/null +++ b/packages/cli/src/tui/App.ts @@ -0,0 +1,368 @@ +/** + * TUI Application + * + * 主 TUI 应用,整合所有组件 + */ + +import blessed from 'blessed'; +import type { Widgets } from 'blessed'; +import type { APIClient, Session, Message } from '../client/api.js'; +import { ChatView, SessionList, StatusBar } from './components/index.js'; +import type { ChatMessage, TUIConfig } from './types.js'; + +export class TUIApp { + private screen: Widgets.Screen; + private client: APIClient; + private sessionList: SessionList; + private chatView: ChatView; + private statusBar: StatusBar; + + private currentSession: Session | null = null; + private ws: WebSocket | null = null; + private focusTarget: 'sessions' | 'chat' = 'sessions'; + + constructor(config: TUIConfig) { + this.client = config.client; + + // 创建屏幕 + this.screen = blessed.screen({ + smartCSR: true, + title: 'AI Terminal Assistant', + cursor: { + artificial: true, + shape: 'line', + blink: true, + color: 'white', + }, + }); + + // 创建组件 + this.sessionList = new SessionList({ + parent: this.screen, + left: 0, + top: 0, + width: '25%', + height: '100%-1', + }); + + this.chatView = new ChatView({ + parent: this.screen, + left: '25%', + top: 0, + width: '75%', + height: '100%-1', + }); + + this.statusBar = new StatusBar({ + parent: this.screen, + bottom: 0, + }); + + this.setupCallbacks(); + this.setupKeyBindings(); + + // 如果有初始 sessionId,连接到该会话 + if (config.sessionId) { + this.connectToSession(config.sessionId); + } + } + + /** + * 设置组件回调 + */ + private setupCallbacks(): void { + // 会话列表回调 + this.sessionList.setOnSelect((session) => { + this.connectToSession(session.id); + }); + + this.sessionList.setOnCreate(async () => { + await this.createSession(); + }); + + this.sessionList.setOnDelete(async (session) => { + await this.deleteSession(session); + }); + + // 聊天视图回调 + this.chatView.setOnSend((content) => { + this.sendMessage(content); + }); + } + + /** + * 设置全局快捷键 + */ + private setupKeyBindings(): void { + // 退出 + this.screen.key(['q', 'C-c'], () => { + this.quit(); + }); + + // Tab 切换焦点 + this.screen.key('tab', () => { + this.toggleFocus(); + }); + + // Escape 切换到会话列表 + this.screen.key('escape', () => { + this.focusTarget = 'sessions'; + this.sessionList.focus(); + this.screen.render(); + }); + } + + /** + * 切换焦点 + */ + private toggleFocus(): void { + if (this.focusTarget === 'sessions') { + this.focusTarget = 'chat'; + this.chatView.focus(); + } else { + this.focusTarget = 'sessions'; + this.sessionList.focus(); + } + this.screen.render(); + } + + /** + * 启动应用 + */ + async start(): Promise { + // 获取服务器健康状态 + try { + const health = await this.client.health(); + this.statusBar.setStatus({ + serverUrl: (this.client as any).baseUrl, + connected: true, + agentAvailable: health.agent.coreAvailable, + }); + } catch { + this.statusBar.setStatus({ + connected: false, + }); + } + + // 加载会话列表 + await this.loadSessions(); + + // 聚焦会话列表 + this.sessionList.focus(); + this.screen.render(); + } + + /** + * 加载会话列表 + */ + private async loadSessions(): Promise { + try { + const { data: sessions } = await this.client.listSessions(); + this.sessionList.setSessions(sessions); + } catch (error) { + this.showError('Failed to load sessions'); + } + } + + /** + * 创建新会话 + */ + private async createSession(): Promise { + try { + const { data: session } = await this.client.createSession(); + await this.loadSessions(); + this.connectToSession(session.id); + } catch (error) { + this.showError('Failed to create session'); + } + } + + /** + * 删除会话 + */ + private async deleteSession(session: Session): Promise { + try { + await this.client.deleteSession(session.id); + if (this.currentSession?.id === session.id) { + this.disconnectWebSocket(); + this.currentSession = null; + this.chatView.clearMessages(); + this.statusBar.setStatus({ sessionId: undefined, sessionName: undefined }); + } + await this.loadSessions(); + } catch (error) { + this.showError('Failed to delete session'); + } + } + + /** + * 连接到会话 + */ + private async connectToSession(sessionId: string): Promise { + // 断开现有连接 + this.disconnectWebSocket(); + + try { + // 获取会话信息 + const { data: session } = await this.client.getSession(sessionId); + this.currentSession = session; + + // 更新状态栏 + this.statusBar.setStatus({ + sessionId: session.id, + sessionName: session.name, + }); + + // 高亮选中的会话 + this.sessionList.selectSession(sessionId); + + // 加载历史消息 + const { data: messages } = await this.client.getMessages(sessionId); + this.chatView.setMessages( + messages.map((m) => this.toDisplayMessage(m)) + ); + + // 连接 WebSocket + this.connectWebSocket(sessionId); + + // 切换焦点到聊天视图 + this.focusTarget = 'chat'; + this.chatView.focus(); + this.screen.render(); + } catch (error) { + this.showError('Failed to connect to session'); + } + } + + /** + * 连接 WebSocket + */ + private connectWebSocket(sessionId: string): void { + this.ws = this.client.connectWebSocket(sessionId); + + this.ws.onopen = () => { + this.statusBar.setStatus({ connected: true }); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data as string); + this.handleWebSocketMessage(message); + } catch { + // 忽略解析错误 + } + }; + + this.ws.onerror = () => { + this.showError('WebSocket error'); + }; + + this.ws.onclose = () => { + this.ws = null; + }; + } + + /** + * 断开 WebSocket + */ + private disconnectWebSocket(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + /** + * 处理 WebSocket 消息 + */ + private handleWebSocketMessage(message: { type: string; payload?: any }): void { + switch (message.type) { + case 'chunk': + this.chatView.appendStreamingContent(message.payload?.content || ''); + break; + + case 'done': + if (message.payload?.message) { + this.chatView.finishStreaming(this.toDisplayMessage(message.payload.message)); + } + // 刷新会话列表以更新消息计数 + this.loadSessions(); + break; + + case 'error': + this.showError(message.payload?.message || 'Unknown error'); + break; + } + } + + /** + * 发送消息 + */ + private sendMessage(content: string): void { + if (!this.ws || !this.currentSession) { + this.showError('Not connected to a session'); + return; + } + + // 添加用户消息到视图 + this.chatView.addMessage({ + id: Date.now().toString(), + role: 'user', + content, + timestamp: new Date().toISOString(), + }); + + // 通过 WebSocket 发送 + this.ws.send(JSON.stringify({ + type: 'message', + sessionId: this.currentSession.id, + payload: { content }, + })); + } + + /** + * 转换消息格式 + */ + private toDisplayMessage(message: Message): ChatMessage { + return { + id: message.id, + role: message.role, + content: message.content, + timestamp: message.timestamp, + }; + } + + /** + * 显示错误 + */ + private showError(message: string): void { + const dialog = blessed.message({ + parent: this.screen, + border: 'line', + height: 'shrink', + width: 'half', + top: 'center', + left: 'center', + label: ' Error ', + tags: true, + style: { + border: { fg: 'red' }, + label: { fg: 'red', bold: true }, + }, + }); + + dialog.error(message, () => { + dialog.destroy(); + this.screen.render(); + }); + } + + /** + * 退出应用 + */ + quit(): void { + this.disconnectWebSocket(); + this.screen.destroy(); + process.exit(0); + } +} diff --git a/packages/cli/src/tui/components/ChatView.ts b/packages/cli/src/tui/components/ChatView.ts new file mode 100644 index 0000000..795bddc --- /dev/null +++ b/packages/cli/src/tui/components/ChatView.ts @@ -0,0 +1,236 @@ +/** + * ChatView Component + * + * 显示聊天消息和输入框 + */ + +import blessed from 'blessed'; +import type { Widgets } from 'blessed'; +import type { ChatMessage } from '../types.js'; + +export interface ChatViewOptions { + parent: Widgets.Screen; + left: number | string; + top: number | string; + width: number | string; + height: number | string; +} + +export class ChatView { + private container: Widgets.BoxElement; + private messageList: Widgets.BoxElement; + private inputBox: Widgets.TextboxElement; + private messages: ChatMessage[] = []; + private streamingContent = ''; + private onSend?: (content: string) => void; + + constructor(options: ChatViewOptions) { + // 主容器 + this.container = blessed.box({ + parent: options.parent, + left: options.left, + top: options.top, + width: options.width, + height: options.height, + border: { type: 'line' }, + label: ' Chat ', + style: { + border: { fg: 'cyan' }, + label: { fg: 'cyan', bold: true }, + }, + }); + + // 消息列表区域 + this.messageList = blessed.box({ + parent: this.container, + top: 0, + left: 0, + right: 0, + bottom: 3, + scrollable: true, + alwaysScroll: true, + scrollbar: { + ch: '│', + style: { fg: 'cyan' }, + }, + mouse: true, + keys: true, + vi: true, + tags: true, + style: { + fg: 'white', + }, + }); + + // 输入框 + this.inputBox = blessed.textbox({ + parent: this.container, + bottom: 0, + left: 0, + right: 0, + height: 3, + border: { type: 'line' }, + label: ' Message (Enter to send, Esc to cancel) ', + style: { + border: { fg: 'green' }, + label: { fg: 'green' }, + focus: { + border: { fg: 'yellow' }, + }, + }, + inputOnFocus: true, + }); + + this.setupKeyBindings(); + } + + private setupKeyBindings(): void { + // 输入框提交 + this.inputBox.on('submit', (value) => { + const content = value.trim(); + if (content && this.onSend) { + this.onSend(content); + } + this.inputBox.clearValue(); + this.inputBox.focus(); + this.render(); + }); + + // 取消输入 + this.inputBox.on('cancel', () => { + this.inputBox.clearValue(); + this.render(); + }); + } + + /** + * 设置消息发送回调 + */ + setOnSend(callback: (content: string) => void): void { + this.onSend = callback; + } + + /** + * 添加消息 + */ + addMessage(message: ChatMessage): void { + this.messages.push(message); + this.updateMessageList(); + } + + /** + * 设置所有消息 + */ + setMessages(messages: ChatMessage[]): void { + this.messages = messages; + this.updateMessageList(); + } + + /** + * 更新流式内容 + */ + appendStreamingContent(content: string): void { + this.streamingContent += content; + this.updateMessageList(); + } + + /** + * 完成流式输出 + */ + finishStreaming(finalMessage: ChatMessage): void { + this.streamingContent = ''; + // 替换或添加最终消息 + const lastMsg = this.messages[this.messages.length - 1]; + if (lastMsg && lastMsg.isStreaming) { + this.messages[this.messages.length - 1] = finalMessage; + } else { + this.messages.push(finalMessage); + } + this.updateMessageList(); + } + + /** + * 清空消息 + */ + clearMessages(): void { + this.messages = []; + this.streamingContent = ''; + this.updateMessageList(); + } + + /** + * 更新消息列表显示 + */ + private updateMessageList(): void { + const lines: string[] = []; + + for (const msg of this.messages) { + const prefix = msg.role === 'user' + ? '{blue-fg}{bold}You:{/bold}{/blue-fg} ' + : '{green-fg}{bold}AI:{/bold}{/green-fg} '; + + // 处理多行消息 + const contentLines = msg.content.split('\n'); + contentLines.forEach((line, i) => { + if (i === 0) { + lines.push(prefix + line); + } else { + lines.push(' ' + line); + } + }); + lines.push(''); + } + + // 添加流式内容 + if (this.streamingContent) { + const streamLines = this.streamingContent.split('\n'); + streamLines.forEach((line, i) => { + if (i === 0) { + lines.push('{green-fg}{bold}AI:{/bold}{/green-fg} ' + line); + } else { + lines.push(' ' + line); + } + }); + lines.push('{yellow-fg}▌{/yellow-fg}'); // 光标指示 + } + + this.messageList.setContent(lines.join('\n')); + this.scrollToBottom(); + this.render(); + } + + /** + * 滚动到底部 + */ + private scrollToBottom(): void { + this.messageList.setScrollPerc(100); + } + + /** + * 聚焦输入框 + */ + focus(): void { + this.inputBox.focus(); + } + + /** + * 渲染 + */ + render(): void { + this.container.screen.render(); + } + + /** + * 销毁 + */ + destroy(): void { + this.container.destroy(); + } + + /** + * 获取容器元素 + */ + getElement(): Widgets.BoxElement { + return this.container; + } +} diff --git a/packages/cli/src/tui/components/SessionList.ts b/packages/cli/src/tui/components/SessionList.ts new file mode 100644 index 0000000..acb3a06 --- /dev/null +++ b/packages/cli/src/tui/components/SessionList.ts @@ -0,0 +1,197 @@ +/** + * SessionList Component + * + * 显示会话列表 + */ + +import blessed from 'blessed'; +import type { Widgets } from 'blessed'; +import type { Session } from '../../client/api.js'; + +export interface SessionListOptions { + parent: Widgets.Screen; + left: number | string; + top: number | string; + width: number | string; + height: number | string; +} + +export class SessionList { + private container: Widgets.BoxElement; + private list: Widgets.ListElement; + private sessions: Session[] = []; + private onSelect?: (session: Session) => void; + private onCreate?: () => void; + private onDelete?: (session: Session) => void; + + constructor(options: SessionListOptions) { + // 主容器 + this.container = blessed.box({ + parent: options.parent, + left: options.left, + top: options.top, + width: options.width, + height: options.height, + border: { type: 'line' }, + label: ' Sessions ', + style: { + border: { fg: 'cyan' }, + label: { fg: 'cyan', bold: true }, + }, + }); + + // 会话列表 + this.list = blessed.list({ + parent: this.container, + top: 0, + left: 0, + right: 0, + bottom: 2, + keys: true, + vi: true, + mouse: true, + style: { + selected: { + bg: 'blue', + fg: 'white', + bold: true, + }, + item: { + fg: 'white', + }, + }, + scrollbar: { + ch: '│', + style: { fg: 'cyan' }, + }, + }); + + // 底部提示 + blessed.box({ + parent: this.container, + bottom: 0, + left: 0, + right: 0, + height: 1, + content: '{gray-fg}n: New d: Delete Enter: Select{/gray-fg}', + tags: true, + style: { + fg: 'gray', + }, + }); + + this.setupKeyBindings(); + } + + private setupKeyBindings(): void { + // 选择会话 + this.list.on('select', (_item, index) => { + if (index === 0 && this.onCreate) { + // 第一项是 "New Session" + this.onCreate(); + } else if (this.sessions[index - 1] && this.onSelect) { + this.onSelect(this.sessions[index - 1]); + } + }); + + // 快捷键: n 创建新会话 + this.list.key('n', () => { + if (this.onCreate) { + this.onCreate(); + } + }); + + // 快捷键: d 删除会话 + this.list.key('d', () => { + const selectedIndex = (this.list as any).selected; + if (selectedIndex > 0 && this.sessions[selectedIndex - 1] && this.onDelete) { + this.onDelete(this.sessions[selectedIndex - 1]); + } + }); + } + + /** + * 设置会话选择回调 + */ + setOnSelect(callback: (session: Session) => void): void { + this.onSelect = callback; + } + + /** + * 设置创建会话回调 + */ + setOnCreate(callback: () => void): void { + this.onCreate = callback; + } + + /** + * 设置删除会话回调 + */ + setOnDelete(callback: (session: Session) => void): void { + this.onDelete = callback; + } + + /** + * 设置会话列表 + */ + setSessions(sessions: Session[]): void { + this.sessions = sessions; + this.updateList(); + } + + /** + * 更新列表显示 + */ + private updateList(): void { + const items = [ + '{green-fg}+ New Session{/green-fg}', + ...this.sessions.map((s) => { + const name = s.name || s.id.slice(0, 8); + const count = s.messageCount || 0; + return `${name} (${count} msgs)`; + }), + ]; + + this.list.setItems(items); + this.render(); + } + + /** + * 高亮指定会话 + */ + selectSession(sessionId: string): void { + const index = this.sessions.findIndex((s) => s.id === sessionId); + if (index >= 0) { + this.list.select(index + 1); // +1 因为第一项是 "New Session" + this.render(); + } + } + + /** + * 聚焦列表 + */ + focus(): void { + this.list.focus(); + } + + /** + * 渲染 + */ + render(): void { + this.container.screen.render(); + } + + /** + * 销毁 + */ + destroy(): void { + this.container.destroy(); + } + + /** + * 获取容器元素 + */ + getElement(): Widgets.BoxElement { + return this.container; + } +} diff --git a/packages/cli/src/tui/components/StatusBar.ts b/packages/cli/src/tui/components/StatusBar.ts new file mode 100644 index 0000000..14fa872 --- /dev/null +++ b/packages/cli/src/tui/components/StatusBar.ts @@ -0,0 +1,105 @@ +/** + * StatusBar Component + * + * 显示状态栏 + */ + +import blessed from 'blessed'; +import type { Widgets } from 'blessed'; + +export interface StatusBarOptions { + parent: Widgets.Screen; + bottom: number; +} + +export interface StatusInfo { + serverUrl: string; + connected: boolean; + sessionId?: string; + sessionName?: string; + agentAvailable: boolean; +} + +export class StatusBar { + private container: Widgets.BoxElement; + private status: StatusInfo = { + serverUrl: '', + connected: false, + agentAvailable: false, + }; + + constructor(options: StatusBarOptions) { + this.container = blessed.box({ + parent: options.parent, + bottom: options.bottom, + left: 0, + right: 0, + height: 1, + tags: true, + style: { + bg: 'blue', + fg: 'white', + }, + }); + + this.updateContent(); + } + + /** + * 更新状态 + */ + setStatus(status: Partial): void { + this.status = { ...this.status, ...status }; + this.updateContent(); + } + + /** + * 更新显示内容 + */ + private updateContent(): void { + const parts: string[] = []; + + // 连接状态 + const connIcon = this.status.connected ? '{green-fg}●{/green-fg}' : '{red-fg}●{/red-fg}'; + parts.push(`${connIcon} ${this.status.serverUrl || 'Not connected'}`); + + // Agent 状态 + if (this.status.connected) { + const agentIcon = this.status.agentAvailable ? '{green-fg}✓{/green-fg}' : '{yellow-fg}✗{/yellow-fg}'; + parts.push(`Agent: ${agentIcon}`); + } + + // 当前会话 + if (this.status.sessionId) { + const name = this.status.sessionName || this.status.sessionId.slice(0, 8); + parts.push(`Session: ${name}`); + } + + // 快捷键提示 + parts.push('{gray-fg}Tab: Switch q: Quit{/gray-fg}'); + + this.container.setContent(' ' + parts.join(' │ ')); + this.render(); + } + + /** + * 渲染 + */ + render(): void { + this.container.screen.render(); + } + + /** + * 销毁 + */ + destroy(): void { + this.container.destroy(); + } + + /** + * 获取容器元素 + */ + getElement(): Widgets.BoxElement { + return this.container; + } +} diff --git a/packages/cli/src/tui/components/index.ts b/packages/cli/src/tui/components/index.ts new file mode 100644 index 0000000..39f66c2 --- /dev/null +++ b/packages/cli/src/tui/components/index.ts @@ -0,0 +1,7 @@ +/** + * TUI Components Export + */ + +export { ChatView, type ChatViewOptions } from './ChatView.js'; +export { SessionList, type SessionListOptions } from './SessionList.js'; +export { StatusBar, type StatusBarOptions, type StatusInfo } from './StatusBar.js'; diff --git a/packages/cli/src/tui/index.ts b/packages/cli/src/tui/index.ts new file mode 100644 index 0000000..7bb1695 --- /dev/null +++ b/packages/cli/src/tui/index.ts @@ -0,0 +1,7 @@ +/** + * TUI Module Export + */ + +export { TUIApp } from './App.js'; +export { ChatView, SessionList, StatusBar } from './components/index.js'; +export type { TUIConfig, TUIState, ChatMessage, TUIEvent, TUIEventType } from './types.js'; diff --git a/packages/cli/src/tui/types.ts b/packages/cli/src/tui/types.ts new file mode 100644 index 0000000..cd4d2b0 --- /dev/null +++ b/packages/cli/src/tui/types.ts @@ -0,0 +1,45 @@ +/** + * TUI Types + */ + +import type { APIClient, Session, Message, HealthStatus } from '../client/api.js'; + +export interface TUIConfig { + client: APIClient; + sessionId?: string; +} + +export interface TUIState { + sessions: Session[]; + currentSession: Session | null; + messages: Message[]; + health: HealthStatus | null; + isConnected: boolean; + isLoading: boolean; + error: string | null; +} + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: string; + isStreaming?: boolean; +} + +export type TUIEventType = + | 'session:select' + | 'session:create' + | 'session:delete' + | 'message:send' + | 'message:chunk' + | 'message:done' + | 'connection:open' + | 'connection:close' + | 'connection:error' + | 'quit'; + +export interface TUIEvent { + type: TUIEventType; + payload?: T; +} diff --git a/packages/web/src/styles/index.css b/packages/web/src/styles/index.css index b7e27d9..4035566 100644 --- a/packages/web/src/styles/index.css +++ b/packages/web/src/styles/index.css @@ -23,7 +23,7 @@ /* Message content */ .message-content { - @apply prose prose-invert max-w-none; + @apply max-w-none text-gray-100; } .message-content pre { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 960edbf..43f98ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@ai-assistant/server': specifier: workspace:* version: link:../server + blessed: + specifier: ^0.1.81 + version: 0.1.81 chalk: specifier: ^5.3.0 version: 5.6.2 @@ -30,6 +33,9 @@ importers: specifier: ^8.0.1 version: 8.2.0 devDependencies: + '@types/blessed': + specifier: ^0.1.25 + version: 0.1.27 '@types/bun': specifier: ^1.1.0 version: 1.3.4 @@ -808,6 +814,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/blessed@0.1.27': + resolution: {integrity: sha512-ZOQGjLvWDclAXp0rW5iuUBXeD6Gr1PkitN7tj7/G8FCoSzTsij6OhXusOzMKhwrZ9YlL2Pmu0d6xJ9zVvk+Hsg==} + '@types/bun@1.3.4': resolution: {integrity: sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==} @@ -967,6 +976,11 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blessed@0.1.81: + resolution: {integrity: sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==} + engines: {node: '>= 0.8.0'} + hasBin: true + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2411,6 +2425,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/blessed@0.1.27': + dependencies: + '@types/node': 22.19.2 + '@types/bun@1.3.4': dependencies: bun-types: 1.3.4 @@ -2592,6 +2610,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + blessed@0.1.81: {} + braces@3.0.3: dependencies: fill-range: 7.1.1