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>
This commit is contained in:
JOJO 2026-05-16 01:06:51 +08:00
parent e2bbd767f7
commit cc9df7959a
23 changed files with 6251 additions and 2 deletions

View File

@ -1,6 +1,6 @@
# Repository Guidelines (Code-Verified)
> Last verified against current codebase: 2026-05-11
> Last verified against current codebase: 2026-05-15
> Scope: `/Users/jojo/Desktop/agents/正在修复中/agents`
这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。
@ -19,10 +19,14 @@
- `utils/`: API client、日志、上下文与对话工具等公共函数
- **前端目录**
- `static/src/`: Vue 3 + TS 前端
- `cli/src/`: React 19 + Ink 6 + TypeScript CLI 前端(正在重写中)
- 监控动画相关核心文件:
- `static/src/components/chat/monitor/MonitorDirector.ts`
- `static/src/stores/monitor.ts`
- `static/src/components/chat/monitor/*`
- **CLI 相关文档**
- `docs/cli_ui_display_spec.md`: CLI 显示层设计说明
- `docs/cli_slash_commands_spec.md`: CLI `/` 指令设计说明
- **其他子项目/资源**
- `android-webview-app/`: Android WebView 客户端工程
- `easyagent/`: 独立前端子目录(与根目录前端并存)
@ -42,18 +46,32 @@
- 开发监听(当前脚本是 build watch`npm run dev`
- Lint`npm run lint`
### CLIReact / Ink
- 安装依赖:`npm --prefix cli install`
- 开发启动:`npm run cli` 或 `npm --prefix cli run dev`
- 构建:`npm run cli:build`
- 类型检查:`npm run cli:typecheck`
- 可执行命令名(构建后):`agents` / `agents-cli`
## 3) 测试现状(不要再写过时命令)
- 当前仓库内可见自动化冒烟:`test/test_server_refactor_smoke.py``unittest`
- 运行方式:`python -m unittest test.test_server_refactor_smoke`
- `test_system_message.py` 依赖外部 `MOONSHOT_API_KEY` 与网络,不属于离线稳定 CI 用例。
- 当前仓库未发现 `pytest.ini`/`pyproject.toml`/`tox.ini`;不要默认要求 `pytest` 作为唯一入口。
- CLI 当前最小可复现验证:
- `npm --prefix cli run typecheck`
- `npm --prefix cli run build`
- 若改动 `server/chat.py` 等后端接口适配,补充:
- `python3 -m py_compile server/chat.py`
- `python -m unittest test.test_server_refactor_smoke`
## 4) 代码修改约定(实用版)
- **最小改动原则**:只改与需求直接相关文件,避免顺手重构。
- **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。
- **前端改动优先级**:按 `static/src` 现有分层改(`app/`、`stores/`、`components/`、`composables/`)。
- **CLI 改动优先级**:优先在 `cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts` 内做最小闭环修改。
- 涉及 monitor 动画/事件联动时,至少同步检查:
- `MonitorDirector.ts`(动画与场景执行)
- `stores/monitor.ts`(事件队列与状态机)
@ -62,9 +80,18 @@
- Python4 空格、`snake_case` 函数、`PascalCase` 类,新增公共函数尽量补 type hints。
- Vue/TS保持现有代码风格不做无关风格清洗。
- CLI React/TS保留现有 Ink 渲染方式与光标修正逻辑,不要轻易重写输入框定位策略。
- 日志:优先复用现有 logger/日志路径,不引入大量临时 `print`
- 提交前至少做与改动相关的最小验证(命令输出或手工步骤要可复现)。
### CLI 当前交互约束2026-05-15
- CLI 连接的是现有本地 Web API默认 `127.0.0.1:8091`),不是独立 agent runtime。
- 启动 CLI 时应清屏、连接本地服务、创建新会话,并将输入区固定在底部。
- 当前目录若不在工作区中,应先弹出“是否添加到工作区”的选择。
- 思考内容当前默认隐藏,只显示“思考中 / 思考完成”标题;相关折叠代码保留,后续可继续修。
- 不要在未获得用户要求的情况下运行交互式 TUI 压测或长时间模拟输入,以免刷屏占满上下文。
## 6) Git 工作流(开发 + Review
### 6.1 核心原则

View File

@ -2,6 +2,66 @@
一个功能完整的 AI 智能体系统,支持多模态交互、子智能体协作、实时终端操作和丰富的工具集成。
## 当前状态2026-05
- Web 端仍然是主线入口,基于 Flask + Vue 3。
- 新 CLI 端已开始重写,位于 `cli/`,技术栈为 React 19 + Ink 6 + TypeScript。
- 当前 CLI 不是独立后端;它会直接连接本地 `8091` Web API并复用现有会话、任务、权限、工作区等接口。
- CLI 已实现“启动即连接后端并新建会话”的流程,但仍处于持续打磨阶段,输入法、光标、时间线裁剪等终端细节仍在迭代。
## CLI 快速开始
### 安装与构建
```bash
npm install
npm --prefix cli install
npm run cli:build
```
### 开发启动
```bash
npm run cli
```
或直接:
```bash
npm --prefix cli run dev
```
### 在目标目录启动 CLI
```bash
cd /path/to/your/project
npm --prefix /Users/jojo/Desktop/agents/正在修复中/agents/cli run dev
```
说明:
- CLI 会默认把“当前终端目录”视为目标工作目录。
- 若当前目录尚未加入工作区,启动后会先提示是否将该目录加入工作区。
- CLI 会优先连接 `http://127.0.0.1:8091`;若本地服务未启动,会尝试拉起:
```bash
python3 -m server.app --path <当前目录> --port 8091 --thinking-mode
```
### 当前 CLI 已支持的能力
- 启动即创建会话,不必等首条消息发送后再建会话
- `/new` `/resume` `/model` `/versioning` `/permission` `/execution`
- `/workspace` `/compact` `/tools` `/path` `/skill` `/status` `/help` `/clear` `/exit`
- 基于任务轮询的时间线展示:用户消息、工具调用、模型输出、审批、后台状态
- 宿主机工作区接入、版本控制开关、权限模式与执行环境切换
### 当前 CLI 已知限制
- 默认隐藏思考内容,只显示“思考中 / 思考完成”标题
- 时间线布局、终端输入法光标、长内容换行等细节仍在持续修正
- 目前更适合本地开发与人工交互,不建议当作完全稳定的自动化终端入口
## 核心特性
### 🎯 多模式运行

3
cli-react-demo/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.easyagent/

226
cli-react-demo/demo.jsx Normal file
View File

@ -0,0 +1,226 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {render, Box, Text, useApp, useInput} from 'ink';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const nowTime = () => new Date().toLocaleTimeString('zh-CN', {hour12: false});
function Header({running, turn}) {
return (
<Box flexDirection="column" marginBottom={1}>
<Text color="cyan" bold>Agents CLI React/Ink Demo</Text>
<Text color="gray">
React 组件渲染到终端 · 支持粘贴多行 · 输入后回车发送 · Ctrl+C 取消当前任务 · /help /exit
</Text>
<Box gap={2}>
<Text>状态: <Text color={running ? 'yellow' : 'green'}>{running ? '运行中' : '空闲'}</Text></Text>
<Text>Turn: <Text color="magenta">#{turn}</Text></Text>
</Box>
</Box>
);
}
function Message({item}) {
if (item.type === 'user') {
return (
<Box marginTop={1}>
<Text color="blue" bold></Text>
<Text color="gray"> {item.time} </Text>
<Text>{item.text}</Text>
</Box>
);
}
if (item.type === 'assistant') {
return (
<Box marginTop={1} flexDirection="column">
<Text color="green" bold>助手 <Text color="gray">{item.time}</Text></Text>
<Text>{item.text || <Text color="gray">正在生成...</Text>}</Text>
</Box>
);
}
if (item.type === 'tool') {
const statusColor = item.status === 'done' ? 'green' : item.status === 'running' ? 'yellow' : 'red';
return (
<Box marginTop={1} borderStyle="round" borderColor={statusColor} paddingX={1} flexDirection="column">
<Text color={statusColor} bold>{item.status === 'done' ? '✓' : '●'} 工具调用{item.name}</Text>
<Text color="gray">{item.detail}</Text>
{item.output ? <Text>{item.output}</Text> : null}
</Box>
);
}
if (item.type === 'system') {
return <Text color="gray"> {item.text}</Text>;
}
return null;
}
function Transcript({items}) {
const visible = items.slice(-10);
return (
<Box flexDirection="column" minHeight={12}>
{visible.map(item => <Message key={item.id} item={item} />)}
</Box>
);
}
function Composer({value, running}) {
return (
<Box flexDirection="column" marginTop={1}>
<Box borderStyle="single" borderColor={running ? 'yellow' : 'cyan'} paddingX={1}>
<Text color="cyan"> </Text>
<Text>{value}</Text>
<Text inverse> </Text>
</Box>
<Text color="gray">快捷键Enter 发送 · 直接粘贴可保留多行 · Backspace 删除 · Ctrl+C {running ? '取消任务' : '退出'} · /help 查看命令</Text>
</Box>
);
}
function App() {
const {exit} = useApp();
const [input, setInput] = useState('');
const [items, setItems] = useState(() => [
{id: 'welcome', type: 'system', text: '这是一个本地 React/Ink CLI demo消息区、工具卡片、输入框和状态栏都是 React 组件。'}
]);
const [running, setRunning] = useState(false);
const [turn, setTurn] = useState(0);
const cancelRef = useRef(false);
const nextId = useRef(1);
const append = useCallback(item => {
setItems(prev => [...prev, {id: String(nextId.current++), ...item}]);
}, []);
const patchLastAssistant = useCallback(delta => {
setItems(prev => {
const copy = [...prev];
for (let i = copy.length - 1; i >= 0; i--) {
if (copy[i].type === 'assistant') {
copy[i] = {...copy[i], text: (copy[i].text || '') + delta};
break;
}
}
return copy;
});
}, []);
const markLastToolDone = useCallback(output => {
setItems(prev => {
const copy = [...prev];
for (let i = copy.length - 1; i >= 0; i--) {
if (copy[i].type === 'tool' && copy[i].status === 'running') {
copy[i] = {...copy[i], status: 'done', output};
break;
}
}
return copy;
});
}, []);
const runFakeAgent = useCallback(async prompt => {
setRunning(true);
cancelRef.current = false;
const currentTurn = turn + 1;
setTurn(currentTurn);
append({type: 'user', text: prompt, time: nowTime()});
if (prompt === '/help') {
append({type: 'assistant', time: nowTime(), text: '可用命令:/help、/clear、/exit。普通文本会触发一段模拟 Agent 流程。现在可以直接粘贴多行内容,换行会保留在输入框和提交内容中。'});
setRunning(false);
return;
}
if (prompt === '/clear') {
setItems([{id: 'cleared', type: 'system', text: '已清屏。'}]);
setRunning(false);
return;
}
if (prompt === '/exit') {
exit();
return;
}
append({type: 'assistant', time: nowTime(), text: ''});
const sentence = '我会先读取项目结构,然后模拟一次工具调用,最后给出结论。这些内容是流式追加的,不会破坏下方输入框。';
for (const char of sentence) {
if (cancelRef.current) {
patchLastAssistant('\n\n[已取消]');
setRunning(false);
return;
}
patchLastAssistant(char);
await sleep(28);
}
append({type: 'tool', status: 'running', name: 'list_files', detail: '扫描当前 workspace 的关键文件...'});
await sleep(900);
if (cancelRef.current) {
markLastToolDone('任务取消,工具调用未继续。');
setRunning(false);
return;
}
markLastToolDone('发现 main.py、server/tasks.py、core/main_terminal.py。');
append({type: 'assistant', time: nowTime(), text: ''});
const finalText = '结论CLI 可以像一个终端 App 一样工作。后续只需要把这里的模拟事件替换成 /api/tasks 的真实事件流。';
for (const char of finalText) {
if (cancelRef.current) {
patchLastAssistant('\n\n[已取消]');
setRunning(false);
return;
}
patchLastAssistant(char);
await sleep(24);
}
setRunning(false);
}, [append, exit, markLastToolDone, patchLastAssistant, turn]);
useInput((chunk, key) => {
if (key.ctrl && chunk === 'c') {
if (running) {
cancelRef.current = true;
append({type: 'system', text: '收到 Ctrl+C正在取消当前模拟任务...'});
} else {
exit();
}
return;
}
if (running) {
return;
}
if (key.return) {
const text = input.trim();
if (!text) return;
setInput('');
void runFakeAgent(text);
return;
}
if (key.backspace || key.delete) {
setInput(prev => Array.from(prev).slice(0, -1).join(''));
return;
}
if (chunk && !key.ctrl && !key.meta) {
// Ink bracketed paste chunk
// CRLF/CR LF Enter
const normalized = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
setInput(prev => prev + normalized);
}
});
return (
<Box flexDirection="column" paddingX={1}>
<Header running={running} turn={turn} />
<Transcript items={items} />
<Composer value={input} running={running} />
</Box>
);
}
render(<App />);

