agent-Specialization/cli/src/components.tsx
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

334 lines
13 KiB
TypeScript
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, { useEffect, useRef, useState } from 'react';
import { Box, Text, useCursor, useStdout, type DOMElement } from 'ink';
import type { CliStatus, SlashCommand, SkillDefinition, TimelineItem, Workspace } from './types.js';
import { formatContext, shortPath } from './format.js';
const SHOW_THINKING_DETAILS = false;
export function StatusLine({ status }: { status: CliStatus }) {
const skill = status.activeSkill ? ` skill:${status.activeSkill.label}` : '';
const background = [
status.backgroundAgents > 0 ? `${status.backgroundAgents}后台智能体` : '',
status.backgroundCommands > 0 ? `${status.backgroundCommands}后台指令` : '',
].filter(Boolean).join(' · ');
const left = `${status.model} · ${status.workspace.label} · ${status.permissionMode} · ${status.executionMode}${background ? ` · ${background}` : ''} · 版本控制:${status.versioningEnabled ? '开' : '关'}${skill}`;
const right = formatContext(status.contextUsed, status.contextLimit);
return (
<Box justifyContent="space-between" width="100%">
<Text color="gray">{left}</Text>
<Text color="gray">{right}</Text>
</Box>
);
}
export function Composer({ value, disabled, marginTop = 1 }: { value: string; disabled?: boolean; marginTop?: number }) {
const { stdout } = useStdout();
const { setCursorPosition } = useCursor();
const contentRef = useRef<DOMElement>(null);
const [contentOrigin, setContentOrigin] = useState<{ x: number; y: number } | null>(null);
const lines = String(value || '').split(/\r?\n/);
const lastLine = lines[lines.length - 1] || '';
const fallbackOrigin = { x: 1, y: Math.max(0, (stdout.rows || 24) - 3) };
const origin = contentOrigin || fallbackOrigin;
setCursorPosition({
x: origin.x + 2 + displayWidth(lastLine),
y: origin.y + Math.max(0, lines.length - 1),
});
useEffect(() => {
const next = getAbsoluteOrigin(contentRef.current);
if (!next) return;
setContentOrigin((prev) => (prev && prev.x === next.x && prev.y === next.y ? prev : next));
});
return (
<Box flexDirection="column" marginTop={marginTop}>
<Box borderStyle="single" borderColor={disabled ? 'gray' : 'cyan'} paddingX={1} flexDirection="column">
<Box ref={contentRef} flexDirection="column">
{lines.map((line, index) => (
<Text key={`composer-line-${index}`}>
{index === 0 ? <Text color="cyan"> </Text> : <Text> </Text>}
<Text>{line}</Text>
{index === lines.length - 1 ? <Text inverse> </Text> : null}
</Text>
))}
</Box>
</Box>
</Box>
);
}
function getAbsoluteOrigin(node: DOMElement | null): { x: number; y: number } | null {
if (!node?.yogaNode) return null;
let x = 0;
let y = 0;
let current: DOMElement | undefined = node;
while (current?.parentNode) {
if (!current.yogaNode) return null;
x += current.yogaNode.getComputedLeft();
y += current.yogaNode.getComputedTop();
current = current.parentNode;
}
return { x, y };
}
function displayWidth(text: string): number {
let width = 0;
for (const char of Array.from(text)) {
const code = char.codePointAt(0) || 0;
if (code === 0) continue;
if (code < 32 || (code >= 0x7f && code < 0xa0)) continue;
width += isWideCodePoint(code) ? 2 : 1;
}
return width;
}
function isWideCodePoint(code: number): boolean {
return (
code >= 0x1100 && (
code <= 0x115f ||
code === 0x2329 ||
code === 0x232a ||
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
(code >= 0xac00 && code <= 0xd7a3) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe10 && code <= 0xfe19) ||
(code >= 0xfe30 && code <= 0xfe6f) ||
(code >= 0xff00 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6) ||
(code >= 0x1f300 && code <= 0x1f64f) ||
(code >= 0x1f900 && code <= 0x1f9ff) ||
(code >= 0x20000 && code <= 0x3fffd)
)
);
}
export function WelcomePanel({ status }: { status: CliStatus }) {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Agents</Text>
<Box borderStyle="round" borderColor="gray" paddingX={2} paddingY={1} flexDirection="column">
<Text bold>Welcome back!</Text>
<Text color="gray">{status.model} · {status.runMode}</Text>
<Text color="gray">{shortPath(status.directory)}</Text>
</Box>
</Box>
);
}
export function Timeline({ items, pulseTick = 0, width = 78 }: { items: TimelineItem[]; pulseTick?: number; width?: number }) {
return (
<Box flexDirection="column">
{items.map((item) => (
<TimelineRow key={item.id} item={item} pulseTick={pulseTick} />
))}
</Box>
);
}
function TimelineRow({ item, pulseTick }: { item: TimelineItem; pulseTick: number }) {
const separator = useFullWidthSeparator();
if (item.kind === 'user') {
const lines = String(item.body || '').split(/\r?\n/);
return (
<Box flexDirection="column" marginTop={1}>
<Box borderStyle="single" borderColor="gray" paddingX={1} flexDirection="column">
{lines.map((line, index) => (
<Text key={`${item.id}-user-${index}`}>
{index === 0 ? <Text color="cyan"> </Text> : <Text> </Text>}
<Text>{line}</Text>
</Text>
))}
</Box>
</Box>
);
}
if (item.kind === 'assistant') {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">{separator}</Text>
<Text> </Text>
{wrapIndentedText(String(item.body || ''), 2, separator.length).map((line, index) => (
<Text key={`${item.id}-assistant-${index}`}>{line}</Text>
))}
{item.status === 'success' ? <><Text> </Text><Text color="gray">{separator}</Text></> : null}
</Box>
);
}
if (item.kind === 'system') {
return <Text color="gray"> {item.body}</Text>;
}
const running = item.status === 'running';
const failed = item.status === 'failed';
const color = running ? (pulseTick % 2 === 0 ? 'gray' : undefined) : failed ? 'red' : item.kind === 'thinking' ? undefined : 'green';
const dot = running ? (pulseTick % 2 === 0 ? '◦' : '•') : '•';
const detailColor = item.kind === 'thinking' || item.kind === 'tool' || item.kind === 'sub_agent' ? 'gray' : undefined;
const detailWidth = separator.length;
const showDetails = item.kind === 'thinking' ? SHOW_THINKING_DETAILS : true;
return (
<Box flexDirection="column" marginTop={1}>
<Text><Text color={color}>{dot}</Text> {item.title || item.kind}</Text>
{showDetails && !running && item.body ? wrapIndentedText(`${item.body}`, 2, detailWidth).map((line, index) => (
<Text key={`${item.id}-body-${index}`} color={detailColor}>{line}</Text>
)) : null}
{showDetails && (item.kind === 'thinking' || !running) && (item.lines || []).map((line, index) => (
wrapIndentedText(line, index === 0 && !item.body ? 2 : 4, detailWidth).map((wrapped, wrappedIndex) => (
<Text key={`${item.id}-${index}-${wrappedIndex}`} color={detailColor}>
{index === 0 && !item.body && wrappedIndex === 0 ? wrapped.replace(/^ /, ' └ ') : wrapped}
</Text>
))
))}
</Box>
);
}
function useFullWidthSeparator(): string {
const { stdout } = useStdout();
const width = Math.max(2, (stdout.columns || 80) - 2);
return '─'.repeat(width);
}
function wrapIndentedText(text: string, indent: number, width: number): string[] {
const prefix = ' '.repeat(indent);
const contentWidth = Math.max(10, width - indent);
const result: string[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine || '';
if (displayWidth(line) <= contentWidth) {
result.push(`${prefix}${line}`);
continue;
}
let current = '';
let currentWidth = 0;
for (const char of Array.from(line)) {
const charWidth = displayWidth(char);
if (current && currentWidth + charWidth > contentWidth) {
result.push(`${prefix}${current}`);
current = char;
currentWidth = charWidth;
} else {
current += char;
currentWidth += charWidth;
}
}
result.push(`${prefix}${current}`);
}
return result.length ? result : [prefix];
}
export function SlashMenu({ query, commands, selectedIndex }: { query: string; commands: SlashCommand[]; selectedIndex: number }) {
const visibleCount = 5;
const start = Math.max(0, selectedIndex - 2);
const end = Math.min(commands.length, start + visibleCount);
const adjustedStart = Math.max(0, end - visibleCount);
const visibleCommands = commands.slice(adjustedStart, end);
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column">
{visibleCommands.map((command, visibleIndex) => {
const index = adjustedStart + visibleIndex;
return (
<Text key={command.name} color={index === selectedIndex ? 'cyan' : undefined}>
{index === selectedIndex ? ' ' : ' '}{command.name.padEnd(13)} {command.description}
</Text>
);
})}
{!commands.length ? <Text color="gray"> </Text> : null}
</Box>
</Box>
);
}
export function Picker({ title, items, selectedIndex }: { title: string; items: Array<{ label: string; description?: string; disabled?: boolean }>; selectedIndex: number }) {
const visibleCount = 5;
const start = Math.max(0, selectedIndex - 2);
const end = Math.min(items.length, start + visibleCount);
const adjustedStart = Math.max(0, end - visibleCount);
const visibleItems = items.slice(adjustedStart, end);
return (
<Box flexDirection="column" marginTop={1}>
<Text bold>{title}</Text>
<Box flexDirection="column" marginTop={1}>
{visibleItems.map((item, visibleIndex) => {
const index = adjustedStart + visibleIndex;
return (
<Text key={`${item.label}-${index}`} color={item.disabled ? 'gray' : index === selectedIndex ? 'cyan' : undefined}>
{index === selectedIndex ? ' ' : ' '}{item.label.padEnd(20)} {item.description || ''}
</Text>
);
})}
{!items.length ? <Text color="gray"> </Text> : null}
</Box>
</Box>
);
}
export function WorkspacePrompt({ cwd, selectedIndex }: { cwd: string; selectedIndex: number }) {
const options = ['确认,添加并继续', '拒绝,退出'];
return (
<Box flexDirection="column">
<Text color="yellow"></Text>
<Text> {shortPath(cwd)}</Text>
<Box marginTop={1} flexDirection="column">
<Text>Agent </Text>
<Text></Text>
</Box>
<Box marginTop={1} flexDirection="column">
{options.map((label, index) => (
<Text key={label} color={index === selectedIndex ? 'cyan' : undefined}>
{index === selectedIndex ? ' ' : ' '}{index + 1}. {label}
</Text>
))}
</Box>
</Box>
);
}
export function StatusPanel({ status }: { status: CliStatus }) {
return (
<Box borderStyle="round" borderColor="gray" paddingX={1} flexDirection="column" marginTop={1}>
<Text>{'>_ Agents CLI'}</Text>
<Text> </Text>
<StatusField label="Model" value={status.model} />
<StatusField label="Directory" value={shortPath(status.directory)} />
<StatusField label="Workspace" value={status.workspace.label} />
<StatusField label="Permissions" value={status.permissionMode} />
<StatusField label="Execution" value={status.executionMode} />
<StatusField label="Session" value={status.sessionId} />
<StatusField label="Context window" value={formatContext(status.contextUsed, status.contextLimit)} />
</Box>
);
}
function StatusField({ label, value }: { label: string; value: string }) {
return <Text> {label.padEnd(15)} {value}</Text>;
}
export function HelpPanel({ commands }: { commands: SlashCommand[] }) {
return (
<Box flexDirection="column" marginTop={1}>
<Text bold>Commands</Text>
<Box flexDirection="column" marginTop={1}>
{commands.map((command) => (
<Text key={command.name}> {command.name.padEnd(13)} {command.description}</Text>
))}
</Box>
</Box>
);
}
export function SkillHint({ skill }: { skill?: SkillDefinition }) {
if (!skill) return null;
return <Text color="gray"> + {skill.label} skill</Text>;
}
export function WorkspaceList({ workspaces, selectedIndex }: { workspaces: Workspace[]; selectedIndex: number }) {
return (
<Picker
title="Workspace"
selectedIndex={selectedIndex}
items={workspaces.map((workspace) => ({ label: workspace.label, description: shortPath(workspace.path) }))}
/>
);
}