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 ( {left} {right} ); } export function Composer({ value, disabled, marginTop = 1 }: { value: string; disabled?: boolean; marginTop?: number }) { const { stdout } = useStdout(); const { setCursorPosition } = useCursor(); const contentRef = useRef(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 ( {lines.map((line, index) => ( {index === 0 ? : } {line} {index === lines.length - 1 ? : null} ))} ); } 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 ( Agents Welcome back! {status.model} · {status.runMode} {shortPath(status.directory)} ); } export function Timeline({ items, pulseTick = 0, width = 78 }: { items: TimelineItem[]; pulseTick?: number; width?: number }) { return ( {items.map((item) => ( ))} ); } function TimelineRow({ item, pulseTick }: { item: TimelineItem; pulseTick: number }) { const separator = useFullWidthSeparator(); if (item.kind === 'user') { const lines = String(item.body || '').split(/\r?\n/); return ( {lines.map((line, index) => ( {index === 0 ? : } {line} ))} ); } if (item.kind === 'assistant') { return ( {separator} {wrapIndentedText(String(item.body || ''), 2, separator.length).map((line, index) => ( {line} ))} {item.status === 'success' ? <> {separator} : null} ); } if (item.kind === 'system') { return • {item.body}; } 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 ( {dot} {item.title || item.kind} {showDetails && !running && item.body ? wrapIndentedText(`└ ${item.body}`, 2, detailWidth).map((line, index) => ( {line} )) : null} {showDetails && (item.kind === 'thinking' || !running) && (item.lines || []).map((line, index) => ( wrapIndentedText(line, index === 0 && !item.body ? 2 : 4, detailWidth).map((wrapped, wrappedIndex) => ( {index === 0 && !item.body && wrappedIndex === 0 ? wrapped.replace(/^ /, ' └ ') : wrapped} )) ))} ); } 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 ( {visibleCommands.map((command, visibleIndex) => { const index = adjustedStart + visibleIndex; return ( {index === selectedIndex ? '› ' : ' '}{command.name.padEnd(13)} {command.description} ); })} {!commands.length ? 无匹配命令 : null} ); } 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 ( {title} {visibleItems.map((item, visibleIndex) => { const index = adjustedStart + visibleIndex; return ( {index === selectedIndex ? '› ' : ' '}{item.label.padEnd(20)} {item.description || ''} ); })} {!items.length ? 暂无选项 : null} ); } export function WorkspacePrompt({ cwd, selectedIndex }: { cwd: string; selectedIndex: number }) { const options = ['确认,添加并继续', '拒绝,退出']; return ( 当前目录尚未添加为工作区: {shortPath(cwd)} Agent 将在此目录中读取文件、执行命令和修改内容。 是否将此目录添加为工作区? {options.map((label, index) => ( {index === selectedIndex ? '› ' : ' '}{index + 1}. {label} ))} ); } export function StatusPanel({ status }: { status: CliStatus }) { return ( {'>_ Agents CLI'} ); } function StatusField({ label, value }: { label: string; value: string }) { return {label.padEnd(15)} {value}; } export function HelpPanel({ commands }: { commands: SlashCommand[] }) { return ( Commands {commands.map((command) => ( {command.name.padEnd(13)} {command.description} ))} ); } export function SkillHint({ skill }: { skill?: SkillDefinition }) { if (!skill) return null; return + 先阅读 {skill.label} skill; } export function WorkspaceList({ workspaces, selectedIndex }: { workspaces: Workspace[]; selectedIndex: number }) { return ( ({ label: workspace.label, description: shortPath(workspace.path) }))} /> ); }