1067
cli-react-demo/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
{
"name": "agents-cli-react-demo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"demo": "tsx demo.jsx"
},
"dependencies": {
"ink": "^6.5.1",
"react": "^19.2.1",
"tsx": "^4.20.6"
}
}

2
cli/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

1660
cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
cli/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "agents-cli",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"agents": "./dist/main.js",
"agents-cli": "./dist/main.js"
},
"scripts": {
"dev": "tsx src/main.tsx",
"build": "esbuild src/main.tsx --bundle --platform=node --format=esm --target=node20 --outfile=dist/main.js --banner:js='#!/usr/bin/env node' && chmod +x dist/main.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"ink": "^6.5.1",
"react": "^19.2.1",
"react-devtools-core": "^6.1.5"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"esbuild": "^0.27.0",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

680
cli/src/App.tsx Normal file
View File

@ -0,0 +1,680 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, useApp, useInput, useStdout } from 'ink';
import { setTimeout as sleep } from 'node:timers/promises';
import { slashCommands, filterCommands } from './commands.js';
import { createWorkspaceForPath, findWorkspaceByPath, loadWorkspaceCatalog, repoRoot } from './workspaces.js';
import type { CliStatus, ExecutionMode, ModelDefinition, PermissionMode, RunMode, SkillDefinition, SlashCommand, TimelineItem, Workspace, WorkspaceCatalog } from './types.js';
import { nowSessionId, shortPath } from './format.js';
import { Composer, HelpPanel, Picker, SkillHint, SlashMenu, StatusLine, StatusPanel, Timeline, WelcomePanel, WorkspacePrompt } from './components.js';
import { ApiClient, createDefaultApiClient } from './api.js';
import { createEventRenderState, reduceTaskEvent } from './eventMapper.js';
type InputMode = 'workspace_prompt' | 'composer' | 'slash_menu' | 'picker' | 'status_panel' | 'help_panel' | 'approval';
type PickerOption = {
label: string;
description?: string;
disabled?: boolean;
onSelect: () => void | Promise<void>;
};
type ApprovalOption = {
label: string;
description?: string;
decision: 'approved' | 'rejected';
};
const cwd = process.cwd();
const initialCatalog = loadWorkspaceCatalog();
const matchedWorkspace = findWorkspaceByPath(initialCatalog, cwd);
function id(): string {
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function initialStatus(workspace: Workspace): CliStatus {
return {
model: '连接中',
runMode: 'thinking',
directory: workspace.path,
workspace,
permissionMode: 'auto_approval',
executionMode: 'sandbox',
sessionId: nowSessionId(),
contextUsed: 0,
contextLimit: 0,
versioningEnabled: false,
backgroundAgents: 0,
backgroundCommands: 0,
};
}
function workspaceFromApi(item: any): Workspace {
return {
workspace_id: String(item.workspace_id || item.id || item.label || 'workspace'),
label: String(item.label || item.workspace_id || 'workspace'),
path: String(item.path || ''),
};
}
export default function App() {
const { exit } = useApp();
const { stdout } = useStdout();
const apiRef = useRef<ApiClient | null>(null);
const runningTaskRef = useRef<string | null>(null);
const currentConversationRef = useRef<string | undefined>(undefined);
const bootstrappedRef = useRef(false);
const [catalog, setCatalog] = useState<WorkspaceCatalog>(initialCatalog);
const [mode, setMode] = useState<InputMode>(matchedWorkspace ? 'composer' : 'workspace_prompt');
const [workspacePromptIndex, setWorkspacePromptIndex] = useState(0);
const [input, setInput] = useState('');
const [slashIndex, setSlashIndex] = useState(0);
const [pickerTitle, setPickerTitle] = useState('');
const [pickerOptions, setPickerOptions] = useState<PickerOption[]>([]);
const [pickerIndex, setPickerIndex] = useState(0);
const [approvalTitle, setApprovalTitle] = useState('');
const [approvalId, setApprovalId] = useState('');
const [approvalOptions] = useState<ApprovalOption[]>([
{ label: '允许一次', decision: 'approved' },
{ label: '拒绝', decision: 'rejected' },
]);
const [approvalIndex, setApprovalIndex] = useState(0);
const [timeline, setTimeline] = useState<TimelineItem[]>([]);
const [status, setStatus] = useState<CliStatus>(() => initialStatus(matchedWorkspace || initialCatalog.workspaces[0]!));
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [running, setRunning] = useState(false);
const [runningStartedAt, setRunningStartedAt] = useState<number | null>(null);
const [nowMs, setNowMs] = useState(Date.now());
const [workingTextTick, setWorkingTextTick] = useState(0);
const [workingDotTick, setWorkingDotTick] = useState(0);
const [timelineDotTick, setTimelineDotTick] = useState(0);
const [models, setModels] = useState<ModelDefinition[]>([]);
const [skills, setSkills] = useState<SkillDefinition[]>([]);
const [permissionOptions, setPermissionOptions] = useState<PermissionMode[]>([]);
const [executionOptions, setExecutionOptions] = useState<ExecutionMode[]>([]);
const commandMatches = useMemo(() => filterCommands(input), [input]);
const runningSeconds = runningStartedAt ? Math.max(0, Math.floor((nowMs - runningStartedAt) / 1000)) : 0;
const hasRunningTimeline = useMemo(() => timeline.some((item) => item.status === 'running'), [timeline]);
useEffect(() => {
if (!running) return;
const timer = setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => clearInterval(timer);
}, [running]);
useEffect(() => {
if (!running) return;
const timer = setInterval(() => {
setWorkingTextTick((value) => value + 1);
}, 100);
return () => clearInterval(timer);
}, [running]);
useEffect(() => {
if (!running) return;
const timer = setInterval(() => {
setWorkingDotTick((value) => value + 1);
}, 800);
return () => clearInterval(timer);
}, [running]);
useEffect(() => {
if (!hasRunningTimeline) return;
let timer: ReturnType<typeof setInterval> | undefined;
const delay = setTimeout(() => {
setTimelineDotTick((value) => value + 1);
timer = setInterval(() => {
setTimelineDotTick((value) => value + 1);
}, 800);
}, 400);
return () => {
clearTimeout(delay);
if (timer) clearInterval(timer);
};
}, [hasRunningTimeline]);
useEffect(() => {
if (mode !== 'composer' || bootstrappedRef.current) return;
bootstrappedRef.current = true;
getApi()
.then((api) => bootstrapConversation(api))
.catch((err) => addItem({ kind: 'tool', title: '初始化失败', status: 'failed', body: String(err.message || err) }));
}, [mode]);
function addItem(item: Omit<TimelineItem, 'id'>) {
setTimeline((prev) => [...prev, { id: id(), ...item }]);
}
function patchStatusFromServer(raw: any, workspaceOverride?: Workspace) {
const execMode = typeof raw?.execution_mode === 'object' ? raw.execution_mode.mode : raw?.execution_mode;
const contextUsed = Number(raw?.context?.total_size || raw?.context_tokens || raw?.token_statistics?.current_context_tokens || 0);
setStatus((prev) => ({
...prev,
model: raw?.model_key || prev.model,
runMode: raw?.run_mode || prev.runMode,
directory: raw?.project_path || workspaceOverride?.path || prev.directory,
workspace: workspaceOverride || prev.workspace,
permissionMode: raw?.permission_mode || prev.permissionMode,
executionMode: execMode || prev.executionMode,
sessionId: raw?.conversation?.current_id || prev.sessionId,
contextUsed: Number.isFinite(contextUsed) && contextUsed > 0 ? contextUsed : prev.contextUsed,
}));
if (raw?.conversation?.current_id) currentConversationRef.current = raw.conversation.current_id;
}
async function getApi(): Promise<ApiClient> {
if (!apiRef.current) apiRef.current = createDefaultApiClient(cwd, repoRoot);
if (!connected && !connecting) {
setConnecting(true);
try {
await apiRef.current.ensureConnected();
setConnected(true);
} finally {
setConnecting(false);
}
} else if (!connected) {
while (!connected && connecting) await sleep(100);
}
return apiRef.current;
}
async function syncBackendState(api = apiRef.current) {
if (!api) return;
try {
const wsData = await api.listWorkspaces();
const workspaces = (wsData.workspaces || []).map(workspaceFromApi);
const current = workspaces.find((ws) => ws.workspace_id === wsData.current_workspace_id) || workspaces.find((ws) => ws.path === status.workspace.path) || status.workspace;
setCatalog({ default_workspace_id: wsData.default_workspace_id, workspaces });
const rawStatus = await api.getStatus();
patchStatusFromServer(rawStatus, current);
const conv = await api.getCurrentConversation().catch(() => null);
const convId = conv?.id || rawStatus?.conversation?.current_id;
if (convId) currentConversationRef.current = convId;
await syncRuntimeCatalogs(api, convId).catch(() => undefined);
} catch (err) {
addItem({ kind: 'tool', title: '同步后端状态失败', status: 'failed', body: String((err as Error).message || err) });
}
}
async function syncRuntimeCatalogs(api: ApiClient, conversationId?: string) {
const [modelItems, personalization, permission, execution, subAgents, backgroundCommands, tokens, versioning] = await Promise.all([
api.listModels().catch(() => []),
api.getPersonalization().catch(() => null),
api.getPermissionMode().catch(() => null),
api.getExecutionMode().catch(() => null),
api.listSubAgents().catch(() => []),
api.listBackgroundCommands().catch(() => []),
conversationId ? api.getConversationTokens(conversationId).catch(() => null) : Promise.resolve(null),
conversationId ? api.getConversationVersioning(conversationId).catch(() => null) : Promise.resolve(null),
]);
if (modelItems.length) setModels(modelItems);
const enabled = new Set((personalization?.data?.enabled_skills || []).map(String));
const skillItems = (personalization?.skills_catalog || [])
.filter((skill: any) => !enabled.size || enabled.has(String(skill.id)))
.map((skill: any) => ({ id: String(skill.id), label: String(skill.label || skill.id), description: String(skill.description || '') }));
if (skillItems.length) setSkills(skillItems);
const rawCompressionLimit = Number(
personalization?.context_compression_settings?.context_window_tokens
|| personalization?.context_compression_settings?.deep_trigger_tokens
|| personalization?.data?.deep_compress_trigger_tokens
|| 0,
);
const currentModel = modelItems.find((item) => item.model_key === status.model);
const modelWindow = Number(currentModel?.context_window || 0);
const compressionLimit = rawCompressionLimit > 0 && modelWindow > 0
? Math.min(rawCompressionLimit, modelWindow)
: rawCompressionLimit || modelWindow;
if (Array.isArray(permission?.options)) setPermissionOptions(permission.options);
if (Array.isArray(execution?.options)) setExecutionOptions(execution.options);
const runningAgents = subAgents.filter((item: any) => !['completed', 'failed', 'cancelled', 'terminated', 'timeout'].includes(String(item.status || '').toLowerCase())).length;
const runningCommands = backgroundCommands.filter((item: any) => !['completed', 'failed', 'cancelled', 'timeout'].includes(String(item.status || '').toLowerCase())).length;
const tokenUsed = Number(tokens?.total_tokens || 0);
setStatus((prev) => ({
...prev,
contextUsed: Number.isFinite(tokenUsed) && tokenUsed > 0 ? tokenUsed : prev.contextUsed,
contextLimit: Number.isFinite(compressionLimit) && compressionLimit > 0 ? compressionLimit : prev.contextLimit,
versioningEnabled: versioning ? Boolean(versioning.enabled) : prev.versioningEnabled,
backgroundAgents: runningAgents,
backgroundCommands: runningCommands,
}));
}
async function bootstrapConversation(api: ApiClient) {
const result = await api.createConversation();
const convId = result.conversation_id || result.data?.conversation_id || result.data?.id || result.id;
if (convId) {
currentConversationRef.current = String(convId);
setStatus((prev) => ({ ...prev, sessionId: String(convId), contextUsed: 0 }));
await syncBackendState(api);
await syncRuntimeCatalogs(api, String(convId)).catch(() => undefined);
}
}
function openPicker(title: string, options: PickerOption[]) {
setPickerTitle(title);
setPickerOptions(options);
setPickerIndex(0);
setMode('picker');
}
function closeOverlay() {
setMode('composer');
setInput('');
setSlashIndex(0);
setPickerIndex(0);
}
async function executeCommand(command: SlashCommand) {
const name = command.name;
try {
if (name === '/new') {
const api = await getApi();
const result = await api.createConversation();
const convId = result.conversation_id || result.data?.conversation_id;
currentConversationRef.current = convId;
setTimeline([]);
setStatus((prev) => ({ ...prev, sessionId: convId || nowSessionId(), contextUsed: 0 }));
if (convId) await syncRuntimeCatalogs(api, String(convId)).catch(() => undefined);
closeOverlay();
return;
}
if (name === '/resume') {
const api = await getApi();
const conversations = await api.listConversations(30);
openPicker('Resume conversation', conversations.map((conv: any) => ({
label: String(conv.title || conv.id || '未命名对话'),
description: String(conv.updated_at || conv.created_at || conv.id || ''),
onSelect: async () => {
await api.loadConversation(String(conv.id));
currentConversationRef.current = String(conv.id);
setStatus((prev) => ({ ...prev, sessionId: String(conv.id) }));
await syncRuntimeCatalogs(api, String(conv.id)).catch(() => undefined);
addItem({ kind: 'system', body: `已切换对话:${conv.title || conv.id}` });
closeOverlay();
},
})));
return;
}
if (name === '/model') {
const api = await getApi();
const modelItems = (await api.listModels().catch(() => models)).filter((item) => item.model_key);
if (modelItems.length) setModels(modelItems);
const currentRunMode = status.runMode;
openPicker('Model', modelItems.map((model) => ({
label: model.name || model.model_key,
description: `${model.model_key === status.model ? 'current · ' : ''}${model.description || model.model_key}`,
onSelect: async () => {
await api.setModel(model.model_key);
setStatus((prev) => ({ ...prev, model: model.model_key, contextLimit: Number(model.context_window) || prev.contextLimit }));
addItem({ kind: 'system', body: `已切换模型:${model.name || model.model_key}` });
const modes = getRunModesForModel(model);
openPicker('Run Mode', modes.map((runMode) => ({
label: runMode,
description: runMode === currentRunMode ? 'current' : '',
onSelect: async () => {
await api.setRunMode(runMode);
setStatus((prev) => ({ ...prev, runMode }));
await syncBackendState(api);
addItem({ kind: 'system', body: `已切换运行模式:${runMode}` });
closeOverlay();
},
})));
},
})));
return;
}
if (name === '/versioning') {
const api = await getApi();
const convId = currentConversationRef.current || status.sessionId;
const current = await api.getConversationVersioning(convId).catch(() => null);
const enabled = current ? Boolean(current.enabled) : status.versioningEnabled;
const trackingMode = current?.tracking_mode;
openPicker('Versioning', [
{ label: enabled ? '关闭版本控制' : '开启版本控制', onSelect: async () => {
const result = await api.setConversationVersioning(convId, !enabled, trackingMode);
setStatus((prev) => ({ ...prev, versioningEnabled: Boolean(result.enabled) }));
addItem({ kind: 'system', body: `版本控制:${result.enabled ? '开' : '关'}` });
closeOverlay();
} },
]);
return;
}
if (name === '/permission') {
const api = await getApi();
const current = await api.getPermissionMode().catch(() => null);
const modes: PermissionMode[] = Array.isArray(current?.options) && current.options.length ? current.options : (permissionOptions.length ? permissionOptions : [status.permissionMode]);
openPicker('Permission Mode', modes.map((permissionMode) => ({ label: permissionMode, description: permissionMode === status.permissionMode ? 'current' : '', onSelect: async () => {
await api.setPermissionMode(permissionMode);
setStatus((prev) => ({ ...prev, permissionMode }));
addItem({ kind: 'system', body: `权限模式:${permissionMode}` });
closeOverlay();
} })));
return;
}
if (name === '/execution') {
const api = await getApi();
const current = await api.getExecutionMode().catch(() => null);
const modes: ExecutionMode[] = Array.isArray(current?.options) && current.options.length ? current.options : (executionOptions.length ? executionOptions : [status.executionMode]);
openPicker('Execution Environment', modes.map((executionMode) => ({ label: executionMode, description: executionMode === status.executionMode ? 'current' : executionMode === 'direct' ? '高风险' : '', onSelect: async () => {
await api.setExecutionMode(executionMode);
setStatus((prev) => ({ ...prev, executionMode }));
addItem({ kind: 'system', body: `执行环境:${executionMode}` });
closeOverlay();
} })));
return;
}
if (name === '/workspace') {
const api = await getApi();
const wsData = await api.listWorkspaces();
const workspaces = (wsData.workspaces || []).map(workspaceFromApi);
setCatalog({ default_workspace_id: wsData.default_workspace_id, workspaces });
openPicker('Workspace', workspaces.map((workspace) => ({ label: workspace.label, description: `${shortPath(workspace.path)}${workspace.workspace_id === wsData.current_workspace_id ? ' current' : ''}`, onSelect: async () => {
await api.selectWorkspace(workspace.workspace_id);
setStatus((prev) => ({ ...prev, workspace, directory: workspace.path }));
addItem({ kind: 'system', body: `已切换工作区:${workspace.label}` });
await syncBackendState(api);
closeOverlay();
} })));
return;
}
if (name === '/compact') {
const api = await getApi();
const convId = currentConversationRef.current || status.sessionId;
const result = await api.compressConversation(convId);
addItem({ kind: 'tool', title: '对话已压缩', body: result.compact_file || '', status: 'success' });
if (result.compressed_conversation_id) {
currentConversationRef.current = result.compressed_conversation_id;
setStatus((prev) => ({ ...prev, sessionId: result.compressed_conversation_id }));
}
closeOverlay();
return;
}
if (name === '/tools') {
const api = await getApi();
const snapshot = await api.getToolSettings();
const categories = snapshot.categories || {};
openPicker('Tools', Object.entries(categories).map(([key, value]: [string, any]) => ({ label: `${value.enabled ? '[x]' : '[ ]'} ${key}`, description: value.label || '', onSelect: async () => {
await api.setToolCategory(key, !value.enabled);
addItem({ kind: 'system', body: `工具 ${key}${!value.enabled ? '启用' : '禁用'}` });
closeOverlay();
} })));
return;
}
if (name === '/path') {
const api = await getApi();
const auth = await api.getPathAuthorization();
openPicker('Authorized Paths', [
{ label: '当前可读写路径', description: (auth.writable_paths || []).join(', ') || '无', onSelect: () => closeOverlay() },
{ label: '当前只读路径', description: (auth.readable_extra_paths || []).join(', ') || '无', onSelect: () => closeOverlay() },
]);
return;
}
if (name === '/skill') {
const api = await getApi();
const personalization = await api.getPersonalization().catch(() => null);
const enabled = new Set((personalization?.data?.enabled_skills || []).map(String));
const skillItems = (personalization?.skills_catalog || skills)
.filter((skill: any) => !enabled.size || enabled.has(String(skill.id)))
.map((skill: any) => ({ id: String(skill.id), label: String(skill.label || skill.id), description: String(skill.description || '') }));
if (skillItems.length) setSkills(skillItems);
openPicker('Skill', skillItems.map((skill: SkillDefinition) => ({ label: skill.label, description: skill.description, onSelect: () => {
setStatus((prev) => ({ ...prev, activeSkill: skill }));
addItem({ kind: 'system', body: `下一条消息将使用 skill${skill.label}` });
closeOverlay();
} })).concat(skillItems.length ? [] : [{ label: '暂无可用 skill', disabled: true, onSelect: () => closeOverlay() }]));
return;
}
if (name === '/status') {
await getApi().then((api) => syncBackendState(api)).catch(() => undefined);
setMode('status_panel');
setInput('');
return;
}
if (name === '/help') {
setMode('help_panel');
setInput('');
return;
}
if (name === '/clear') {
setTimeline([]);
closeOverlay();
return;
}
if (name === '/exit') {
if (runningTaskRef.current) await apiRef.current?.cancelTask(runningTaskRef.current).catch(() => undefined);
exit();
}
} catch (err) {
addItem({ kind: 'tool', title: `命令失败:${name}`, status: 'failed', body: String((err as Error).message || err) });
closeOverlay();
}
}
async function submitUserMessage(text: string) {
addItem({ kind: 'user', body: text });
const skill = status.activeSkill;
const message = skill ? `${text}\n\n先阅读 ${skill.label} skill` : text;
if (skill) addItem({ kind: 'user', body: `先阅读 ${skill.label} skill` });
setStatus((prev) => ({ ...prev, activeSkill: undefined }));
setRunning(true);
setRunningStartedAt(Date.now());
try {
const api = await getApi();
const task = await api.createTask({ message, conversation_id: currentConversationRef.current, model_key: status.model, run_mode: status.runMode });
runningTaskRef.current = task.task_id;
if (task.conversation_id) currentConversationRef.current = task.conversation_id;
await pollTask(api, task.task_id);
await syncBackendState(api);
} catch (err) {
addItem({ kind: 'tool', title: '发送失败', status: 'failed', body: String((err as Error).message || err) });
} finally {
runningTaskRef.current = null;
setRunning(false);
setRunningStartedAt(null);
}
}
async function pollTask(api: ApiClient, taskId: string) {
let offset = 0;
const renderState = createEventRenderState();
for (;;) {
const result = await api.pollTask(taskId, offset);
offset = result.next_offset;
for (const event of result.events || []) {
if (event.type === 'tool_approval_required') {
const approval = event.data?.approval || event.data;
if (approval?.approval_id) {
setApprovalId(String(approval.approval_id));
setApprovalTitle(`需要审批:${approval.tool_name || '工具调用'}`);
setApprovalIndex(0);
setMode('approval');
}
}
setTimeline((prev) => reduceTaskEvent(prev, renderState, event));
}
if (!['pending', 'running', 'cancel_requested'].includes(String(result.status))) break;
await sleep(700);
}
}
useInput((chunk, key) => {
void handleInput(chunk, key);
});
async function handleInput(chunk: string, key: any) {
if (mode === 'workspace_prompt') {
if (key.upArrow || chunk === 'k') setWorkspacePromptIndex(0);
else if (key.downArrow || chunk === 'j') setWorkspacePromptIndex(1);
else if (key.return) {
if (workspacePromptIndex === 0) {
const result = createWorkspaceForPath(catalog, cwd);
setCatalog(result.catalog);
setStatus((prev) => ({ ...prev, workspace: result.workspace, directory: result.workspace.path }));
bootstrappedRef.current = true;
setMode('composer');
getApi().then(async (api) => {
await api.createWorkspace(result.workspace.path, result.workspace.label).catch(() => undefined);
const wsData = await api.listWorkspaces().catch(() => null);
const backendWs = wsData?.workspaces?.find((ws: any) => String(ws.path) === result.workspace.path);
if (backendWs) await api.selectWorkspace(String(backendWs.workspace_id));
await bootstrapConversation(api);
}).catch((err) => addItem({ kind: 'tool', title: '连接后端失败', status: 'failed', body: String(err.message || err) }));
} else {
exit();
}
}
return;
}
if (mode === 'approval') {
if (key.upArrow) setApprovalIndex((prev) => Math.max(0, prev - 1));
else if (key.downArrow) setApprovalIndex((prev) => Math.min(approvalOptions.length - 1, prev + 1));
else if (key.return && approvalId) {
const selected = approvalOptions[approvalIndex]!;
await apiRef.current?.decideApproval(approvalId, selected.decision).catch((err) => addItem({ kind: 'tool', title: '审批提交失败', status: 'failed', body: String(err.message || err) }));
addItem({ kind: 'system', body: `审批已${selected.decision === 'approved' ? '允许' : '拒绝'}` });
setMode('composer');
}
return;
}
if (key.escape) {
if (runningTaskRef.current) {
await apiRef.current?.cancelTask(runningTaskRef.current).catch(() => undefined);
addItem({ kind: 'system', body: '已请求停止当前任务。' });
return;
}
closeOverlay();
return;
}
if (mode === 'status_panel' || mode === 'help_panel') {
if (key.return || chunk === 'q') closeOverlay();
return;
}
if (mode === 'picker') {
if (key.upArrow) setPickerIndex((prev) => Math.max(0, prev - 1));
else if (key.downArrow) setPickerIndex((prev) => Math.min(pickerOptions.length - 1, prev + 1));
else if (key.return) await pickerOptions[pickerIndex]?.onSelect();
return;
}
if (key.ctrl && chunk === 'c') {
exit();
return;
}
if (mode === 'slash_menu') {
if (key.upArrow) setSlashIndex((prev) => Math.max(0, prev - 1));
else if (key.downArrow) setSlashIndex((prev) => Math.min(commandMatches.length - 1, prev + 1));
else if (key.return) {
const command = commandMatches[slashIndex];
if (command) await executeCommand(command);
} else if (key.backspace || key.delete) {
setInput((prev) => {
const next = Array.from(prev).slice(0, -1).join('');
if (!next) setMode('composer');
return next;
});
setSlashIndex(0);
} else if (chunk && !key.ctrl && !key.meta) {
const normalized = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
setInput((prev) => prev + normalized);
setSlashIndex(0);
}
return;
}
if (key.return) {
const text = input.trim();
if (!text) return;
if (text.startsWith('/')) {
const command = filterCommands(text)[0];
if (command) await executeCommand(command);
return;
}
setInput('');
await submitUserMessage(text);
return;
}
if (key.backspace || key.delete) {
setInput((prev) => Array.from(prev).slice(0, -1).join(''));
return;
}
if (chunk && !key.ctrl && !key.meta) {
const normalized = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
setInput((prev) => {
const next = prev + normalized;
if (next.startsWith('/')) {
setMode('slash_menu');
setSlashIndex(0);
}
return next;
});
}
}
if (mode === 'workspace_prompt') {
return <WorkspacePrompt cwd={cwd} selectedIndex={workspacePromptIndex} />;
}
return (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column">
{timeline.length === 0 ? <WelcomePanel status={status} /> : <Timeline items={timeline} pulseTick={timelineDotTick} width={Math.max(10, (stdout.columns || 80) - 2)} />}
{timeline.length > 0 && timeline[timeline.length - 1]?.kind === 'assistant' ? <Text> </Text> : null}
</Box>
<Box flexDirection="column">
{running ? (
<Box marginTop={1}>
<Text>
<Text color={workingDotTick % 2 === 0 ? 'gray' : undefined}>{workingDotTick % 2 === 0 ? '◦' : '•'}</Text>{' '}
<AnimatedSummary text="Working" tick={workingTextTick} />
{` (${runningSeconds}s • 按 Esc 停止)`}
</Text>
</Box>
) : null}
{mode === 'slash_menu' ? <SlashMenu query={input} commands={commandMatches} selectedIndex={slashIndex} /> : null}
{mode === 'picker' ? <Picker title={pickerTitle} items={pickerOptions} selectedIndex={pickerIndex} /> : null}
{mode === 'status_panel' ? <StatusPanel status={status} /> : null}
{mode === 'help_panel' ? <HelpPanel commands={slashCommands} /> : null}
{mode === 'approval' ? <Picker title={approvalTitle} items={approvalOptions} selectedIndex={approvalIndex} /> : null}
{mode === 'composer' || mode === 'slash_menu' ? <><SkillHint skill={status.activeSkill} /><Composer value={input} disabled={running} marginTop={0} /></> : null}
<StatusLine status={status} />
</Box>
</Box>
);
}
function AnimatedSummary({ text, tick }: { text: string; tick: number }) {
const chars = Array.from(text || '正在执行...');
const cycle = chars.length + 8;
const head = tick % cycle;
return (
<>
{chars.map((char, index) => {
const distance = head - index;
const active = head < chars.length + 4 && distance >= 0 && distance < 5;
return (
<Text key={`${index}-${char}`} color={active ? undefined : 'gray'}>
{char === ' ' ? '\u00A0' : char}
</Text>
);
})}
</>
);
}
function getRunModesForModel(model?: ModelDefinition): RunMode[] {
if (!model) return ['fast', 'thinking', 'deep'];
if (model.deep_only) return ['deep'];
if (model.fast_only || !model.supports_thinking) return ['fast'];
return ['fast', 'thinking', 'deep'];
}

313
cli/src/api.ts Normal file
View File

@ -0,0 +1,313 @@
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 });
}

