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