agent-Specialization/cli/src/api.ts
JOJO cc9df7959a feat(cli): 新增 React/Ink CLI 端,修复全屏布局与光标定位
## 新增:基于 React/Ink 的 CLI 终端(cli/)

使用 Ink 框架构建完整的终端 UI,取代原有的纯 print 交互方式:

- App.tsx:主应用组件,管理对话状态、任务轮询、键盘输入、slash 命令
- components.tsx:Timeline、Composer、StatusLine、Picker、SlashMenu 等全部 UI 组件
- eventMapper.ts:将后端 SSE 事件流映射为 Timeline 条目,支持 thinking/tool/assistant/sub_agent 等类型
- api.ts:封装所有后端 REST 接口(任务、对话、模型、权限、工作区等)
- commands.ts:slash 命令定义(/new /resume /model /permission /execution /workspace /compact /tools 等)
- workspaces.ts:本地工作区目录的持久化管理
- types.ts:所有共享类型定义
- format.ts:context 格式化、路径缩写等工具函数

## 重大修复:去除固定高度全屏模式,恢复滚轮滚动

**问题根因**:根 Box 设置了 `height={stdout.rows}` 使 Ink 进入原地重绘模式
(类似 vim/htop),所有内容在同一块屏幕区域反复擦写,永远不会真正
"滚出"屏幕顶部,导致 terminal 的 scrollback buffer 始终为空,鼠标
滚轮无任何响应。

**修复方案**:
- 移除根 Box 的 `height` 约束和 `overflow="hidden"`
- 移除内层 Box 的 `flexGrow`/`justifyContent="flex-end"` 全屏布局
- Timeline 不再做行数截断,直接渲染全部条目
- 内容自然流入 terminal scrollback buffer,鼠标滚轮恢复正常

**同步清理**:删除 `getTimelineMaxRows`、`estimateTimelineRows`、
`scrollOffset` 状态及方向键滚动逻辑(这些都是全屏模式下的补丁,
去除固定高度后不再需要)

## 修复:Composer 光标位置偏移一行

`setCursorPosition` 中 y 轴使用 `lines.length` 导致单行时偏移 +1。
改为 `lines.length - 1`,与实际最后一行渲染位置对齐。

## 其他

- AGENTS.md / README.md:补充 CLI 启动方式与架构说明
- server/chat.py:补充任务轮询接口所需字段
- package.json:新增 cli 相关脚本入口
- docs/cli_slash_commands_spec.md、docs/cli_ui_display_spec.md:CLI 设计文档

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:06:51 +08:00