28
cli/src/commands.ts Normal file
View File

@ -0,0 +1,28 @@
import type { SlashCommand } from './types.js';
export const slashCommands: SlashCommand[] = [
{ name: '/new', description: '新建对话', kind: 'immediate' },
{ name: '/resume', description: '查看对话列表并切换对话', kind: 'picker' },
{ name: '/model', description: '切换模型与思考模式', kind: 'picker' },
{ name: '/versioning', description: '管理版本控制', aliases: ['/version'], kind: 'picker' },
{ name: '/permission', description: '切换权限模式', aliases: ['/perm'], kind: 'picker' },
{ name: '/execution', description: '切换执行环境', aliases: ['/exec', '/env'], kind: 'picker' },
{ name: '/workspace', description: '选择或切换工作区', kind: 'picker' },
{ name: '/compact', description: '压缩当前对话上下文', kind: 'immediate' },
{ name: '/tools', description: '启用或禁用工具', aliases: ['/tool'], kind: 'picker' },
{ name: '/path', description: '管理路径授权', kind: 'form' },
{ name: '/skill', description: '选择本轮要使用的 skill', kind: 'picker' },
{ name: '/status', description: '查看当前状态', kind: 'panel' },
{ name: '/help', description: '查看命令帮助', kind: 'panel' },
{ name: '/clear', description: '清空当前 CLI 显示', kind: 'immediate' },
{ name: '/exit', description: '退出 CLI', kind: 'immediate' },
];
export function filterCommands(query: string): SlashCommand[] {
const normalized = query.trim().toLowerCase();
if (!normalized || normalized === '/') return slashCommands;
return slashCommands.filter((command) => {
const candidates = [command.name, ...(command.aliases || [])];
return candidates.some((name) => name.toLowerCase().startsWith(normalized) || name.toLowerCase().includes(normalized));
});
}

