## 新增:基于 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>
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve, basename } from 'node:path';
|
|
import type { Workspace, WorkspaceCatalog } from './types.js';
|
|
|
|
export const repoRoot = process.env.AGENTS_REPO_ROOT || resolve(new URL('../..', import.meta.url).pathname);
|
|
const configPath = resolve(repoRoot, 'config/host_workspaces.json');
|
|
|
|
function defaultCatalog(): WorkspaceCatalog {
|
|
const fallbackPath = resolve(repoRoot, 'project');
|
|
return {
|
|
default_workspace_id: 'default',
|
|
workspaces: [
|
|
{
|
|
workspace_id: 'default',
|
|
label: '默认工作区',
|
|
path: fallbackPath,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function normalizePath(path: string): string {
|
|
return resolve(path);
|
|
}
|
|
|
|
function slugify(value: string): string {
|
|
const slug = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/-{2,}/g, '-')
|
|
.replace(/^[-._]+|[-._]+$/g, '');
|
|
return slug || 'workspace';
|
|
}
|
|
|
|
export function getHostWorkspacesConfigPath(): string {
|
|
return configPath;
|
|
}
|
|
|
|
export function loadWorkspaceCatalog(): WorkspaceCatalog {
|
|
try {
|
|
const raw = JSON.parse(readFileSync(configPath, 'utf8')) as Partial<WorkspaceCatalog>;
|
|
const workspaces = Array.isArray(raw.workspaces) ? raw.workspaces : [];
|
|
const normalized = workspaces
|
|
.filter((item): item is Workspace => Boolean(item && item.workspace_id && item.path))
|
|
.map((item) => ({
|
|
workspace_id: String(item.workspace_id),
|
|
label: String(item.label || item.workspace_id),
|
|
path: normalizePath(String(item.path)),
|
|
}));
|
|
if (!normalized.length) return defaultCatalog();
|
|
const defaultId = raw.default_workspace_id && normalized.some((ws) => ws.workspace_id === raw.default_workspace_id)
|
|
? String(raw.default_workspace_id)
|
|
: normalized[0]!.workspace_id;
|
|
return { default_workspace_id: defaultId, workspaces: normalized };
|
|
} catch {
|
|
return defaultCatalog();
|
|
}
|
|
}
|
|
|
|
export function saveWorkspaceCatalog(catalog: WorkspaceCatalog): void {
|
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
writeFileSync(configPath, JSON.stringify(catalog, null, 2), 'utf8');
|
|
}
|
|
|
|
export function findWorkspaceByPath(catalog: WorkspaceCatalog, cwd: string): Workspace | undefined {
|
|
const target = normalizePath(cwd);
|
|
return catalog.workspaces.find((ws) => normalizePath(ws.path) === target);
|
|
}
|
|
|
|
export function createWorkspaceForPath(catalog: WorkspaceCatalog, cwd: string): { catalog: WorkspaceCatalog; workspace: Workspace; created: boolean } {
|
|
const path = normalizePath(cwd);
|
|
const existing = findWorkspaceByPath(catalog, path);
|
|
if (existing) return { catalog, workspace: existing, created: false };
|
|
|
|
const baseLabel = basename(path) || 'workspace';
|
|
const baseId = slugify(baseLabel);
|
|
const used = new Set(catalog.workspaces.map((ws) => ws.workspace_id));
|
|
let workspaceId = baseId;
|
|
let suffix = 2;
|
|
while (used.has(workspaceId)) {
|
|
workspaceId = `${baseId}-${suffix}`;
|
|
suffix += 1;
|
|
}
|
|
|
|
const workspace: Workspace = {
|
|
workspace_id: workspaceId,
|
|
label: baseLabel,
|
|
path,
|
|
};
|
|
const nextCatalog: WorkspaceCatalog = {
|
|
default_workspace_id: catalog.default_workspace_id || workspaceId,
|
|
workspaces: [...catalog.workspaces, workspace],
|
|
};
|
|
saveWorkspaceCatalog(nextCatalog);
|
|
return { catalog: nextCatalog, workspace, created: true };
|
|
}
|