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 (
Agents CLI React/Ink Demo
React 组件渲染到终端 · 支持粘贴多行 · 输入后回车发送 · Ctrl+C 取消当前任务 · /help 或 /exit
状态: {running ? '运行中' : '空闲'}
Turn: #{turn}
);
}
function Message({item}) {
if (item.type === 'user') {
return (
你
{item.time}
{item.text}
);
}
if (item.type === 'assistant') {
return (
助手 {item.time}
{item.text || 正在生成...}
);
}
if (item.type === 'tool') {
const statusColor = item.status === 'done' ? 'green' : item.status === 'running' ? 'yellow' : 'red';
return (
{item.status === 'done' ? '✓' : '●'} 工具调用:{item.name}
{item.detail}
{item.output ? {item.output} : null}
);
}
if (item.type === 'system') {
return ◆ {item.text};
}
return null;
}
function Transcript({items}) {
const visible = items.slice(-10);
return (
{visible.map(item => )}
);
}
function Composer({value, running}) {
return (
›
{value}
快捷键:Enter 发送 · 直接粘贴可保留多行 · Backspace 删除 · Ctrl+C {running ? '取消任务' : '退出'} · /help 查看命令
);
}
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 (
);
}
render();