333
cli/src/components.tsx Normal file
View File

@ -0,0 +1,333 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Text, useCursor, useStdout, type DOMElement } from 'ink';
import type { CliStatus, SlashCommand, SkillDefinition, TimelineItem, Workspace } from './types.js';
import { formatContext, shortPath } from './format.js';
const SHOW_THINKING_DETAILS = false;
export function StatusLine({ status }: { status: CliStatus }) {
const skill = status.activeSkill ? ` skill:${status.activeSkill.label}` : '';
const background = [
status.backgroundAgents > 0 ? `${status.backgroundAgents}后台智能体` : '',
status.backgroundCommands > 0 ? `${status.backgroundCommands}后台指令` : '',
].filter(Boolean).join(' · ');
const left = `${status.model} · ${status.workspace.label} · ${status.permissionMode} · ${status.executionMode}${background ? ` · ${background}` : ''} · 版本控制:${status.versioningEnabled ? '开' : '关'}${skill}`;
const right = formatContext(status.contextUsed, status.contextLimit);
return (
<Box justifyContent="space-between" width="100%">
<Text color="gray">{left}</Text>
<Text color="gray">{right}</Text>
</Box>
);
}
export function Composer({ value, disabled, marginTop = 1 }: { value: string; disabled?: boolean; marginTop?: number }) {
const { stdout } = useStdout();
const { setCursorPosition } = useCursor();
const contentRef = useRef<DOMElement>(null);
const [contentOrigin, setContentOrigin] = useState<{ x: number; y: number } | null>(null);
const lines = String(value || '').split(/\r?\n/);
const lastLine = lines[lines.length - 1] || '';
const fallbackOrigin = { x: 1, y: Math.max(0, (stdout.rows || 24) - 3) };
const origin = contentOrigin || fallbackOrigin;
setCursorPosition({
x: origin.x + 2 + displayWidth(lastLine),
y: origin.y + Math.max(0, lines.length - 1),
});
useEffect(() => {
const next = getAbsoluteOrigin(contentRef.current);
if (!next) return;
setContentOrigin((prev) => (prev && prev.x === next.x && prev.y === next.y ? prev : next));
});
return (
<Box flexDirection="column" marginTop={marginTop}>
<Box borderStyle="single" borderColor={disabled ? 'gray' : 'cyan'} paddingX={1} flexDirection="column">
<Box ref={contentRef} flexDirection="column">
{lines.map((line, index) => (
<Text key={`composer-line-${index}`}>
{index === 0 ? <Text color="cyan"> </Text> : <Text> </Text>}
<Text>{line}</Text>
{index === lines.length - 1 ? <Text inverse> </Text> : null}
</Text>
))}
</Box>
</Box>
</Box>
);
}
function getAbsoluteOrigin(node: DOMElement | null): { x: number; y: number } | null {
if (!node?.yogaNode) return null;
let x = 0;
let y = 0;
let current: DOMElement | undefined = node;
while (current?.parentNode) {
if (!current.yogaNode) return null;
x += current.yogaNode.getComputedLeft();
y += current.yogaNode.getComputedTop();
current = current.parentNode;
}
return { x, y };
}
function displayWidth(text: string): number {
let width = 0;
for (const char of Array.from(text)) {
const code = char.codePointAt(0) || 0;
if (code === 0) continue;
if (code < 32 || (code >= 0x7f && code < 0xa0)) continue;
width += isWideCodePoint(code) ? 2 : 1;
}
return width;
}
function isWideCodePoint(code: number): boolean {
return (
code >= 0x1100 && (
code <= 0x115f ||
code === 0x2329 ||
code === 0x232a ||
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
(code >= 0xac00 && code <= 0xd7a3) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe10 && code <= 0xfe19) ||
(code >= 0xfe30 && code <= 0xfe6f) ||
(code >= 0xff00 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6) ||
(code >= 0x1f300 && code <= 0x1f64f) ||
(code >= 0x1f900 && code <= 0x1f9ff) ||
(code >= 0x20000 && code <= 0x3fffd)
)
);
}
export function WelcomePanel({ status }: { status: CliStatus }) {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Agents</Text>
<Box borderStyle="round" borderColor="gray" paddingX={2} paddingY={1} flexDirection="column">
<Text bold>Welcome back!</Text>
<Text color="gray">{status.model} · {status.runMode}</Text>
<Text color="gray">{shortPath(status.directory)}</Text>
</Box>
</Box>
);
}
export function Timeline({ items, pulseTick = 0, width = 78 }: { items: TimelineItem[]; pulseTick?: number; width?: number }) {
return (
<Box flexDirection="column">
{items.map((item) => (
<TimelineRow key={item.id} item={item} pulseTick={pulseTick} />
))}
</Box>
);
}
function TimelineRow({ item, pulseTick }: { item: TimelineItem; pulseTick: number }) {
const separator = useFullWidthSeparator();
if (item.kind === 'user') {
const lines = String(item.body || '').split(/\r?\n/);
return (
<Box flexDirection="column" marginTop={1}>
<Box borderStyle="single" borderColor="gray" paddingX={1} flexDirection="column">
{lines.map((line, index) => (
<Text key={`${item.id}-user-${index}`}>
{index === 0 ? <Text color="cyan"> </Text> : <Text> </Text>}
<Text>{line}</Text>
</Text>
))}
</Box>
</Box>
);
}
if (item.kind === 'assistant') {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">{separator}</Text>
<Text> </Text>
{wrapIndentedText(String(item.body || ''), 2, separator.length).map((line, index) => (
<Text key={`${item.id}-assistant-${index}`}>{line}</Text>
))}
{item.status === 'success' ? <><Text> </Text><Text color="gray">{separator}</Text></> : null}
</Box>
);
}
if (item.kind === 'system') {
return <Text color="gray"> {item.body}</Text>;
}
const running = item.status === 'running';
const failed = item.status === 'failed';
const color = running ? (pulseTick % 2 === 0 ? 'gray' : undefined) : failed ? 'red' : item.kind === 'thinking' ? undefined : 'green';
const dot = running ? (pulseTick % 2 === 0 ? '◦' : '•') : '•';
const detailColor = item.kind === 'thinking' || item.kind === 'tool' || item.kind === 'sub_agent' ? 'gray' : undefined;
const detailWidth = separator.length;
const showDetails = item.kind === 'thinking' ? SHOW_THINKING_DETAILS : true;
return (
<Box flexDirection="column" marginTop={1}>
<Text><Text color={color}>{dot}</Text> {item.title || item.kind}</Text>
{showDetails && !running && item.body ? wrapIndentedText(`${item.body}`, 2, detailWidth).map((line, index) => (
<Text key={`${item.id}-body-${index}`} color={detailColor}>{line}</Text>
)) : null}
{showDetails && (item.kind === 'thinking' || !running) && (item.lines || []).map((line, index) => (
wrapIndentedText(line, index === 0 && !item.body ? 2 : 4, detailWidth).map((wrapped, wrappedIndex) => (
<Text key={`${item.id}-${index}-${wrappedIndex}`} color={detailColor}>
{index === 0 && !item.body && wrappedIndex === 0 ? wrapped.replace(/^ /, ' └ ') : wrapped}
</Text>
))
))}
</Box>
);
}
function useFullWidthSeparator(): string {
const { stdout } = useStdout();
const width = Math.max(2, (stdout.columns || 80) - 2);
return '─'.repeat(width);
}
function wrapIndentedText(text: string, indent: number, width: number): string[] {
const prefix = ' '.repeat(indent);
const contentWidth = Math.max(10, width - indent);
const result: string[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine || '';
if (displayWidth(line) <= contentWidth) {
result.push(`${prefix}${line}`);
continue;
}
let current = '';
let currentWidth = 0;
for (const char of Array.from(line)) {
const charWidth = displayWidth(char);
if (current && currentWidth + charWidth > contentWidth) {
result.push(`${prefix}${current}`);
current = char;
currentWidth = charWidth;
} else {
current += char;
currentWidth += charWidth;
}
}
result.push(`${prefix}${current}`);
}
return result.length ? result : [prefix];
}
export function SlashMenu({ query, commands, selectedIndex }: { query: string; commands: SlashCommand[]; selectedIndex: number }) {
const visibleCount = 5;
const start = Math.max(0, selectedIndex - 2);
const end = Math.min(commands.length, start + visibleCount);
const adjustedStart = Math.max(0, end - visibleCount);
const visibleCommands = commands.slice(adjustedStart, end);
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column">
{visibleCommands.map((command, visibleIndex) => {
const index = adjustedStart + visibleIndex;
return (
<Text key={command.name} color={index === selectedIndex ? 'cyan' : undefined}>
{index === selectedIndex ? ' ' : ' '}{command.name.padEnd(13)} {command.description}
</Text>
);
})}
{!commands.length ? <Text color="gray"> </Text> : null}
</Box>
</Box>
);
}
export function Picker({ title, items, selectedIndex }: { title: string; items: Array<{ label: string; description?: string; disabled?: boolean }>; selectedIndex: number }) {
const visibleCount = 5;
const start = Math.max(0, selectedIndex - 2);
const end = Math.min(items.length, start + visibleCount);
const adjustedStart = Math.max(0, end - visibleCount);
const visibleItems = items.slice(adjustedStart, end);
return (
<Box flexDirection="column" marginTop={1}>
<Text bold>{title}</Text>
<Box flexDirection="column" marginTop={1}>
{visibleItems.map((item, visibleIndex) => {
const index = adjustedStart + visibleIndex;
return (
<Text key={`${item.label}-${index}`} color={item.disabled ? 'gray' : index === selectedIndex ? 'cyan' : undefined}>
{index === selectedIndex ? ' ' : ' '}{item.label.padEnd(20)} {item.description || ''}
</Text>
);
})}
{!items.length ? <Text color="gray"> </Text> : null}
</Box>
</Box>
);
}
export function WorkspacePrompt({ cwd, selectedIndex }: { cwd: string; selectedIndex: number }) {
const options = ['确认,添加并继续', '拒绝,退出'];
return (
<Box flexDirection="column">
<Text color="yellow"></Text>
<Text> {shortPath(cwd)}</Text>
<Box marginTop={1} flexDirection="column">
<Text>Agent </Text>
<Text></Text>
</Box>
<Box marginTop={1} flexDirection="column">
{options.map((label, index) => (
<Text key={label} color={index === selectedIndex ? 'cyan' : undefined}>
{index === selectedIndex ? ' ' : ' '}{index + 1}. {label}
</Text>
))}
</Box>
</Box>
);
}
export function StatusPanel({ status }: { status: CliStatus }) {
return (
<Box borderStyle="round" borderColor="gray" paddingX={1} flexDirection="column" marginTop={1}>
<Text>{'>_ Agents CLI'}</Text>
<Text> </Text>
<StatusField label="Model" value={status.model} />
<StatusField label="Directory" value={shortPath(status.directory)} />
<StatusField label="Workspace" value={status.workspace.label} />
<StatusField label="Permissions" value={status.permissionMode} />
<StatusField label="Execution" value={status.executionMode} />
<StatusField label="Session" value={status.sessionId} />
<StatusField label="Context window" value={formatContext(status.contextUsed, status.contextLimit)} />
</Box>
);
}
function StatusField({ label, value }: { label: string; value: string }) {
return <Text> {label.padEnd(15)} {value}</Text>;
}
export function HelpPanel({ commands }: { commands: SlashCommand[] }) {
return (
<Box flexDirection="column" marginTop={1}>
<Text bold>Commands</Text>
<Box flexDirection="column" marginTop={1}>
{commands.map((command) => (
<Text key={command.name}> {command.name.padEnd(13)} {command.description}</Text>
))}
</Box>
</Box>
);
}
export function SkillHint({ skill }: { skill?: SkillDefinition }) {
if (!skill) return null;
return <Text color="gray"> + {skill.label} skill</Text>;
}
export function WorkspaceList({ workspaces, selectedIndex }: { workspaces: Workspace[]; selectedIndex: number }) {
return (
<Picker
title="Workspace"
selectedIndex={selectedIndex}
items={workspaces.map((workspace) => ({ label: workspace.label, description: shortPath(workspace.path) }))}
/>
);
}

