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:
parent
e2bbd767f7
commit
cc9df7959a
29
AGENTS.md
29
AGENTS.md
@ -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`
|
||||
|
||||
### CLI(React / 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 @@
|
||||
|
||||
- Python:4 空格、`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 核心原则
|
||||
|
||||
60
README.md
60
README.md
@ -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
3
cli-react-demo/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
|
||||
.easyagent/
|
||||
226
cli-react-demo/demo.jsx
Normal file
226
cli-react-demo/demo.jsx
Normal 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
1067
cli-react-demo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
cli-react-demo/package.json
Normal file
14
cli-react-demo/package.json
Normal 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
2
cli/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
1660
cli/package-lock.json
generated
Normal file
1660
cli/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
cli/package.json
Normal file
27
cli/package.json
Normal 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
680
cli/src/App.tsx
Normal 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
313
cli/src/api.ts
Normal 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
28
cli/src/commands.ts
Normal 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
333
cli/src/components.tsx
Normal 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
281
cli/src/eventMapper.ts
Normal 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
29
cli/src/format.ts
Normal 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
7
cli/src/main.tsx
Normal 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
65
cli/src/types.ts
Normal 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
97
cli/src/workspaces.ts
Normal 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
14
cli/tsconfig.json
Normal 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"]
|
||||
}
|
||||
764
docs/cli_slash_commands_spec.md
Normal file
764
docs/cli_slash_commands_spec.md
Normal 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
523
docs/cli_ui_display_spec.md
Normal 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 在做什么。
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user