claude-code-best/src/components/messages/SystemTextMessage.tsx
claude-code-best 5b1a52b8e0
更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* 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>
2026-04-04 23:24:27 +08:00

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>
&nbsp;
{info.command === 'prompt'
? `prompt: ${info.promptText || ''}`
: info.command}
{durationStr}
</Text>
)
})}
{preventedContinuation && stopReason && (
<Text>
<Text dimColor> &nbsp;</Text>
{stopReason}
</Text>
)}
{hookErrors.length > 0 &&
hookErrors.map((err, idx) => (
<Text key={idx}>
<Text dimColor> &nbsp;</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>
)
}