281
cli/src/eventMapper.ts Normal file
View File

@ -0,0 +1,281 @@
import type { TimelineItem } from './types.js';
import { lastLines } from './format.js';
type ToolState = {
timelineId: string;
name: string;
args: any;
title: string;
summary: string;
};
export type EventRenderState = {
thinkingId?: string;
thinkingStartedAt?: number;
thinkingText: string;
thinkingLines: string[];
assistantId?: string;
assistantText: string;
tools: Map<string, ToolState>;
};
export function createEventRenderState(): EventRenderState {
return { thinkingText: '', thinkingLines: [], assistantText: '', tools: new Map() };
}
export function reduceTaskEvent(items: TimelineItem[], state: EventRenderState, event: { type: string; data: any }): TimelineItem[] {
const data = event.data || {};
switch (event.type) {
case 'thinking_start': {
const timelineId = newId();
state.thinkingId = timelineId;
state.thinkingStartedAt = Date.now();
state.thinkingText = '';
state.thinkingLines = [];
return [...items, { id: timelineId, kind: 'thinking', title: '思考中', status: 'running', lines: [] }];
}
case 'thinking_chunk': {
const content = String(data.content || '');
state.thinkingText += content;
const lines = state.thinkingText.split(/\r?\n/).filter((line) => line.length > 0);
state.thinkingLines = lines;
if (!state.thinkingId) {
state.thinkingId = newId();
state.thinkingStartedAt = Date.now();
state.thinkingText = content;
items = [...items, { id: state.thinkingId, kind: 'thinking', title: '思考中', status: 'running', lines: [] }];
}
return patchItem(items, state.thinkingId, { lines: lastLines(state.thinkingLines, 5) });
}
case 'thinking_end': {
if (!state.thinkingId) return items;
const elapsed = state.thinkingStartedAt ? `${((Date.now() - state.thinkingStartedAt) / 1000).toFixed(1)}s` : '';
const id = state.thinkingId;
state.thinkingId = undefined;
state.thinkingStartedAt = undefined;
state.thinkingText = '';
state.thinkingLines = [];
return patchItem(items, id, {
title: `思考完成 ${elapsed}`.trim(),
status: 'success',
body: undefined,
summary: undefined,
lines: [],
});
}
case 'text_start': {
const timelineId = newId();
state.assistantId = timelineId;
state.assistantText = '';
return [...items, { id: timelineId, kind: 'assistant', status: 'running', body: '' }];
}
case 'text_chunk': {
const content = String(data.content || '');
if (!state.assistantId) {
state.assistantId = newId();
state.assistantText = '';
items = [...items, { id: state.assistantId, kind: 'assistant', status: 'running', body: '' }];
}
state.assistantText += content;
return patchItem(items, state.assistantId, { body: state.assistantText });
}
case 'text_end': {
const full = String(data.full_content || state.assistantText || '');
if (!state.assistantId) return full ? [...items, { id: newId(), kind: 'assistant', title: '完成', status: 'success', body: full }] : items;
const id = state.assistantId;
state.assistantId = undefined;
state.assistantText = '';
return patchItem(items, id, { status: 'success', body: full });
}
case 'tool_preparing':
case 'tool_start': {
const toolId = String(data.id || data.execution_id || data.preparing_id || newId());
const name = String(data.name || 'tool');
const args = data.arguments || {};
const title = toolTitle(name, args, 'running');
const summary = toolSummaryPreview(data, name, args, 'running');
const existingKey = data.preparing_id ? String(data.preparing_id) : toolId;
const existing = state.tools.get(existingKey);
if (existing) {
state.tools.delete(existingKey);
state.tools.set(toolId, { ...existing, name, args, title, summary });
return patchItem(items, existing.timelineId, {
title,
summary,
status: 'running',
body: toolInitialBody(name, args),
lines: undefined,
});
}
const timelineId = newId();
state.tools.set(toolId, { timelineId, name, args, title, summary });
return [...items, { id: timelineId, kind: 'tool', title, summary, status: 'running', body: toolInitialBody(name, args) }];
}
case 'tool_update_action':
case 'update_action': {
const toolId = String(data.id || data.execution_id || data.preparing_id || '');
const tool = state.tools.get(toolId);
if (!tool) return items;
if (data.result !== undefined || data.status) {
const status = String(data.status || '').toLowerCase() === 'completed' ? 'success' : String(data.status || '').toLowerCase() === 'failed' ? 'failed' : 'running';
return patchItem(items, tool.timelineId, {
title: toolTitle(tool.name, tool.args, status),
summary: toolSummaryPreview(data, tool.name, tool.args, status),
status,
body: toolResultBody(tool.name, tool.args, data.result),
lines: toolResultLines(tool.name, tool.args, data.result),
});
}
return items;
}
case 'append_payload':
case 'modify_payload': {
return [...items, renderPayloadEvent(data)];
}
case 'tool_approval_required': {
const approval = data.approval || data;
return [...items, { id: newId(), kind: 'tool', title: `需要审批:${approval.tool_name || '工具调用'}`, status: 'running', body: approval.preview?.summary || '' }];
}
case 'tool_approval_resolved': {
return [...items, { id: newId(), kind: 'tool', title: `审批${data.decision === 'approved' ? '通过' : '拒绝'}`, status: data.decision === 'approved' ? 'success' : 'failed', body: data.reason || '' }];
}
case 'task_complete': {
return items;
}
case 'task_stopped': {
return [...items, { id: newId(), kind: 'tool', title: '任务已停止', status: 'cancelled', body: data.message || '' }];
}
case 'error': {
return [...items, { id: newId(), kind: 'tool', title: '任务失败', status: 'failed', body: data.message || data.error || '未知错误' }];
}
case 'token_update': {
return items;
}
default:
return items;
}
}
function toolTitle(name: string, args: any, status: string): string {
if (name === 'run_command') return `运行 ${args.command || ''}`.trim();
if (name === 'run_python') return '运行 Python';
if (name === 'write_file') return `Write ${args.file_path || args.path || ''} ${countWrite(args.content || '')}`.trim();
if (name === 'edit_file') return `Edited ${args.file_path || args.path || ''} ${countEdit(args)}`.trim();
if (name === 'read_file' || name === 'read_skill') return `阅读 ${args.path || args.file_path || ''}`.trim();
if (name === 'terminal_session') return `${status === 'running' ? '创建' : '终端'} ${args.session_name || args.name || ''}`.trim();
if (name === 'terminal_input') return `终端输入 ${args.command || args.input || ''}`.trim();
return name;
}
function toolSummaryPreview(data: any, name: string, args: any, status: string): string {
const intent = data?.intent_rendered || data?.intent_full || data?.intent || args?.intent_rendered || args?.intent_full || args?.intent || '';
if (intent) return truncateSummaryPreview(firstLine(String(intent)));
if (status === 'success') return '工具执行完成';
if (status === 'failed') return `${name || '工具'} 执行失败`;
if (data?.status === 'preparing') return `准备调用 ${name || '工具'}...`;
return `正在调用 ${name || '工具'}...`;
}
function firstLine(text: string): string {
return text.split(/\r?\n/)[0] || '';
}
function truncateSummaryPreview(text: string): string {
const value = String(text || '');
return value.length > 25 ? `${value.slice(0, 25)}...` : value;
}
function toolInitialBody(name: string, args: any): string {
if (name === 'write_file' || name === 'edit_file') return '';
return '';
}
function toolResultBody(name: string, args: any, result: any): string {
if (typeof result === 'string') return summarizeText(result);
if (!result) return '';
if (name === 'run_command' || name === 'terminal_input' || name === 'run_python') {
const exitCode = result.exit_code ?? result.returncode;
const text = String(result?.output || result?.stdout || result?.stderr || '').trim();
if (exitCode !== undefined && exitCode !== 0) return text ? `exit code ${exitCode}` : `exit code ${exitCode}\n(no output)`;
return text ? '' : '(no output)';
}
if (name === 'read_file' || name === 'read_skill') {
const content = result.content || result.text || '';
const count = String(content).split(/\r?\n/).filter(Boolean).length;
const type = result.type || 'read';
if (type === 'extract') return `提取了 ${count}`;
return `读取了 ${count}`;
}
if (name === 'write_file') return '';
if (name === 'edit_file') return '';
if (result.error) return String(result.error);
return result.success === false ? '失败' : '成功';
}
function toolResultLines(name: string, args: any, result: any): string[] | undefined {
if (name === 'write_file') {
return String(args.content || '').split('\n').map((line, idx) => `${idx + 1} +${line}`);
}
if (name === 'edit_file') {
return editLines(args);
}
const text = typeof result === 'string' ? result : String(result?.output || result?.stdout || result?.content || result?.text || '');
if (!text || name === 'read_file' || name === 'read_skill') return undefined;
return summarizeLines(text);
}
function renderPayloadEvent(data: any): TimelineItem {
const path = data.path || data.file_path || '';
const lines: string[] = [];
if (Array.isArray(data.old_lines)) lines.push(...data.old_lines.map((line: string, idx: number) => `${idx + 1} -${line}`));
if (Array.isArray(data.new_lines)) lines.push(...data.new_lines.map((line: string, idx: number) => `${idx + 1} +${line}`));
return { id: newId(), kind: 'tool', title: `Edited ${path}`, status: 'success', lines };
}
function countWrite(content: string): string {
const added = content ? content.split('\n').length : 0;
return `(+${added} -0)`;
}
function countEdit(args: any): string {
const lines = editLines(args);
const removed = lines.filter((line) => line.includes(' -')).length;
const added = lines.filter((line) => line.includes(' +')).length;
return `(+${added} -${removed})`;
}
function editLines(args: any): string[] {
const replacements = Array.isArray(args.replacements) ? args.replacements : [];
const result: string[] = [];
const emitPair = (oldText: string, newText: string) => {
const oldLines = oldText.replace(/\\n/g, '\n').split('\n');
const newLines = newText.replace(/\\n/g, '\n').split('\n');
oldLines.filter((line) => line.length > 0).forEach((line, idx) => result.push(`${idx + 1} -${line}`));
newLines.filter((line) => line.length > 0).forEach((line, idx) => result.push(`${idx + 1} +${line}`));
};
if (replacements.length) {
for (const rep of replacements) emitPair(String(rep.old_text || rep.old || ''), String(rep.new_text || rep.new || ''));
} else {
emitPair(String(args.old_string || ''), String(args.new_string || ''));
}
return result;
}
function summarizeText(text: string): string {
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
return summarizeLines(lines.join('\n')).join('\n');
}
function summarizeLines(text: string): string[] {
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length <= 5) return lines;
return [lines[0]!, lines[1]!, `… +${lines.length - 4} lines`, lines[lines.length - 2]!, lines[lines.length - 1]!];
}
function patchItem(items: TimelineItem[], id: string, patch: Partial<TimelineItem>): TimelineItem[] {
return items.map((item) => item.id === id ? { ...item, ...patch } : item);
}
function newId(): string {
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

29
cli/src/format.ts Normal file
View File

@ -0,0 +1,29 @@
import { homedir } from 'node:os';
export function shortPath(path: string): string {
const home = homedir();
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
}
export function formatContext(used: number, limit: number): string {
if (!limit || limit <= 0) return formatTokens(used);
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.round((left / limit) * 100) : 0;
return `${pct}% left ${formatTokens(used)} / ${formatTokens(limit)}`;
}
export function formatTokens(value: number): string {
if (value >= 1000) {
const rounded = Math.round((value / 1000) * 10) / 10;
return `${rounded}K`;
}
return String(value);
}
export function nowSessionId(): string {
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function lastLines(lines: string[], limit = 5): string[] {
return lines.slice(Math.max(0, lines.length - limit));
}

7
cli/src/main.tsx Normal file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env node
import React from 'react';
import { render } from 'ink';
import App from './App.js';
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
render(<App />);

65
cli/src/types.ts Normal file
View File

@ -0,0 +1,65 @@
export type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
export type ExecutionMode = 'sandbox' | 'direct';
export type RunMode = 'fast' | 'thinking' | 'deep';
export type Workspace = {
workspace_id: string;
label: string;
path: string;
};
export type WorkspaceCatalog = {
default_workspace_id: string;
workspaces: Workspace[];
};
export type CliStatus = {
model: string;
runMode: RunMode;
directory: string;
workspace: Workspace;
permissionMode: PermissionMode;
executionMode: ExecutionMode;
sessionId: string;
contextUsed: number;
contextLimit: number;
versioningEnabled: boolean;
backgroundAgents: number;
backgroundCommands: number;
activeSkill?: SkillDefinition;
};
export type SkillDefinition = {
id: string;
label: string;
description: string;
};
export type ModelDefinition = {
model_key: string;
name: string;
description?: string;
supports_thinking?: boolean;
fast_only?: boolean;
deep_only?: boolean;
context_window?: number;
};
export type TimelineItem = {
id: string;
kind: 'system' | 'user' | 'assistant' | 'tool' | 'thinking' | 'sub_agent';
title?: string;
summary?: string;
body?: string;
status?: 'running' | 'success' | 'failed' | 'cancelled';
lines?: string[];
};
export type SlashCommandKind = 'immediate' | 'picker' | 'panel' | 'form';
export type SlashCommand = {
name: string;
description: string;
aliases?: string[];
kind: SlashCommandKind;
};

97
cli/src/workspaces.ts Normal file
View File

@ -0,0 +1,97 @@
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 };
}

