## 新增:基于 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>
227 lines
7.1 KiB
JavaScript
227 lines
7.1 KiB
JavaScript
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 />);
|