agent-Specialization/cli/src/eventMapper.ts
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

282 lines
12 KiB
TypeScript

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)}`;
}