14
cli/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node", "react"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View File

@ -0,0 +1,764 @@
# CLI Slash 命令设计规范(草案)
> 目标:定义 CLI 中 `/` 命令的命名、交互方式、显示方式与最小功能集。
> 关联文档:`docs/cli_ui_display_spec.md`
## 1. 总体原则
1. **所有主动操作都从 `/` 命令进入**:避免额外快捷键过多导致记忆负担。
2. **命令面板统一交互**输入过滤、上下选择、Enter 确认。
3. **没有必要的命令不做**:后台内部操作、配置文件管理、文件浏览等不暴露成命令。
4. **审批不做独立命令**:有审批时直接占用输入栏显示审批交互。
5. **命令只负责 UI 入口**:真正执行仍通过后端 API / CLI 适配层完成。
---
## 2. 启动命令与工作区初始化
### 2.1 启动命令
正式启动命令建议为:
```bash
agents
```
开发期或兼容别名可保留:
```bash
agents-cli
```
命名理由:
- 和项目名一致
- 短、好记
- 类似 `codex` / `claude` 的产品级 CLI 命令
- 比 `agent` 更不容易和泛称混淆
### 2.2 默认工作区目录
用户在任意目录执行:
```bash
cd /Users/jojo/Desktop/test
agents
```
CLI 默认使用 `process.cwd()` 作为本次启动的工作区目录。
### 2.3 当前目录已是工作区
如果当前目录已经存在于工作区列表中:
1. 自动选择该工作区
2. 进入 CLI 主界面
### 2.4 当前目录不是工作区
如果当前目录不在工作区列表中,启动时先显示确认提示:
```text
当前目录尚未添加为工作区:
/Users/jojo/Desktop/test
Agent 将在此目录中读取文件、执行命令和修改内容。
是否将此目录添加为工作区?
1. 确认,添加并继续
2. 拒绝,退出
```
选择 `确认`
```text
• 已添加工作区 test
└ /Users/jojo/Desktop/test
```
然后进入 CLI 主界面。
选择 `拒绝`
```text
已取消,退出 Agents CLI。
```
并退出程序。
### 2.5 工作区匹配规则
v1 只做精确路径匹配:
- 当前目录路径与已有工作区路径完全一致:视为已有工作区
- 当前目录是已有工作区的子目录:仍视为新目录,需要确认是否添加
这样可以保证行为明确:
> 在哪个目录启动Agent 就在哪个目录工作。
### 2.6 自动工作区名称
自动创建工作区时:
- 默认名称使用目录 basename
- 如 `/Users/jojo/Desktop/test``test`
- 如果名称重复,可自动追加序号,例如 `test-2`
---
## 3. 命令面板交互
### 3.1 触发
用户在输入栏输入 `/` 后打开命令面板。
```text
/
```
输入更多字符后实时过滤:
```text
/a
/compact 压缩当前对话上下文
/path 管理路径授权
/permission 切换权限模式
```
### 3.2 选择
- `↑` / `↓` 切换当前选中项
- 当前选中项使用高亮颜色
- `Enter` 执行当前命令
- `Esc` 关闭命令面板
### 3.3 展示格式
```text
/mo
/model 切换模型与思考模式
```
格式规则:
- 左侧显示命令名
- 右侧显示简短说明
- 说明必须短,不超过一行
---
## 4. 最终命令清单
| 命令 | 说明 | 类型 |
|---|---|---|
| `/new` | 新建对话 | 立即执行 / 可确认 |
| `/resume` | 查看对话列表并切换对话 | 选择器 |
| `/model` | 切换模型与思考模式 | 选择器 |
| `/versioning` | 管理版本控制 | 选择器 |
| `/permission` | 切换权限模式 | 选择器 |
| `/execution` | 切换执行环境 | 选择器 |
| `/workspace` | 选择或切换工作区 | 选择器 |
| `/compact` | 压缩当前对话上下文 | 立即执行 / 可确认 |
| `/tools` | 启用或禁用工具 | 选择器 |
| `/path` | 管理路径授权 | 选择器 / 表单 |
| `/skill` | 选择本轮要使用的 skill | 选择器 |
| `/status` | 查看当前状态 | 信息面板 |
| `/help` | 查看命令帮助 | 信息面板 |
| `/clear` | 清空当前 CLI 显示 | 立即执行 |
| `/exit` | 退出 CLI | 立即执行 / 可确认 |
---
## 5. 不做的命令
以下命令不做:
| 命令 | 不做原因 |
|---|---|
| `/approval` | 有待审批内容时直接在输入栏显示审批交互,无需手动进入 |
| `/memory` | 属于后端/Agent 内部操作,不作为 CLI 常用入口 |
| `/files` | CLI 不做文件浏览器,文件操作由 Agent 工具执行 |
| `/tasks` | 后台任务数量放在底部状态栏;详细逻辑先不暴露 |
| `/focus` | 暂不做额外显示模式 |
---
## 6. 各命令详细设计
### 6.1 `/new`
说明:
```text
/new 新建对话
```
行为:
- 创建一个空白对话
- 清空当前 CLI 消息区
- 切换到底部输入待命态
如果当前有未完成任务:
```text
当前任务仍在运行,是否新建对话?
1. 新建并停止当前任务
2. 等待当前任务完成
3. 取消
```
---
### 6.2 `/resume`
说明:
```text
/resume 查看对话列表并切换对话
```
行为:
- 打开历史对话选择器
- 显示最近对话
- 选择后切换当前会话
显示示例:
```text
Resume conversation
今天 14:32 修复 CLI 设计文档
今天 13:10 调研 Claude Code CLI
昨天 22:45 Android 发布联动
```
字段建议:
- 时间
- 对话标题 / 首条用户消息摘要
- 当前工作区(可选)
- 模型(可选)
---
### 6.3 `/model`
说明:
```text
/model 切换模型与思考模式
```
行为:
- 打开模型选择器
- 支持模型和运行模式一起切换
显示示例:
```text
Model
1. kimi-k2.5 当前模型
2. gpt-5.5 高质量编码
3. gpt-5.4-mini 快速低成本
Run mode
1. thinking 思考模式
2. fast 快速模式
3. deep 深度思考
```
规则:
- 当前选中项标记为 `current`
- 被管理员禁用的模型显示为 disabled不可选
- fast-only / deep-only 模型需要显示提示
---
### 6.4 `/versioning`
说明:
```text
/versioning 管理版本控制
```
为什么用 `versioning`
- 比 `/version` 更明确是“版本控制”
- 避免和模型版本混淆
行为:
- 打开版本控制设置 / 恢复面板
显示示例:
```text
Versioning
1. 开启版本控制
2. 关闭版本控制
3. 查看版本记录
4. 恢复到选定版本
```
字段建议:
- 当前是否开启
- 当前跟踪模式
- 最近版本点
- 是否与当前工作区匹配
---
### 6.5 `/permission`
说明:
```text
/permission 切换权限模式
```
行为:
- 打开权限模式选择器
显示示例:
```text
Permission Mode
1. Readonly 只读,不允许写入
2. Approval 需要审批后执行写入/敏感操作
3. Auto Approval 由自动审批智能体审核
4. Unrestricted 不做权限拦截
```
规则:
- 当前权限模式显示 `current`
- 高风险项需要二次确认
---
### 6.6 `/execution`
说明:
```text
/execution 切换执行环境
```
为什么用 `execution`
- 覆盖 `sandbox``direct`
- 比 `/env` 更明确
- 比 `/runtime` 更贴近用户动作
行为:
- 打开执行环境选择器
显示示例:
```text
Execution Environment
1. Sandbox 使用 OS 沙箱执行命令
2. Direct 在宿主机直接执行命令
```
规则:
- `Direct` 需要风险提示
- 如果 direct 有 TTL显示剩余时间
---
### 6.7 `/workspace`
说明:
```text
/workspace 选择或切换工作区
```
行为:
- 打开工作区列表
- 支持选择已有工作区
- 支持新建工作区
显示示例:
```text
Workspace
1. agents /Users/jojo/Desktop/agents/正在修复中/agents current
2. test /Users/jojo/Desktop/test
3. 新建工作区...
```
新建工作区时,输入栏切换成表单:
```text
Workspace path
/Users/jojo/Desktop/new-project
```
可选第二步:
```text
Workspace name
new-project
```
---
### 6.8 `/compact`
说明:
```text
/compact 压缩当前对话上下文
```
行为:
- 触发当前对话压缩
- 压缩前可确认
显示示例:
```text
确认压缩当前对话上下文?
1. 压缩
2. 取消
```
完成后:
```text
• 对话已压缩
└ 当前上下文 24k/200k
```
---
### 6.9 `/tools`
说明:
```text
/tools 启用或禁用工具
```
行为:
- 打开工具分类选择器
- 支持启用/禁用工具分类
显示示例:
```text
Tools
[x] terminal 终端命令
[x] file 文件读写
[ ] web 网络访问
[x] sub_agent 子智能体
```
规则:
- 空格切换选中
- Enter 保存
- Esc 取消
---
### 6.10 `/path`
说明:
```text
/path 管理路径授权
```
行为:
- 查看已授权路径
- 添加可读写路径
- 添加只读路径
- 删除授权路径
显示示例:
```text
Authorized Paths
1. 添加可读写路径
2. 添加只读路径
3. 删除授权路径
Current
rw /Users/jojo/Desktop/agents
ro /Users/jojo/Downloads
```
添加路径表单:
```text
Path
/Users/jojo/Desktop/project
```
---
### 6.11 `/skill`
说明:
```text
/skill 选择本轮要使用的 skill
```
这是 CLI 新增能力Web 端暂无对应入口。
行为:
- 打开 skill 列表
- 用户选择一个 skill
- 下一次发送用户消息时CLI 在用户消息下方附加一条 user 消息:
```text
先阅读 docs skill
```
### 显示示例
```text
Skill
docs 阅读文档相关技能
frontend 前端开发相关技能
backend 后端开发相关技能
```
选择后输入栏状态栏显示:
```text
skill: docs
```
发送消息时:
```text
帮我整理一下 CLI 文档
+ 先阅读 docs skill
```
规则:
- v1 先做单选
- 选择后只作用于下一条用户消息
- 消息发送后清空当前 skill
- 后续可扩展为多选
---
### 6.12 `/status`
说明:
```text
/status 查看当前状态
```
行为:
- 打开状态信息面板
- 类似 Codex CLI 的状态卡
显示示例:
```text
╭────────────────────────────────────────────────────────────────────╮
│ >_ Agents CLI │
│ │
│ Model: kimi-k2.5 │
│ Directory: ~/Desktop/agents/正在修复中/agents │
│ Workspace: agents │
│ Permissions: auto_approval │
│ Execution: sandbox │
│ Session: 019e253e-7310-7330-ac02-859338456535 │
│ Context window: 99% left (14.6K used / 258K) │
╰────────────────────────────────────────────────────────────────────╯
```
#### 字段说明
| 字段 | 说明 |
|---|---|
| `Model` | 当前模型 |
| `Directory` | 当前工作目录 |
| `Workspace` | 当前工作区名称 |
| `Permissions` | 当前权限模式 |
| `Execution` | 当前执行环境 |
| `Session` | 当前会话 ID |
| `Context window` | 上下文窗口使用情况 |
---
### 6.13 `/help`
说明:
```text
/help 查看命令帮助
```
显示命令列表:
```text
Commands
/new 新建对话
/resume 查看对话列表并切换对话
/model 切换模型与思考模式
/versioning 管理版本控制
/permission 切换权限模式
/execution 切换执行环境
/workspace 选择或切换工作区
/compact 压缩当前对话上下文
/tools 启用或禁用工具
/path 管理路径授权
/skill 选择本轮要使用的 skill
/status 查看当前状态
/clear 清空当前 CLI 显示
/exit 退出 CLI
```
---
### 6.14 `/clear`
说明:
```text
/clear 清空当前 CLI 显示
```
行为:
- 只清空 CLI 当前显示区
- 不删除对话历史
- 不影响后端会话
---
### 6.15 `/exit`
说明:
```text
/exit 退出 CLI
```
行为:
- 如果当前无任务,直接退出
- 如果有运行任务,显示确认:
```text
当前任务仍在运行,是否退出?
1. 退出并停止当前任务
2. 后台继续运行并退出
3. 取消
```
---
## 7. 审批交互与命令系统的关系
审批不是 `/approval` 命令。
当有待审批操作时,输入栏临时切换为审批态:
```text
需要批准:运行 rm -rf dist
1. 允许一次
2. 切换到无限制
3. 拒绝
```
审批完成后恢复普通输入态。
---
## 8. 底部状态栏与 `/status` 的关系
底部状态栏只显示短信息:
```text
5 后台智能体 3 后台指令 版本控制: 开 自动审批 沙箱 24k/200k
```
`/status` 显示完整信息卡。
---
## 9. 实现建议
### 9.1 命令定义结构
```ts
type SlashCommand = {
name: string;
aliases?: string[];
description: string;
kind: 'immediate' | 'picker' | 'panel' | 'form';
run: () => void | Promise<void>;
};
```
### 9.2 命令状态
```ts
type CliInputMode =
| 'composer'
| 'slash_menu'
| 'picker'
| 'approval'
| 'form'
| 'status_panel'
| 'help_panel';
```
### 9.3 命令过滤
- 前缀匹配优先
- 模糊匹配其次
- 当前项默认选第一条
---
## 10. 一句话总结
CLI 的 `/` 命令系统目标是:
> 用一个统一命令入口覆盖 Web 对话页的主要直接操作,同时保持终端交互简单、可键盘驱动、低噪声。