314 lines
11 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import { basename } from 'node:path';
import type { ExecutionMode, ModelDefinition, PermissionMode, RunMode, Workspace } from './types.js';
export type ApiConfig = {
baseUrl: string;
repoRoot: string;
cwd: string;
port: number;
};
export type TaskPollResult = {
task_id: string;
status: string;
error?: string;
conversation_id?: string;
events: Array<{ idx: number; type: string; data: any; ts?: number }>;
next_offset: number;
};
export class ApiClient {
private cookie = '';
private csrfToken = '';
private serverProcess: ChildProcess | null = null;
constructor(private readonly config: ApiConfig) {}
get baseUrl(): string {
return this.config.baseUrl;
}
async ensureConnected(): Promise<void> {
const ok = await this.tryFetchCsrf();
if (!ok) {
this.startServer();
await this.waitForServer();
await this.fetchCsrf();
}
await this.hostLogin();
await this.fetchCsrf();
}
private startServer(): void {
if (this.serverProcess) return;
const env = {
...process.env,
TERMINAL_SANDBOX_MODE: 'host',
HOST_PROJECT_PATH: this.config.cwd,
WEB_SERVER_PORT: String(this.config.port),
AGENTS_CLI_STARTED_SERVER: '1',
};
this.serverProcess = spawn(
process.execPath.includes('node') ? 'python3' : 'python3',
['-m', 'server.app', '--path', this.config.cwd, '--port', String(this.config.port), '--thinking-mode'],
{
cwd: this.config.repoRoot,
env,
detached: true,
stdio: 'ignore',
},
);
this.serverProcess.unref();
}
private async waitForServer(): Promise<void> {
const deadline = Date.now() + 20_000;
let lastError: unknown;
while (Date.now() < deadline) {
const ok = await this.tryFetchCsrf().catch((err) => {
lastError = err;
return false;
});
if (ok) return;
await sleep(500);
}
throw new Error(`无法连接本地 Web 服务:${String(lastError || 'timeout')}`);
}
private async tryFetchCsrf(): Promise<boolean> {
try {
await this.fetchCsrf();
return true;
} catch {
return false;
}
}
async fetchCsrf(): Promise<string> {
const data = await this.request<{ token: string }>('/api/csrf-token', { method: 'GET', skipCsrf: true, allowUnauthorized: true });
this.csrfToken = data.token;
return this.csrfToken;
}
async hostLogin(): Promise<void> {
await this.request('/host-login', { method: 'POST' });
}
async getStatus(): Promise<any> {
return this.request('/api/status', { method: 'GET' });
}
async listModels(): Promise<ModelDefinition[]> {
const res = await this.request<{ items?: ModelDefinition[]; data?: any }>('/api/v1/models', { method: 'GET' });
return res.items || res.data?.items || [];
}
async getPersonalization(): Promise<any> {
return this.request('/api/personalization', { method: 'GET' });
}
async listWorkspaces(): Promise<{ current_workspace_id: string; default_workspace_id: string; workspaces: Workspace[] }> {
const res = await this.request<{ data: any }>('/api/host/workspaces', { method: 'GET' });
return res.data;
}
async createWorkspace(path: string, label = basename(path)): Promise<{ workspace: Workspace; created: boolean }> {
const res = await this.request<{ data: any }>('/api/host/workspaces/create', {
method: 'POST',
body: { path, label },
});
return res.data;
}
async selectWorkspace(workspaceId: string): Promise<void> {
await this.request('/api/host/workspaces/select', {
method: 'POST',
body: { workspace_id: workspaceId },
});
}
async createConversation(): Promise<any> {
return this.request('/api/conversations', { method: 'POST', body: {} });
}
async listConversations(limit = 20): Promise<any[]> {
const res = await this.request<{ data: any }>(`/api/conversations?limit=${limit}`, { method: 'GET' });
return res.data?.conversations || res.data || [];
}
async loadConversation(conversationId: string): Promise<any> {
return this.request(`/api/conversations/${encodeURIComponent(conversationId)}/load`, { method: 'PUT', body: {} });
}
async getCurrentConversation(): Promise<any> {
const res = await this.request<{ data: any }>('/api/conversations/current', { method: 'GET' });
return res.data;
}
async compressConversation(conversationId: string): Promise<any> {
return this.request(`/api/conversations/${encodeURIComponent(conversationId)}/compress`, { method: 'POST', body: {} });
}
async setModel(modelKey: string): Promise<any> {
return this.request('/api/model', { method: 'POST', body: { model_key: modelKey } });
}
async setRunMode(mode: RunMode): Promise<any> {
return this.request('/api/thinking-mode', { method: 'POST', body: { mode } });
}
async getPermissionMode(): Promise<any> {
return this.request('/api/permission-mode', { method: 'GET' });
}
async setPermissionMode(mode: PermissionMode): Promise<any> {
return this.request('/api/permission-mode', { method: 'POST', body: { mode } });
}
async getExecutionMode(): Promise<any> {
return this.request('/api/execution-mode', { method: 'GET' });
}
async getConversationVersioning(conversationId: string): Promise<any> {
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/versioning`, { method: 'GET' });
return res.data;
}
async setConversationVersioning(conversationId: string, enabled: boolean, trackingMode?: string): Promise<any> {
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/versioning`, {
method: 'POST',
body: { enabled, ...(trackingMode ? { tracking_mode: trackingMode } : {}) },
});
return res.data;
}
async getConversationTokens(conversationId: string): Promise<any> {
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/tokens`, { method: 'GET' });
return res.data;
}
async listSubAgents(): Promise<any[]> {
const res = await this.request<{ data: any[] }>('/api/sub_agents', { method: 'GET' });
return Array.isArray(res.data) ? res.data : [];
}
async listBackgroundCommands(): Promise<any[]> {
const res = await this.request<{ data: any[] }>('/api/background_commands', { method: 'GET' });
return Array.isArray(res.data) ? res.data : [];
}
async setExecutionMode(mode: ExecutionMode): Promise<any> {
return this.request('/api/execution-mode', { method: 'POST', body: { mode } });
}
async getToolSettings(): Promise<any> {
return this.request('/api/tool-settings', { method: 'GET' });
}
async setToolCategory(category: string, enabled: boolean): Promise<any> {
return this.request('/api/tool-settings', { method: 'POST', body: { category, enabled } });
}
async getPathAuthorization(): Promise<any> {
return this.request('/api/path-authorization', { method: 'GET' });
}
async setPathAuthorization(writablePaths: string[], readableExtraPaths: string[]): Promise<any> {
return this.request('/api/path-authorization', {
method: 'POST',
body: { writable_paths: writablePaths, readable_extra_paths: readableExtraPaths },
});
}
async createTask(payload: { message: string; conversation_id?: string; model_key?: string; run_mode?: RunMode }): Promise<any> {
const res = await this.request<{ data: any }>('/api/tasks', {
method: 'POST',
body: payload,
});
return res.data;
}
async pollTask(taskId: string, offset: number): Promise<TaskPollResult> {
const res = await this.request<{ data: TaskPollResult }>(`/api/tasks/${encodeURIComponent(taskId)}?from=${offset}`, { method: 'GET' });
return res.data;
}
async cancelTask(taskId: string): Promise<void> {
await this.request(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST', body: {} });
}
async decideApproval(approvalId: string, decision: 'approved' | 'rejected'): Promise<any> {
return this.request(`/api/tool-approvals/${encodeURIComponent(approvalId)}/decision`, {
method: 'POST',
body: { decision },
});
}
private async request<T = any>(path: string, options: { method: string; body?: any; skipCsrf?: boolean; allowUnauthorized?: boolean }): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (this.cookie) headers.Cookie = this.cookie;
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
if (!options.skipCsrf && options.method !== 'GET' && this.csrfToken) headers['X-CSRF-Token'] = this.csrfToken;
const response = await fetch(`${this.config.baseUrl}${path}`, {
method: options.method,
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
redirect: 'manual',
});
this.captureCookies(response);
if (response.status === 401 && !options.allowUnauthorized) {
await this.fetchCsrf();
await this.hostLogin();
return this.request(path, { ...options, allowUnauthorized: true });
}
const text = await response.text();
let data: any = {};
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { success: false, error: text };
}
}
if (!response.ok || data?.success === false) {
throw new Error(data?.error || data?.message || `HTTP ${response.status}`);
}
return data as T;
}
private captureCookies(response: Response): void {
const anyHeaders = response.headers as any;
const rawCookies: string[] = typeof anyHeaders.getSetCookie === 'function'
? anyHeaders.getSetCookie()
: [];
const fallback = response.headers.get('set-cookie');
if (fallback) rawCookies.push(fallback);
if (!rawCookies.length) return;
const current = new Map<string, string>();
for (const part of this.cookie.split(';')) {
const trimmed = part.trim();
if (!trimmed) continue;
const eq = trimmed.indexOf('=');
if (eq > 0) current.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
}
for (const raw of rawCookies) {
const cookiePair = raw.split(';', 1)[0];
const eq = cookiePair.indexOf('=');
if (eq > 0) current.set(cookiePair.slice(0, eq), cookiePair.slice(eq + 1));
}
this.cookie = Array.from(current.entries()).map(([key, value]) => `${key}=${value}`).join('; ');
}
}
export function createDefaultApiClient(cwd: string, repoRoot: string): ApiClient {
const port = Number(process.env.AGENTS_API_PORT || process.env.WEB_SERVER_PORT || 8091);
const baseUrl = process.env.AGENTS_API_BASE || `http://127.0.0.1:${port}`;
return new ApiClient({ baseUrl, repoRoot, cwd, port });
}