feat: 实现 TUI 组件系统
- 添加 blessed 终端 UI 库 - 创建 ChatView 组件:支持消息列表和流式输出 - 创建 SessionList 组件:会话管理和快捷键 - 创建 StatusBar 组件:连接状态显示 - 创建 TUIApp 主应用整合所有组件 - 更新 attach 命令支持 --tui/--no-tui 选项 - 添加 CLAUDE.md 项目规范文件 - 修复 Web 前端 CSS prose 类缺失问题
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -16,8 +16,10 @@ export function registerAttachCommand(program: Command): void {
|
||||
.argument('<url>', 'Server URL (e.g., http://192.168.1.100:3000)')
|
||||
.option('-t, --token <token>', 'Authentication token')
|
||||
.option('-s, --session <id>', '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');
|
||||
|
||||
@@ -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<void> {
|
||||
// 获取服务器健康状态
|
||||
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<void> {
|
||||
try {
|
||||
const { data: sessions } = await this.client.listSessions();
|
||||
this.sessionList.setSessions(sessions);
|
||||
} catch (error) {
|
||||
this.showError('Failed to load sessions');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*/
|
||||
private async createSession(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 断开现有连接
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<StatusInfo>): 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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<T = unknown> {
|
||||
type: TUIEventType;
|
||||
payload?: T;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Generated
+20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user