523
docs/cli_ui_display_spec.md Normal file
View File

@ -0,0 +1,523 @@
# CLI UI 显示规范(草案)
> 目标:把 Web 端已有的信息表达方式,压缩成一个低噪声、可扫描、适合终端的 CLI 交互层。
> 适用范围CLI 前端展示层,不定义 Agent Runtime 本身。
## 1. 设计原则
1. **事件流优先**CLI 只消费结构化事件,不直接消费原始 API 响应。
2. **极简优先**:正文少、状态清楚、动作明确。
3. **固定符号**:统一用少量状态符号表达进度与结果。
4. **完整展示变更**:文件编辑类内容不折叠,直接完整显示。
5. **思考可见但不冗长**:只保留滚动中的短窗口,不保留完整历史。
6. **交互独立**审批、选择、Slash 命令都走独立输入态。
---
## 2. 总体布局
```text
[历史事件区]
[当前思考 / 工具 / 子智能体输出]
[最终结果]
──
[输入栏]
[状态栏]
```
### 底部状态栏建议
```text
5 后台智能体 3 后台指令 版本控制: 开 自动审批 沙箱 24k/200k
```
可扩展字段:
- 模型名
- 运行模式thinking / fast 先不做切换也可保留显示位)
- Git 开关
- 自动审批状态
- 沙箱/直连状态
- 上下文剩余量
---
## 3. 状态符号
| 状态 | 符号 | 颜色 | 说明 |
|---|---|---|---|
| 运行中 | `◦` / `•` 交替 | 黄/灰 | 闪烁 |
| 完成 | `•` | 绿 | 静态 |
| 失败 | `•` | 红 | 静态 |
| 用户输入 | `` | 白/青 | 静态 |
| 审批中 | `◦` | 黄 | 慢闪 |
| 已取消 | `•` | 灰 | 静态 |
---
## 4. 思考块显示
思考块只保留一种样式:**5 行滚动窗口**。
### 4.1 运行中
```text
◦ 思考中
└ 第一行
第二行
第三行
第四行
第五行
```
当加入第 6 行时,自动滚动:
```text
◦ 思考中
└ 第二行
第三行
第四行
第五行
第六行
```
### 4.2 完成后
```text
• 思考完成 4.2s
```
不做折叠展开,不保留完整思考历史。
### 4.3 规则
- 只显示最新 5 行
- 只显示当前思考内容
- 完成后只留完成状态
- 不显示“思考全文”
---
## 5. 子智能体前台显示
子智能体前台展示方式与思考块一致,也是 5 行滚动窗口。
```text
◦ 子智能体 review-agent...
└ 运行 ls -la && pwd
搜索 python 新版本
第三行
第四行
第五行
```
完成后:
```text
• 子智能体完成 8.1s
```
### 规则
- 子智能体只显示前台进度,不显示全部历史
- 只保留最新 5 行
- 完成态只显示耗时和结果摘要
---
## 6. 工具块通用格式
统一使用:
```text
• 动作摘要
└ 结果摘要
```
### 6.1 运行中
```text
◦ 运行 npm run build
└ 正在执行...
```
### 6.2 完成
```text
• 运行 npm run build
└ 成功,用时 8.2s
```
### 6.3 失败
```text
• 运行 npm run build
└ 失败exit code 2
```
---
## 7. 工具显示规范
下面定义 `└` 后面的内容包含哪些字段、如何显示。
### 7.1 运行命令类
#### 适用工具
- `run_command`
- `terminal_input`
- `terminal_session`
- `terminal_snapshot`
#### 显示字段
- `command` / `input`
- `timeout`
- `exit_code`
- `stdout` / `output`
- `stderr`(若可分离)
#### 显示规则
```text
• 运行 printf '' > test.txt && ls -l test.txt
└ -rw-r--r-- 1 jojo staff 0 May 14 14:48 test.txt
```
长输出:
```text
• 运行 ls -la && pwd
└ 第 1 行内容
第 2 行内容
… +6 lines
第 9 行内容
第 10 行内容
```
#### 约束
- 第一行优先显示结果中最有信息量的一行
- 长文本可局部截断,但不要丢掉关键错误
- 如果有 exit code必须显示
---
### 7.2 文件编辑类
#### 适用工具
- `write_file`
- `edit_file`
#### 原则
**必须完整显示,不折叠。**
因为:
- 这是文件内容本身,不是工具摘要
- 单次替换通常不会太大
- 终端里保留完整 + / - 最有价值
#### 7.2.1 Write
显示字段:
- `path`
- `mode`append / overwrite
- `status`
- `content`
显示模板:
```text
• Write test.txt (+3 -0)
1 +这是第一行测试内容。
2 +这是第二行测试内容。
3 +这是第三行测试内容。
```
#### 7.2.2 Edit
显示字段:
- `path`
- `replace_all`
- `found`
- `replacements`
- 每个 replacement 的 `old` / `new`
显示模板:
```text
• Edited main.py (+2 -2)
14 -old line 1
15 -old line 2
14 +new line 1
15 +new line 2
```
#### 7.2.3 规则
- 不折叠
- 不显示上下文行
- 只显示参数里已有的变更信息
- 若存在多段替换,按替换顺序逐段输出
---
### 7.3 阅读类
#### 适用工具
- `read_file`
- `read_skill`
#### 显示字段
- `path`
- `status`
- `size`
- `read_type`
- `content`
- `search` / `extract` 子模式信息
#### 显示模板
普通读取:
```text
• 阅读 test.txt
└ 读取 3 行
```
提取模式:
```text
• 阅读 test.txt
└ 提取第 12-28 行
```
搜索模式(如果未来恢复):
```text
• 搜索 handle_task
└ 找到 12 处,位于 5 个文件
```
#### 规则
- 正文尽量压缩成“读取了多少 / 提取了什么 / 找到了什么”
- 需要看全文时再走展开命令
---
### 7.4 搜索类
当前 CLI v1 **不做搜索工具**
如果未来恢复,显示方式参考阅读类:
```text
• 搜索 "xxx"
└ 找到 N 处,位于 M 个文件
```
---
### 7.5 文件创建 / 删除 / 重命名
当前 CLI v1 **不做这些工具块**,可直接忽略。
---
### 7.6 子智能体 / 后台任务类
#### 显示字段
- `name`
- `status`
- `latest_progress_lines`
- `elapsed`
#### 显示模板
```text
◦ 子智能体 review-agent...
└ 运行 ls -la && pwd
搜索 python 新版本
第三行
第四行
第五行
```
完成后:
```text
• 子智能体完成 8.1s
```
#### 规则
- 只显示最新 5 行
- 运行中用 `◦`
- 完成后用 `•`
---
### 7.7 审批 / 交互类
#### 适用场景
- 权限审批
- 需要用户确认的危险操作
- 自定义选择
- Slash 命令补全
#### 显示方式
使用**输入栏扩展 + 选项列表**,不是普通消息流。
示例:
```text
┌────────────────────────────────────┐
│ 需要批准:运行 rm -rf dist │
│ 原因:删除构建目录 │
│ │
1. Allow once │
│ 2. Always allow │
│ 3. Deny │
└────────────────────────────────────┘
```
#### 规则
- 上下键切换
- 当前行高亮
- Enter 确认
- Esc 或拒绝关闭
---
## 8. Slash 命令规范
所有操作都通过 `/` 输入。
### 8.1 触发方式
输入 `/``/a` 后,打开命令建议列表。
### 8.2 显示方式
```text
/approve 简要说明
/allow 简要说明
/assist 简要说明
```
### 8.3 规则
- 支持模糊过滤
- 上下键选择
- 当前项变色
- Enter 执行
---
## 9. 最终输出区
任务完成后,使用固定分隔结构:
```text
──
• 已创建 test.txt 文件。
1. 修改文件列表test.txt
2. 核心变更点:在 /Users/jojo/Desktop/test 下创建了一个空文本文件
3. 验证结果:已执行 ls -l test.txt文件存在。
──
```
### 最终结果字段
建议统一输出:
1. **修改文件列表**
2. **核心变更点**
3. **验证结果**
如果有失败:
- 失败原因
- 已做的尝试
- 下一步建议
---
## 10. 信息优先级
从高到低:
1. 当前交互态(审批 / 命令选择)
2. 当前思考 / 当前子智能体进度
3. 当前工具输出
4. 最终结论
5. 低优先级元信息
---
## 11. 参考的 Web 端表现
Web 端已经存在的对应参考:
- `static/src/components/chat/actions/ThinkingAction.vue`
- 思考块
- 运行中 / 完成两态
- 可折叠
- `static/src/components/chat/actions/ToolAction.vue`
- 工具块
- `renderEnhancedToolResult()`
- 文件编辑 / 读取 / 命令 / 图片等不同分支
- `static/src/components/chat/StackedBlocks.vue`
- 思考 + 工具的堆叠展示
- `static/src/components/chat/MinimalBlocks.vue`
- 摘要组展示
- 最终只保留当前步骤预览
- `static/src/components/panels/ToolApprovalPanel.vue`
- 审批面板
- 编辑 diff、写入内容、命令预览
CLI 的目标不是复制这些视觉细节,而是抽取其中最有信息价值的字段。
---
## 12. 推荐的 CLI 默认策略
### 默认开启
- 5 行思考滚动
- 5 行子智能体滚动
- 文件编辑完整展示
- 命令输出摘要展示
- Slash 命令补全
- 审批选择弹层
### 默认关闭
- 完整思考历史
- 文件创建 / 删除 / 重命名显示
- 搜索工具
- 折叠展开复杂交互
---
## 13. 一句话总结
CLI 的目标是:
> 用最少的符号,表达最多的有效信息;用固定的状态和结构,让用户能一眼看懂 Agent 在做什么。

