## 新增:基于 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>
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
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) }))}
|
||
/>
|
||
);
|
||
}
|