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

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

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

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

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

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

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

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

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

## 其他

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

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

227 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 />);