View File

@ -8,7 +8,10 @@
"build": "tsc --noEmit && vite build",
"lint": "eslint \"static/src/**/*.{ts,tsx,js,vue}\" --max-warnings=0",
"format": "prettier --write \"static/src/**/*.{ts,tsx,js,vue,css}\"",
"preview": "vite preview"
"preview": "vite preview",
"cli": "npm --prefix cli run dev",
"cli:build": "npm --prefix cli run build",
"cli:typecheck": "npm --prefix cli run typecheck"
},
"dependencies": {
"@types/html2canvas": "^0.5.35",

View File

@ -15,6 +15,7 @@ import secrets
from config import THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS
from modules.personalization_manager import (
load_personalization_config,
resolve_context_compression_settings,
save_personalization_config,
THINKING_INTERVAL_MIN,
THINKING_INTERVAL_MAX,
@ -28,6 +29,7 @@ from modules.upload_security import UploadSecurityError
from modules.host_sandbox_policy import load_policy, save_policy
from modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal
from config.model_profiles import get_model_context_window
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
@ -244,11 +246,23 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
)
data_out = dict(data)
data_out["enabled_skills"] = enabled_skills
compression_settings = resolve_context_compression_settings(data_out)
try:
model_window = get_model_context_window(getattr(terminal, "model_key", None) or "kimi-k2.5")
except Exception:
model_window = None
return jsonify({
"success": True,
"data": data_out,
"tool_categories": terminal.get_tool_settings_snapshot(),
"skills_catalog": skills_catalog,
"context_compression_settings": {
**compression_settings,
"context_window_tokens": min(
int(compression_settings.get("deep_trigger_tokens") or 0),
int(model_window or compression_settings.get("deep_trigger_tokens") or 0),
),
},
"thinking_interval_default": THINKING_FAST_INTERVAL,
"thinking_interval_range": {
"min": THINKING_INTERVAL_MIN,
@ -314,11 +328,23 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
debug_log(f"应用个性化偏好失败: {exc}")
config_out = dict(config)
config_out["enabled_skills"] = enabled_skills
compression_settings = resolve_context_compression_settings(config_out)
try:
model_window = get_model_context_window(getattr(terminal, "model_key", None) or "kimi-k2.5")
except Exception:
model_window = None
return jsonify({
"success": True,
"data": config_out,
"tool_categories": terminal.get_tool_settings_snapshot(),
"skills_catalog": skills_catalog,
"context_compression_settings": {
**compression_settings,
"context_window_tokens": min(
int(compression_settings.get("deep_trigger_tokens") or 0),
int(model_window or compression_settings.get("deep_trigger_tokens") or 0),
),
},
"thinking_interval_default": THINKING_FAST_INTERVAL,
"thinking_interval_range": {
"min": THINKING_INTERVAL_MIN,