* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-2): 格式化 commands (79 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-5): 格式化 components其余 + hooks + tools (232 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md - README.md: 大幅重写,更详细版本历史和配置示例 - Run.ps1: 新增 Windows 启动脚本 - TODO.md: 新增包完成清单 - V6.md: 删除(架构重构规划已不适用) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复以前的问题 * fix: 修复 login 面板的问题 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
510 lines
14 KiB
TypeScript
510 lines
14 KiB
TypeScript
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
|
import { Box, Text, type TextProps } from '../../ink.js'
|
|
import { feature } from 'bun:bundle'
|
|
import * as React from 'react'
|
|
import { useState } from 'react'
|
|
import sample from 'lodash-es/sample.js'
|
|
import {
|
|
BLACK_CIRCLE,
|
|
REFERENCE_MARK,
|
|
TEARDROP_ASTERISK,
|
|
} from '../../constants/figures.js'
|
|
import figures from 'figures'
|
|
import { basename } from 'path'
|
|
import { MessageResponse } from '../MessageResponse.js'
|
|
import { FilePathLink } from '../FilePathLink.js'
|
|
import { openPath } from '../../utils/browser.js'
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
const teamMemSaved = feature('TEAMMEM')
|
|
? (require('./teamMemSaved.js') as typeof import('./teamMemSaved.js'))
|
|
: null
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js'
|
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
|
import type {
|
|
SystemMessage,
|
|
SystemStopHookSummaryMessage,
|
|
SystemBridgeStatusMessage,
|
|
SystemTurnDurationMessage,
|
|
SystemThinkingMessage,
|
|
SystemMemorySavedMessage,
|
|
} from '../../types/message.js'
|
|
import { SystemAPIErrorMessage } from './SystemAPIErrorMessage.js'
|
|
import {
|
|
formatDuration,
|
|
formatNumber,
|
|
formatSecondsShort,
|
|
} from '../../utils/format.js'
|
|
import { getGlobalConfig } from '../../utils/config.js'
|
|
import Link from '../../ink/components/Link.js'
|
|
import ThemedText from '../design-system/ThemedText.js'
|
|
import { CtrlOToExpand } from '../CtrlOToExpand.js'
|
|
import { useAppStateStore } from '../../state/AppState.js'
|
|
import { isBackgroundTask, type TaskState } from '../../tasks/types.js'
|
|
import { getPillLabel } from '../../tasks/pillLabel.js'
|
|
import { useSelectedMessageBg } from '../messageActions.js'
|
|
|
|
type Props = {
|
|
message: SystemMessage
|
|
addMargin: boolean
|
|
verbose: boolean
|
|
isTranscriptMode?: boolean
|
|
}
|
|
|
|
export function SystemTextMessage({
|
|
message,
|
|
addMargin,
|
|
verbose,
|
|
isTranscriptMode,
|
|
}: Props): React.ReactNode {
|
|
const bg = useSelectedMessageBg()
|
|
// Turn duration messages are always shown in grey
|
|
if (message.subtype === 'turn_duration') {
|
|
return <TurnDurationMessage message={message} addMargin={addMargin} />
|
|
}
|
|
|
|
if (message.subtype === 'memory_saved') {
|
|
return <MemorySavedMessage message={message} addMargin={addMargin} />
|
|
}
|
|
|
|
if (message.subtype === 'away_summary') {
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
marginTop={addMargin ? 1 : 0}
|
|
backgroundColor={bg}
|
|
width="100%"
|
|
>
|
|
<Box minWidth={2}>
|
|
<Text dimColor>{REFERENCE_MARK}</Text>
|
|
</Box>
|
|
<Text dimColor>{message.content}</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// Agents killed confirmation
|
|
if (message.subtype === 'agents_killed') {
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
marginTop={addMargin ? 1 : 0}
|
|
backgroundColor={bg}
|
|
width="100%"
|
|
>
|
|
<Box minWidth={2}>
|
|
<Text color="error">{BLACK_CIRCLE}</Text>
|
|
</Box>
|
|
<Text dimColor>All background agents stopped</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// Thinking messages are subtle, like turn duration (ant-only)
|
|
if (message.subtype === 'thinking') {
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
return <ThinkingMessage message={message} addMargin={addMargin} />
|
|
}
|
|
return null
|
|
}
|
|
|
|
|
|
if (message.subtype === 'bridge_status') {
|
|
return <BridgeStatusMessage message={message} addMargin={addMargin} />
|
|
}
|
|
|
|
if (message.subtype === 'scheduled_task_fire') {
|
|
return (
|
|
<Box marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
|
|
<Text dimColor>
|
|
{TEARDROP_ASTERISK} {message.content}
|
|
</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (message.subtype === 'permission_retry') {
|
|
return (
|
|
<Box marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
|
|
<Text dimColor>{TEARDROP_ASTERISK} </Text>
|
|
<Text>Allowed </Text>
|
|
<Text bold>{message.commands.join(', ')}</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// Stop hook summaries should always be visible
|
|
const isStopHookSummary = message.subtype === 'stop_hook_summary'
|
|
|
|
if (!isStopHookSummary && !verbose && message.level === 'info') {
|
|
return null
|
|
}
|
|
|
|
if (message.subtype === 'api_error') {
|
|
return <SystemAPIErrorMessage message={message} verbose={verbose} />
|
|
}
|
|
|
|
if (message.subtype === 'stop_hook_summary') {
|
|
return (
|
|
<StopHookSummaryMessage
|
|
message={message}
|
|
addMargin={addMargin}
|
|
verbose={verbose}
|
|
isTranscriptMode={isTranscriptMode}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const content = message.content
|
|
// In case the event doesn't have a content
|
|
// validation, so content can be undefined at runtime despite the types.
|
|
if (typeof content !== 'string') {
|
|
return null
|
|
}
|
|
return (
|
|
<Box flexDirection="row" width="100%">
|
|
<SystemTextMessageInner
|
|
content={content}
|
|
addMargin={addMargin}
|
|
dot={message.level !== 'info'}
|
|
color={message.level === 'warning' ? 'warning' : undefined}
|
|
dimColor={message.level === 'info'}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function StopHookSummaryMessage({
|
|
message,
|
|
addMargin,
|
|
verbose,
|
|
isTranscriptMode,
|
|
}: {
|
|
message: SystemStopHookSummaryMessage
|
|
addMargin: boolean
|
|
verbose: boolean
|
|
isTranscriptMode?: boolean
|
|
}): React.ReactNode {
|
|
const bg = useSelectedMessageBg()
|
|
const {
|
|
hookCount,
|
|
hookInfos,
|
|
hookErrors,
|
|
preventedContinuation,
|
|
stopReason,
|
|
} = message
|
|
const { columns } = useTerminalSize()
|
|
|
|
// Prefer wall-clock time when available (hooks run in parallel)
|
|
const totalDurationMs =
|
|
message.totalDurationMs ??
|
|
hookInfos.reduce((sum, h) => sum + (h.durationMs ?? 0), 0)
|
|
const isAnt = process.env.USER_TYPE === 'ant'
|
|
|
|
// Only show summary if there are errors or continuation was prevented
|
|
// For ants: also show when hooks took > 500ms
|
|
// Non-stop hooks (e.g. PreToolUse) are pre-filtered by the caller
|
|
if (hookErrors.length === 0 && !preventedContinuation && !message.hookLabel) {
|
|
if (!isAnt || totalDurationMs < HOOK_TIMING_DISPLAY_THRESHOLD_MS) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const totalStr =
|
|
isAnt && totalDurationMs > 0
|
|
? ` (${formatSecondsShort(totalDurationMs)})`
|
|
: ''
|
|
// Non-stop hooks (e.g. PreToolUse) render as a child line without bullet
|
|
if (message.hookLabel) {
|
|
return (
|
|
<Box flexDirection="column" width="100%">
|
|
<Text dimColor>
|
|
{' ⎿ '}Ran {hookCount} {message.hookLabel}{' '}
|
|
{hookCount === 1 ? 'hook' : 'hooks'}
|
|
{totalStr}
|
|
</Text>
|
|
{isTranscriptMode &&
|
|
hookInfos.map((info, idx) => {
|
|
const durationStr =
|
|
isAnt && info.durationMs !== undefined
|
|
? ` (${formatSecondsShort(info.durationMs)})`
|
|
: ''
|
|
return (
|
|
<Text key={`cmd-${idx}`} dimColor>
|
|
{' ⎿ '}
|
|
{info.command === 'prompt'
|
|
? `prompt: ${info.promptText || ''}`
|
|
: info.command}
|
|
{durationStr}
|
|
</Text>
|
|
)
|
|
})}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
marginTop={addMargin ? 1 : 0}
|
|
backgroundColor={bg}
|
|
width="100%"
|
|
>
|
|
<Box minWidth={2}>
|
|
<Text>{BLACK_CIRCLE}</Text>
|
|
</Box>
|
|
<Box flexDirection="column" width={columns - 10}>
|
|
<Text>
|
|
Ran <Text bold>{hookCount}</Text> {message.hookLabel ?? 'stop'}{' '}
|
|
{hookCount === 1 ? 'hook' : 'hooks'}
|
|
{totalStr}
|
|
{!verbose && hookInfos.length > 0 && (
|
|
<>
|
|
{' '}
|
|
<CtrlOToExpand />
|
|
</>
|
|
)}
|
|
</Text>
|
|
{verbose &&
|
|
hookInfos.length > 0 &&
|
|
hookInfos.map((info, idx) => {
|
|
const durationStr =
|
|
isAnt && info.durationMs !== undefined
|
|
? ` (${formatSecondsShort(info.durationMs)})`
|
|
: ''
|
|
return (
|
|
<Text key={`cmd-${idx}`} dimColor>
|
|
⎿
|
|
{info.command === 'prompt'
|
|
? `prompt: ${info.promptText || ''}`
|
|
: info.command}
|
|
{durationStr}
|
|
</Text>
|
|
)
|
|
})}
|
|
{preventedContinuation && stopReason && (
|
|
<Text>
|
|
<Text dimColor>⎿ </Text>
|
|
{stopReason}
|
|
</Text>
|
|
)}
|
|
{hookErrors.length > 0 &&
|
|
hookErrors.map((err, idx) => (
|
|
<Text key={idx}>
|
|
<Text dimColor>⎿ </Text>
|
|
{message.hookLabel ?? 'Stop'} hook error: {err}
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function SystemTextMessageInner({
|
|
content,
|
|
addMargin,
|
|
dot,
|
|
color,
|
|
dimColor,
|
|
}: {
|
|
content: string
|
|
addMargin: boolean
|
|
dot: boolean
|
|
color?: TextProps['color']
|
|
dimColor?: boolean
|
|
}): React.ReactNode {
|
|
const { columns } = useTerminalSize()
|
|
const bg = useSelectedMessageBg()
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
marginTop={addMargin ? 1 : 0}
|
|
backgroundColor={bg}
|
|
width="100%"
|
|
>
|
|
{dot && (
|
|
<Box minWidth={2}>
|
|
<Text color={color} dimColor={dimColor}>
|
|
{BLACK_CIRCLE}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
<Box flexDirection="column" width={columns - 10}>
|
|
<Text color={color} dimColor={dimColor} wrap="wrap">
|
|
{content.trim()}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function TurnDurationMessage({
|
|
message,
|
|
addMargin,
|
|
}: {
|
|
message: SystemTurnDurationMessage
|
|
addMargin: boolean
|
|
}): React.ReactNode {
|
|
const bg = useSelectedMessageBg()
|
|
const [verb] = useState(() => sample(TURN_COMPLETION_VERBS) ?? 'Worked')
|
|
const store = useAppStateStore()
|
|
const [backgroundTaskSummary] = useState(() => {
|
|
const tasks = store.getState().tasks
|
|
const running = (Object.values(tasks ?? {}) as TaskState[]).filter(
|
|
isBackgroundTask,
|
|
)
|
|
return running.length > 0 ? getPillLabel(running) : null
|
|
})
|
|
|
|
const showTurnDuration = getGlobalConfig().showTurnDuration ?? true
|
|
|
|
const duration = formatDuration(message.durationMs)
|
|
const hasBudget = message.budgetLimit !== undefined
|
|
const budgetSuffix = (() => {
|
|
if (!hasBudget) return ''
|
|
const tokens = message.budgetTokens!
|
|
const limit = message.budgetLimit!
|
|
const usage =
|
|
tokens >= limit
|
|
? `${formatNumber(tokens)} used (${formatNumber(limit)} min ${figures.tick})`
|
|
: `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round((tokens / limit) * 100)}%)`
|
|
const nudges =
|
|
message.budgetNudges! > 0
|
|
? ` \u00B7 ${message.budgetNudges} ${message.budgetNudges === 1 ? 'nudge' : 'nudges'}`
|
|
: ''
|
|
return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}`
|
|
})()
|
|
|
|
if (!showTurnDuration && !hasBudget) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
marginTop={addMargin ? 1 : 0}
|
|
backgroundColor={bg}
|
|
width="100%"
|
|
>
|
|
<Box minWidth={2}>
|
|
<Text dimColor>{TEARDROP_ASTERISK}</Text>
|
|
</Box>
|
|
<Text dimColor>
|
|
{showTurnDuration && `${verb} for ${duration}`}
|
|
{budgetSuffix}
|
|
{backgroundTaskSummary &&
|
|
` \u00B7 ${backgroundTaskSummary} still running`}
|
|
</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function MemorySavedMessage({
|
|
message,
|
|
addMargin,
|
|
}: {
|
|
message: SystemMemorySavedMessage
|
|
addMargin: boolean
|
|
}): React.ReactNode {
|
|
const bg = useSelectedMessageBg()
|
|
const { writtenPaths } = message
|
|
const team = feature('TEAMMEM')
|
|
? teamMemSaved!.teamMemSavedPart(message)
|
|
: null
|
|
const privateCount = writtenPaths.length - (team?.count ?? 0)
|
|
const parts = [
|
|
privateCount > 0
|
|
? `${privateCount} ${privateCount === 1 ? 'memory' : 'memories'}`
|
|
: null,
|
|
team?.segment,
|
|
].filter(Boolean)
|
|
return (
|
|
<Box
|
|
flexDirection="column"
|
|
marginTop={addMargin ? 1 : 0}
|
|
backgroundColor={bg}
|
|
>
|
|
<Box flexDirection="row">
|
|
<Box minWidth={2}>
|
|
<Text dimColor>{BLACK_CIRCLE}</Text>
|
|
</Box>
|
|
<Text>
|
|
{message.verb ?? 'Saved'} {parts.join(' \u00B7 ')}
|
|
</Text>
|
|
</Box>
|
|
{writtenPaths.map(p => (
|
|
<MemoryFileRow key={p} path={p} />
|
|
))}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function MemoryFileRow({ path }: { path: string }): React.ReactNode {
|
|
const [hover, setHover] = useState(false)
|
|
return (
|
|
<MessageResponse>
|
|
<Box
|
|
onClick={() => void openPath(path)}
|
|
onMouseEnter={() => setHover(true)}
|
|
onMouseLeave={() => setHover(false)}
|
|
>
|
|
<Text dimColor={!hover} underline={hover}>
|
|
<FilePathLink filePath={path}>{basename(path)}</FilePathLink>
|
|
</Text>
|
|
</Box>
|
|
</MessageResponse>
|
|
)
|
|
}
|
|
|
|
function ThinkingMessage({
|
|
message,
|
|
addMargin,
|
|
}: {
|
|
message: SystemThinkingMessage
|
|
addMargin: boolean
|
|
}): React.ReactNode {
|
|
const bg = useSelectedMessageBg()
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
marginTop={addMargin ? 1 : 0}
|
|
backgroundColor={bg}
|
|
width="100%"
|
|
>
|
|
<Box minWidth={2}>
|
|
<Text dimColor>{TEARDROP_ASTERISK}</Text>
|
|
</Box>
|
|
<Text dimColor>{message.content}</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function BridgeStatusMessage({
|
|
message,
|
|
addMargin,
|
|
}: {
|
|
message: SystemBridgeStatusMessage
|
|
addMargin: boolean
|
|
}): React.ReactNode {
|
|
const bg = useSelectedMessageBg()
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
marginTop={addMargin ? 1 : 0}
|
|
backgroundColor={bg}
|
|
width={999}
|
|
>
|
|
<Box minWidth={2} />
|
|
<Box flexDirection="column">
|
|
<Text>
|
|
<ThemedText color="suggestion">/remote-control</ThemedText> is active.
|
|
Code in CLI or at
|
|
</Text>
|
|
<Link url={message.url}>{message.url}</Link>
|
|
{message.upgradeNudge && <Text dimColor>⎿ {message.upgradeNudge}</Text>}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|