style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -1,22 +1,22 @@
import figures from 'figures'
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { AdvisorBlock } from '../../utils/advisor.js'
import { renderModelName } from '../../utils/model/model.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { CtrlOToExpand } from '../CtrlOToExpand.js'
import { MessageResponse } from '../MessageResponse.js'
import { ToolUseLoader } from '../ToolUseLoader.js'
import figures from 'figures';
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { AdvisorBlock } from '../../utils/advisor.js';
import { renderModelName } from '../../utils/model/model.js';
import { jsonStringify } from '../../utils/slowOperations.js';
import { CtrlOToExpand } from '../CtrlOToExpand.js';
import { MessageResponse } from '../MessageResponse.js';
import { ToolUseLoader } from '../ToolUseLoader.js';
type Props = {
block: AdvisorBlock
addMargin: boolean
resolvedToolUseIDs: Set<string>
erroredToolUseIDs: Set<string>
shouldAnimate: boolean
verbose: boolean
advisorModel?: string
}
block: AdvisorBlock;
addMargin: boolean;
resolvedToolUseIDs: Set<string>;
erroredToolUseIDs: Set<string>;
shouldAnimate: boolean;
verbose: boolean;
advisorModel?: string;
};
export function AdvisorMessage({
block,
@@ -28,10 +28,7 @@ export function AdvisorMessage({
advisorModel,
}: Props): React.ReactNode {
if (block.type === 'server_tool_use') {
const input =
block.input && Object.keys(block.input).length > 0
? jsonStringify(block.input)
: null
const input = block.input && Object.keys(block.input).length > 0 ? jsonStringify(block.input) : null;
return (
<Box marginTop={addMargin ? 1 : 0} paddingRight={2} flexDirection="row">
<ToolUseLoader
@@ -40,46 +37,34 @@ export function AdvisorMessage({
isError={erroredToolUseIDs.has(block.id)}
/>
<Text bold>Advising</Text>
{advisorModel ? (
<Text dimColor> using {renderModelName(advisorModel)}</Text>
) : null}
{advisorModel ? <Text dimColor> using {renderModelName(advisorModel)}</Text> : null}
{input ? <Text dimColor> · {input}</Text> : null}
</Box>
)
);
}
let body: React.ReactNode
let body: React.ReactNode;
switch (block.content.type) {
case 'advisor_tool_result_error':
body = (
<Text color="error">
Advisor unavailable ({block.content.error_code})
</Text>
)
break
body = <Text color="error">Advisor unavailable ({block.content.error_code})</Text>;
break;
case 'advisor_result':
body = verbose ? (
<Text dimColor>{block.content.text}</Text>
) : (
<Text dimColor>
{figures.tick} Advisor has reviewed the conversation and will apply
the feedback <CtrlOToExpand />
{figures.tick} Advisor has reviewed the conversation and will apply the feedback <CtrlOToExpand />
</Text>
)
break
);
break;
case 'advisor_redacted_result':
body = (
<Text dimColor>
{figures.tick} Advisor has reviewed the conversation and will apply
the feedback
</Text>
)
break
body = <Text dimColor>{figures.tick} Advisor has reviewed the conversation and will apply the feedback</Text>;
break;
}
return (
<Box paddingRight={2}>
<MessageResponse>{body}</MessageResponse>
</Box>
)
);
}

View File

@@ -1,18 +1,16 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import React from 'react';
import { Box, Text } from '@anthropic/ink';
type Props = {
addMargin: boolean
}
addMargin: boolean;
};
export function AssistantRedactedThinkingMessage({
addMargin = false,
}: Props): React.ReactNode {
export function AssistantRedactedThinkingMessage({ addMargin = false }: Props): React.ReactNode {
return (
<Box marginTop={addMargin ? 1 : 0}>
<Text dimColor italic>
Thinking
</Text>
</Box>
)
);
}

View File

@@ -1,9 +1,9 @@
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import React, { useContext } from 'react'
import { ERROR_MESSAGE_USER_ABORT } from 'src/services/compact/compact.js'
import { isRateLimitErrorMessage } from 'src/services/rateLimitMessages.js'
import { BLACK_CIRCLE } from '../../constants/figures.js'
import { Box, NoSelect, Text } from '@anthropic/ink'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import React, { useContext } from 'react';
import { ERROR_MESSAGE_USER_ABORT } from 'src/services/compact/compact.js';
import { isRateLimitErrorMessage } from 'src/services/rateLimitMessages.js';
import { BLACK_CIRCLE } from '../../constants/figures.js';
import { Box, NoSelect, Text } from '@anthropic/ink';
import {
API_ERROR_MESSAGE_PREFIX,
API_TIMEOUT_ERROR_MESSAGE,
@@ -16,50 +16,40 @@ import {
PROMPT_TOO_LONG_ERROR_MESSAGE,
startsWithApiErrorPrefix,
TOKEN_REVOKED_ERROR_MESSAGE,
} from '../../services/api/errors.js'
import {
isEmptyMessageText,
NO_RESPONSE_REQUESTED,
} from '../../utils/messages.js'
import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js'
import {
getDefaultSonnetModel,
renderModelName,
} from '../../utils/model/model.js'
import { isMacOsKeychainLocked } from '../../utils/secureStorage/macOsKeychainStorage.js'
import { CtrlOToExpand } from '../CtrlOToExpand.js'
import { InterruptedByUser } from '../InterruptedByUser.js'
import { Markdown } from '../Markdown.js'
import { MessageResponse } from '../MessageResponse.js'
import { MessageActionsSelectedContext } from '../messageActions.js'
import { RateLimitMessage } from './RateLimitMessage.js'
} from '../../services/api/errors.js';
import { isEmptyMessageText, NO_RESPONSE_REQUESTED } from '../../utils/messages.js';
import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js';
import { getDefaultSonnetModel, renderModelName } from '../../utils/model/model.js';
import { isMacOsKeychainLocked } from '../../utils/secureStorage/macOsKeychainStorage.js';
import { CtrlOToExpand } from '../CtrlOToExpand.js';
import { InterruptedByUser } from '../InterruptedByUser.js';
import { Markdown } from '../Markdown.js';
import { MessageResponse } from '../MessageResponse.js';
import { MessageActionsSelectedContext } from '../messageActions.js';
import { RateLimitMessage } from './RateLimitMessage.js';
const MAX_API_ERROR_CHARS = 1000
const MAX_API_ERROR_CHARS = 1000;
type Props = {
param: TextBlockParam
addMargin: boolean
shouldShowDot: boolean
verbose: boolean
width?: number | string
onOpenRateLimitOptions?: () => void
}
param: TextBlockParam;
addMargin: boolean;
shouldShowDot: boolean;
verbose: boolean;
width?: number | string;
onOpenRateLimitOptions?: () => void;
};
function InvalidApiKeyMessage(): React.ReactNode {
const isKeychainLocked = isMacOsKeychainLocked()
const isKeychainLocked = isMacOsKeychainLocked();
return (
<MessageResponse>
<Box flexDirection="column">
<Text color="error">{INVALID_API_KEY_ERROR_MESSAGE}</Text>
{isKeychainLocked && (
<Text dimColor>
· Run in another terminal: security unlock-keychain
</Text>
)}
{isKeychainLocked && <Text dimColor>· Run in another terminal: security unlock-keychain</Text>}
</Box>
</MessageResponse>
)
);
}
export function AssistantTextMessage({
@@ -69,30 +59,25 @@ export function AssistantTextMessage({
verbose,
onOpenRateLimitOptions,
}: Props): React.ReactNode {
const isSelected = useContext(MessageActionsSelectedContext)
const isSelected = useContext(MessageActionsSelectedContext);
if (isEmptyMessageText(text)) {
return null
return null;
}
// Handle all rate limit error messages from getRateLimitErrorMessage
// Use the exported function to avoid fragile string coupling
if (isRateLimitErrorMessage(text)) {
return (
<RateLimitMessage
text={text}
onOpenRateLimitOptions={onOpenRateLimitOptions}
/>
)
return <RateLimitMessage text={text} onOpenRateLimitOptions={onOpenRateLimitOptions} />;
}
switch (text) {
// Local JSX commands don't need a response, but we still want Claude to see them
// Tool results render their own interrupt messages
case NO_RESPONSE_REQUESTED:
return null
return null;
case PROMPT_TOO_LONG_ERROR_MESSAGE: {
const upgradeHint = getUpgradeMessage('warning')
const upgradeHint = getUpgradeMessage('warning');
return (
<MessageResponse height={1}>
<Text color="error">
@@ -100,28 +85,27 @@ export function AssistantTextMessage({
{upgradeHint ? ` · ${upgradeHint}` : ''}
</Text>
</MessageResponse>
)
);
}
case CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE:
return (
<MessageResponse height={1}>
<Text color="error">
Credit balance too low &middot; Add funds:
https://platform.claude.com/settings/billing
Credit balance too low &middot; Add funds: https://platform.claude.com/settings/billing
</Text>
</MessageResponse>
)
);
case INVALID_API_KEY_ERROR_MESSAGE:
return <InvalidApiKeyMessage />
return <InvalidApiKeyMessage />;
case INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL:
return (
<MessageResponse height={1}>
<Text color="error">{INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL}</Text>
</MessageResponse>
)
);
case ORG_DISABLED_ERROR_MESSAGE_ENV_KEY:
case ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH:
@@ -129,45 +113,37 @@ export function AssistantTextMessage({
<MessageResponse>
<Text color="error">{text}</Text>
</MessageResponse>
)
);
case TOKEN_REVOKED_ERROR_MESSAGE:
return (
<MessageResponse height={1}>
<Text color="error">{TOKEN_REVOKED_ERROR_MESSAGE}</Text>
</MessageResponse>
)
);
case API_TIMEOUT_ERROR_MESSAGE:
return (
<MessageResponse height={1}>
<Text color="error">
{API_TIMEOUT_ERROR_MESSAGE}
{process.env.API_TIMEOUT_MS && (
<>
{' '}
(API_TIMEOUT_MS={process.env.API_TIMEOUT_MS}ms, try increasing
it)
</>
)}
{process.env.API_TIMEOUT_MS && <> (API_TIMEOUT_MS={process.env.API_TIMEOUT_MS}ms, try increasing it)</>}
</Text>
</MessageResponse>
)
);
case CUSTOM_OFF_SWITCH_MESSAGE:
return (
<MessageResponse>
<Box flexDirection="column" gap={1}>
<Text color="error">
We are experiencing high demand for Opus 4.
</Text>
<Text color="error">We are experiencing high demand for Opus 4.</Text>
<Text>
To continue immediately, use /model to switch to{' '}
{renderModelName(getDefaultSonnetModel())} and continue coding.
To continue immediately, use /model to switch to {renderModelName(getDefaultSonnetModel())} and continue
coding.
</Text>
</Box>
</MessageResponse>
)
);
// TODO: Move this to a user turn
case ERROR_MESSAGE_USER_ABORT:
@@ -175,11 +151,11 @@ export function AssistantTextMessage({
<MessageResponse height={1}>
<InterruptedByUser />
</MessageResponse>
)
);
default:
if (startsWithApiErrorPrefix(text)) {
const truncated = !verbose && text.length > MAX_API_ERROR_CHARS
const truncated = !verbose && text.length > MAX_API_ERROR_CHARS;
return (
<MessageResponse>
<Box flexDirection="column">
@@ -193,7 +169,7 @@ export function AssistantTextMessage({
{truncated && <CtrlOToExpand />}
</Box>
</MessageResponse>
)
);
}
return (
<Box
@@ -207,9 +183,7 @@ export function AssistantTextMessage({
<Box flexDirection="row">
{shouldShowDot && (
<NoSelect fromLeftEdge minWidth={2}>
<Text color={isSelected ? 'suggestion' : 'text'}>
{BLACK_CIRCLE}
</Text>
<Text color={isSelected ? 'suggestion' : 'text'}>{BLACK_CIRCLE}</Text>
</NoSelect>
)}
<Box flexDirection="column">
@@ -217,6 +191,6 @@ export function AssistantTextMessage({
</Box>
</Box>
</Box>
)
);
}
}

View File

@@ -1,24 +1,18 @@
import type {
ThinkingBlock,
ThinkingBlockParam,
} from '@anthropic-ai/sdk/resources/index.mjs'
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import { CtrlOToExpand } from '../CtrlOToExpand.js'
import { Markdown } from '../Markdown.js'
import type { ThinkingBlock, ThinkingBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import { CtrlOToExpand } from '../CtrlOToExpand.js';
import { Markdown } from '../Markdown.js';
type Props = {
// Accept either full ThinkingBlock/ThinkingBlockParam or a minimal shape with just type and thinking
param:
| ThinkingBlock
| ThinkingBlockParam
| { type: 'thinking'; thinking: string }
addMargin: boolean
isTranscriptMode: boolean
verbose: boolean
param: ThinkingBlock | ThinkingBlockParam | { type: 'thinking'; thinking: string };
addMargin: boolean;
isTranscriptMode: boolean;
verbose: boolean;
/** When true, hide this thinking block entirely (used for past thinking in transcript mode) */
hideInTranscript?: boolean
}
hideInTranscript?: boolean;
};
export function AssistantThinkingMessage({
param: { thinking },
@@ -28,15 +22,15 @@ export function AssistantThinkingMessage({
hideInTranscript = false,
}: Props): React.ReactNode {
if (!thinking) {
return null
return null;
}
if (hideInTranscript) {
return null
return null;
}
const shouldShowFullThinking = isTranscriptMode || verbose
const label = '∴ Thinking'
const shouldShowFullThinking = isTranscriptMode || verbose;
const label = '∴ Thinking';
if (!shouldShowFullThinking) {
return (
@@ -45,16 +39,11 @@ export function AssistantThinkingMessage({
{label} <CtrlOToExpand />
</Text>
</Box>
)
);
}
return (
<Box
flexDirection="column"
gap={1}
marginTop={addMargin ? 1 : 0}
width="100%"
>
<Box flexDirection="column" gap={1} marginTop={addMargin ? 1 : 0} width="100%">
<Text dimColor italic>
{label}
</Text>
@@ -62,5 +51,5 @@ export function AssistantThinkingMessage({
<Markdown dimColor>{thinking}</Markdown>
</Box>
</Box>
)
);
}

View File

@@ -1,41 +1,36 @@
import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import React, { useMemo } from 'react'
import { useTerminalSize } from 'src/hooks/useTerminalSize.js'
import type { ThemeName } from 'src/utils/theme.js'
import type { Command } from '../../commands.js'
import { BLACK_CIRCLE } from '../../constants/figures.js'
import { Box, Text, stringWidth, useTheme } from '@anthropic/ink'
import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js'
import {
findToolByName,
type Tool,
type ToolProgressData,
type Tools,
} from '../../Tool.js'
import type { ProgressMessage } from '../../types/message.js'
import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js'
import { logError } from '../../utils/log.js'
import type { buildMessageLookups } from '../../utils/messages.js'
import { MessageResponse } from '../MessageResponse.js'
import { useSelectedMessageBg } from '../messageActions.js'
import { SentryErrorBoundary } from '../SentryErrorBoundary.js'
import { ToolUseLoader } from '../ToolUseLoader.js'
import { HookProgressMessage } from './HookProgressMessage.js'
import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import React, { useMemo } from 'react';
import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
import type { ThemeName } from 'src/utils/theme.js';
import type { Command } from '../../commands.js';
import { BLACK_CIRCLE } from '../../constants/figures.js';
import { Box, Text, stringWidth, useTheme } from '@anthropic/ink';
import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js';
import { findToolByName, type Tool, type ToolProgressData, type Tools } from '../../Tool.js';
import type { ProgressMessage } from '../../types/message.js';
import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js';
import { logError } from '../../utils/log.js';
import type { buildMessageLookups } from '../../utils/messages.js';
import { MessageResponse } from '../MessageResponse.js';
import { useSelectedMessageBg } from '../messageActions.js';
import { SentryErrorBoundary } from '../SentryErrorBoundary.js';
import { ToolUseLoader } from '../ToolUseLoader.js';
import { HookProgressMessage } from './HookProgressMessage.js';
type Props = {
param: ToolUseBlockParam
addMargin: boolean
tools: Tools
commands: Command[]
verbose: boolean
inProgressToolUseIDs: Set<string>
progressMessagesForMessage: ProgressMessage[]
shouldAnimate: boolean
shouldShowDot: boolean
inProgressToolCallCount?: number
lookups: ReturnType<typeof buildMessageLookups>
isTranscriptMode?: boolean
}
param: ToolUseBlockParam;
addMargin: boolean;
tools: Tools;
commands: Command[];
verbose: boolean;
inProgressToolUseIDs: Set<string>;
progressMessagesForMessage: ProgressMessage[];
shouldAnimate: boolean;
shouldShowDot: boolean;
inProgressToolCallCount?: number;
lookups: ReturnType<typeof buildMessageLookups>;
isTranscriptMode?: boolean;
};
export function AssistantToolUseMessage({
param,
@@ -51,29 +46,21 @@ export function AssistantToolUseMessage({
lookups,
isTranscriptMode,
}: Props): React.ReactNode {
const terminalSize = useTerminalSize()
const [theme] = useTheme()
const bg = useSelectedMessageBg()
const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider(
state => state.pendingWorkerRequest,
)
const isClassifierCheckingRaw = useIsClassifierChecking(param.id)
const permissionMode = useAppStateMaybeOutsideOfProvider(
state => state.toolPermissionContext.mode,
)
const terminalSize = useTerminalSize();
const [theme] = useTheme();
const bg = useSelectedMessageBg();
const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider(state => state.pendingWorkerRequest);
const isClassifierCheckingRaw = useIsClassifierChecking(param.id);
const permissionMode = useAppStateMaybeOutsideOfProvider(state => state.toolPermissionContext.mode);
// strippedDangerousRules is set by stripDangerousPermissionsForAutoMode
// (even to {}) whenever auto is active, and cleared by restoreDangerousPermissions
// on deactivation — a reliable proxy for isAutoModeActive() during plan.
// prePlanMode would be stale after transitionPlanAutoMode deactivates mid-plan.
const hasStrippedRules = useAppStateMaybeOutsideOfProvider(
state => !!state.toolPermissionContext.strippedDangerousRules,
)
const isAutoClassifier =
permissionMode === 'auto' || (permissionMode === 'plan' && hasStrippedRules)
const isClassifierChecking =
process.env.USER_TYPE === 'ant' &&
isClassifierCheckingRaw &&
permissionMode !== 'auto'
);
const isAutoClassifier = permissionMode === 'auto' || (permissionMode === 'plan' && hasStrippedRules);
const isClassifierChecking = process.env.USER_TYPE === 'ant' && isClassifierCheckingRaw && permissionMode !== 'auto';
// Memoize on param identity (stable — from the persisted message object).
// Zod safeParse allocates per call, and some tools' userFacingName()
@@ -81,47 +68,34 @@ export function AssistantToolUseMessage({
// this, ~50 bash messages × shell-quote-per-render pushed transition
// render past the shimmer tick → abort → infinite retry (#21605).
const parsed = useMemo(() => {
if (!tools) return null
const tool = findToolByName(tools, param.name)
if (!tool) return null
const input = tool.inputSchema.safeParse(param.input)
const data = input.success ? input.data : undefined
if (!tools) return null;
const tool = findToolByName(tools, param.name);
if (!tool) return null;
const input = tool.inputSchema.safeParse(param.input);
const data = input.success ? input.data : undefined;
return {
tool,
input,
userFacingToolName: tool.userFacingName(data),
userFacingToolNameBackgroundColor:
tool.userFacingNameBackgroundColor?.(data),
userFacingToolNameBackgroundColor: tool.userFacingNameBackgroundColor?.(data),
isTransparentWrapper: tool.isTransparentWrapper?.() ?? false,
}
}, [tools, param])
};
}, [tools, param]);
if (!parsed) {
// Guard against undefined tools (required prop) or unknown tool name
logError(
new Error(
tools
? `Tool ${param.name} not found`
: `Tools array is undefined for tool ${param.name}`,
),
)
return null
logError(new Error(tools ? `Tool ${param.name} not found` : `Tools array is undefined for tool ${param.name}`));
return null;
}
const {
tool,
input,
userFacingToolName,
userFacingToolNameBackgroundColor,
isTransparentWrapper,
} = parsed
const { tool, input, userFacingToolName, userFacingToolNameBackgroundColor, isTransparentWrapper } = parsed;
const isResolved = lookups.resolvedToolUseIDs.has(param.id)
const isQueued = !inProgressToolUseIDs.has(param.id) && !isResolved
const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id
const isResolved = lookups.resolvedToolUseIDs.has(param.id);
const isQueued = !inProgressToolUseIDs.has(param.id) && !isResolved;
const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id;
if (isTransparentWrapper) {
if (isQueued || isResolved) return null
if (isQueued || isResolved) return null;
return (
<Box flexDirection="column" width="100%" backgroundColor={bg}>
{renderToolUseProgressMessage(
@@ -134,18 +108,18 @@ export function AssistantToolUseMessage({
terminalSize,
)}
</Box>
)
);
}
if (userFacingToolName === '') {
return null
return null;
}
const renderedToolUseMessage = input.success
? renderToolUseMessage(tool, input.data, { theme, verbose, commands })
: null
: null;
if (renderedToolUseMessage === null) {
return null
return null;
}
return (
@@ -157,11 +131,7 @@ export function AssistantToolUseMessage({
backgroundColor={bg}
>
<Box flexDirection="column">
<Box
flexDirection="row"
flexWrap="nowrap"
minWidth={stringWidth(userFacingToolName) + (shouldShowDot ? 2 : 0)}
>
<Box flexDirection="row" flexWrap="nowrap" minWidth={stringWidth(userFacingToolName) + (shouldShowDot ? 2 : 0)}>
{shouldShowDot &&
(isQueued ? (
<Box minWidth={2}>
@@ -182,9 +152,7 @@ export function AssistantToolUseMessage({
bold
wrap="truncate-end"
backgroundColor={userFacingToolNameBackgroundColor}
color={
userFacingToolNameBackgroundColor ? 'inverseText' : undefined
}
color={userFacingToolNameBackgroundColor ? 'inverseText' : undefined}
>
{userFacingToolName}
</Text>
@@ -195,18 +163,14 @@ export function AssistantToolUseMessage({
</Box>
)}
{/* Render tool-specific tags (timeout, model, resume ID, etc.) */}
{input.success &&
tool.renderToolUseTag &&
tool.renderToolUseTag(input.data)}
{input.success && tool.renderToolUseTag && tool.renderToolUseTag(input.data)}
</Box>
{!isResolved &&
!isQueued &&
(isClassifierChecking ? (
<MessageResponse height={1}>
<Text dimColor>
{isAutoClassifier
? 'Auto classifier checking\u2026'
: 'Bash classifier checking\u2026'}
{isAutoClassifier ? 'Auto classifier checking\u2026' : 'Bash classifier checking\u2026'}
</Text>
</MessageResponse>
) : isWaitingForPermission ? (
@@ -231,29 +195,23 @@ export function AssistantToolUseMessage({
{!isResolved && isQueued && renderToolUseQueuedMessage(tool)}
</Box>
</Box>
)
);
}
function renderToolUseMessage(
tool: Tool,
input: unknown,
{
theme,
verbose,
commands,
}: { theme: ThemeName; verbose: boolean; commands: Command[] },
{ theme, verbose, commands }: { theme: ThemeName; verbose: boolean; commands: Command[] },
): React.ReactNode {
try {
const parsed = tool.inputSchema.safeParse(input)
const parsed = tool.inputSchema.safeParse(input);
if (!parsed.success) {
return ''
return '';
}
return tool.renderToolUseMessage(parsed.data, { theme, verbose, commands })
return tool.renderToolUseMessage(parsed.data, { theme, verbose, commands });
} catch (error) {
logError(
new Error(`Error rendering tool use message for ${tool.name}: ${error}`),
)
return ''
logError(new Error(`Error rendering tool use message for ${tool.name}: ${error}`));
return '';
}
}
@@ -268,16 +226,15 @@ function renderToolUseProgressMessage(
inProgressToolCallCount,
isTranscriptMode,
}: {
verbose: boolean
inProgressToolCallCount?: number
isTranscriptMode?: boolean
verbose: boolean;
inProgressToolCallCount?: number;
isTranscriptMode?: boolean;
},
terminalSize: { columns: number; rows: number },
): React.ReactNode {
const toolProgressMessages = progressMessagesForMessage.filter(
(msg): msg is ProgressMessage<ToolProgressData> =>
(msg.data as Record<string, unknown>).type !== 'hook_progress',
)
(msg): msg is ProgressMessage<ToolProgressData> => (msg.data as Record<string, unknown>).type !== 'hook_progress',
);
try {
const toolMessages =
tool.renderToolUseProgressMessage?.(toolProgressMessages, {
@@ -286,7 +243,7 @@ function renderToolUseProgressMessage(
terminalSize,
inProgressToolCallCount: inProgressToolCallCount ?? 1,
isTranscriptMode,
}) ?? null
}) ?? null;
return (
<>
<SentryErrorBoundary>
@@ -300,26 +257,18 @@ function renderToolUseProgressMessage(
</SentryErrorBoundary>
{toolMessages}
</>
)
);
} catch (error) {
logError(
new Error(
`Error rendering tool use progress message for ${tool.name}: ${error}`,
),
)
return null
logError(new Error(`Error rendering tool use progress message for ${tool.name}: ${error}`));
return null;
}
}
function renderToolUseQueuedMessage(tool: Tool): React.ReactNode {
try {
return tool.renderToolUseQueuedMessage?.()
return tool.renderToolUseQueuedMessage?.();
} catch (error) {
logError(
new Error(
`Error rendering tool use queued message for ${tool.name}: ${error}`,
),
)
return null
logError(new Error(`Error rendering tool use queued message for ${tool.name}: ${error}`));
return null;
}
}

View File

@@ -1,90 +1,76 @@
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import React, { useMemo } from 'react'
import { Ansi, Box, Text } from '@anthropic/ink'
import { FilePathLink } from '../FilePathLink.js'
import { toInkColor } from '../../utils/ink.js'
import type { Attachment } from 'src/utils/attachments.js'
import type { NullRenderingAttachmentType } from './nullRenderingAttachments.js'
import { useAppState } from '../../state/AppState.js'
import { getDisplayPath } from 'src/utils/file.js'
import { formatFileSize } from 'src/utils/format.js'
import { MessageResponse } from '../MessageResponse.js'
import { basename, sep } from 'path'
import { UserTextMessage } from './UserTextMessage.js'
import { DiagnosticsDisplay } from '../DiagnosticsDisplay.js'
import { getContentText } from 'src/utils/messages.js'
import type { Theme } from 'src/utils/theme.js'
import { UserImageMessage } from './UserImageMessage.js'
import React, { useMemo } from 'react';
import { Ansi, Box, Text } from '@anthropic/ink';
import { FilePathLink } from '../FilePathLink.js';
import { toInkColor } from '../../utils/ink.js';
import type { Attachment } from 'src/utils/attachments.js';
import type { NullRenderingAttachmentType } from './nullRenderingAttachments.js';
import { useAppState } from '../../state/AppState.js';
import { getDisplayPath } from 'src/utils/file.js';
import { formatFileSize } from 'src/utils/format.js';
import { MessageResponse } from '../MessageResponse.js';
import { basename, sep } from 'path';
import { UserTextMessage } from './UserTextMessage.js';
import { DiagnosticsDisplay } from '../DiagnosticsDisplay.js';
import { getContentText } from 'src/utils/messages.js';
import type { Theme } from 'src/utils/theme.js';
import { UserImageMessage } from './UserImageMessage.js';
import { jsonParse } from '../../utils/slowOperations.js'
import { plural } from '../../utils/stringUtils.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
import {
tryRenderPlanApprovalMessage,
formatTeammateMessageContent,
} from './PlanApprovalMessage.js'
import { BLACK_CIRCLE } from '../../constants/figures.js'
import { TeammateMessageContent } from './UserTeammateMessage.js'
import { isShutdownApproved } from '../../utils/teammateMailbox.js'
import { CtrlOToExpand } from '../CtrlOToExpand.js'
import { jsonParse } from '../../utils/slowOperations.js';
import { plural } from '../../utils/stringUtils.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
import { tryRenderPlanApprovalMessage, formatTeammateMessageContent } from './PlanApprovalMessage.js';
import { BLACK_CIRCLE } from '../../constants/figures.js';
import { TeammateMessageContent } from './UserTeammateMessage.js';
import { isShutdownApproved } from '../../utils/teammateMailbox.js';
import { CtrlOToExpand } from '../CtrlOToExpand.js';
import { feature } from 'bun:bundle'
import { useSelectedMessageBg } from '../messageActions.js'
import { feature } from 'bun:bundle';
import { useSelectedMessageBg } from '../messageActions.js';
type Props = {
addMargin: boolean
attachment: Attachment
verbose: boolean
isTranscriptMode?: boolean
}
addMargin: boolean;
attachment: Attachment;
verbose: boolean;
isTranscriptMode?: boolean;
};
export function AttachmentMessage({
attachment,
addMargin,
verbose,
isTranscriptMode,
}: Props): React.ReactNode {
const bg = useSelectedMessageBg()
export function AttachmentMessage({ attachment, addMargin, verbose, isTranscriptMode }: Props): React.ReactNode {
const bg = useSelectedMessageBg();
// Hoisted to mount-time — per-message component, re-renders on every scroll.
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH')
?
useMemo(() => isEnvTruthy(process.env.IS_DEMO), [])
: false
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') ? useMemo(() => isEnvTruthy(process.env.IS_DEMO), []) : false;
// Handle teammate_mailbox BEFORE switch
if (isAgentSwarmsEnabled() && attachment.type === 'teammate_mailbox') {
// Filter out idle notifications BEFORE counting - they are hidden in the UI
// so showing them in the count would be confusing ("2 messages in mailbox:" with nothing shown)
const visibleMessages = attachment.messages.filter(msg => {
if (isShutdownApproved(msg.text)) {
return false
return false;
}
try {
const parsed = jsonParse(msg.text)
return (
parsed?.type !== 'idle_notification' &&
parsed?.type !== 'teammate_terminated'
)
const parsed = jsonParse(msg.text);
return parsed?.type !== 'idle_notification' && parsed?.type !== 'teammate_terminated';
} catch {
return true // Non-JSON messages are visible
return true; // Non-JSON messages are visible
}
})
});
if (visibleMessages.length === 0) {
return null
return null;
}
return (
<Box flexDirection="column">
{visibleMessages.map((msg, idx) => {
// Try to parse as JSON for task_assignment messages
let parsedMsg: {
type?: string
taskId?: string
subject?: string
assignedBy?: string
} | null = null
type?: string;
taskId?: string;
subject?: string;
assignedBy?: string;
} | null = null;
try {
parsedMsg = jsonParse(msg.text)
parsedMsg = jsonParse(msg.text);
} catch {
// Not JSON, treat as plain text
}
@@ -98,26 +84,20 @@ export function AttachmentMessage({
<Text> - {parsedMsg.subject}</Text>
<Text dimColor> (from {parsedMsg.assignedBy || msg.from})</Text>
</Box>
)
);
}
// Note: idle_notification messages already filtered out above
// Try to render as plan approval message (request or response)
const planApprovalElement = tryRenderPlanApprovalMessage(
msg.text,
msg.from,
)
const planApprovalElement = tryRenderPlanApprovalMessage(msg.text, msg.from);
if (planApprovalElement) {
return (
<React.Fragment key={idx}>{planApprovalElement}</React.Fragment>
)
return <React.Fragment key={idx}>{planApprovalElement}</React.Fragment>;
}
// Plain text message - sender header with chevron, truncated content
const inkColor = toInkColor(msg.color)
const formattedContent =
formatTeammateMessageContent(msg.text) ?? msg.text
const inkColor = toInkColor(msg.color);
const formattedContent = formatTeammateMessageContent(msg.text) ?? msg.text;
return (
<TeammateMessageContent
key={idx}
@@ -127,10 +107,10 @@ export function AttachmentMessage({
summary={msg.summary}
isTranscriptMode={isTranscriptMode}
/>
)
);
})}
</Box>
)
);
}
// skill_discovery rendered here (not in the switch) so the 'skill_discovery'
@@ -138,25 +118,22 @@ export function AttachmentMessage({
// be conditionally eliminated; an if-body can.
if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
if (attachment.type === 'skill_discovery') {
if (attachment.skills.length === 0) return null
if (attachment.skills.length === 0) return null;
// Ant users get shortIds inline so they can /skill-feedback while the
// turn is still fresh. External users (when this un-gates) just see
// names — shortId is undefined outside ant builds anyway.
const names = attachment.skills
.map(s => (s.shortId ? `${s.name} [${s.shortId}]` : s.name))
.join(', ')
const firstId = attachment.skills[0]?.shortId
const names = attachment.skills.map(s => (s.shortId ? `${s.name} [${s.shortId}]` : s.name)).join(', ');
const firstId = attachment.skills[0]?.shortId;
const hint =
process.env.USER_TYPE === 'ant' && !isDemoEnv && firstId
? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]`
: ''
: '';
return (
<Line>
<Text bold>{attachment.skills.length}</Text> relevant{' '}
{plural(attachment.skills.length, 'skill')}: {names}
<Text bold>{attachment.skills.length}</Text> relevant {plural(attachment.skills.length, 'skill')}: {names}
{hint && <Text dimColor>{hint}</Text>}
</Line>
)
);
}
}
@@ -167,23 +144,22 @@ export function AttachmentMessage({
<Line>
Listed directory <Text bold>{attachment.displayPath + sep}</Text>
</Line>
)
);
case 'file':
case 'already_read_file':
if (attachment.content.type === 'notebook') {
return (
<Line>
Read <Text bold>{attachment.displayPath}</Text> (
{attachment.content.file.cells.length} cells)
Read <Text bold>{attachment.displayPath}</Text> ({attachment.content.file.cells.length} cells)
</Line>
)
);
}
if (attachment.content.type === 'file_unchanged') {
return (
<Line>
Read <Text bold>{attachment.displayPath}</Text> (unchanged)
</Line>
)
);
}
return (
<Line>
@@ -193,46 +169,39 @@ export function AttachmentMessage({
: formatFileSize(attachment.content.file.originalSize)}
)
</Line>
)
);
case 'compact_file_reference':
return (
<Line>
Referenced file <Text bold>{attachment.displayPath}</Text>
</Line>
)
);
case 'pdf_reference':
return (
<Line>
Referenced PDF <Text bold>{attachment.displayPath}</Text> (
{attachment.pageCount} pages)
Referenced PDF <Text bold>{attachment.displayPath}</Text> ({attachment.pageCount} pages)
</Line>
)
);
case 'selected_lines_in_ide':
return (
<Line>
Selected{' '}
<Text bold>{attachment.lineEnd - attachment.lineStart + 1}</Text>{' '}
lines from <Text bold>{attachment.displayPath}</Text> in{' '}
{attachment.ideName}
Selected <Text bold>{attachment.lineEnd - attachment.lineStart + 1}</Text> lines from{' '}
<Text bold>{attachment.displayPath}</Text> in {attachment.ideName}
</Line>
)
);
case 'nested_memory':
return (
<Line>
Loaded <Text bold>{attachment.displayPath}</Text>
</Line>
)
);
case 'relevant_memories':
// Usually absorbed into a CollapsedReadSearchGroup (collapseReadSearch.ts)
// so this only renders when the preceding tool was non-collapsible (Edit,
// Write) and no group was open. Match CollapsedReadSearchContent's style:
// 2-space gutter, dim text, count only — filenames/content in ctrl+o.
return (
<Box
flexDirection="column"
marginTop={addMargin ? 1 : 0}
backgroundColor={bg}
>
<Box flexDirection="column" marginTop={addMargin ? 1 : 0} backgroundColor={bg}>
<Box flexDirection="row">
<Box minWidth={2} />
<Text dimColor>
@@ -251,9 +220,7 @@ export function AttachmentMessage({
<Box key={m.path} flexDirection="column">
<MessageResponse>
<Text dimColor>
<FilePathLink filePath={m.path}>
{basename(m.path)}
</FilePathLink>
<FilePathLink filePath={m.path}>{basename(m.path)}</FilePathLink>
</Text>
</MessageResponse>
{isTranscriptMode && (
@@ -266,9 +233,9 @@ export function AttachmentMessage({
</Box>
))}
</Box>
)
);
case 'dynamic_skill': {
const skillCount = attachment.skillNames.length
const skillCount = attachment.skillNames.length;
return (
<Line>
Loaded{' '}
@@ -277,37 +244,32 @@ export function AttachmentMessage({
</Text>{' '}
from <Text bold>{attachment.displayPath}</Text>
</Line>
)
);
}
case 'skill_listing': {
if (attachment.isInitial) {
return null
return null;
}
return (
<Line>
<Text bold>{attachment.skillCount}</Text>{' '}
{plural(attachment.skillCount, 'skill')} available
<Text bold>{attachment.skillCount}</Text> {plural(attachment.skillCount, 'skill')} available
</Line>
)
);
}
case 'agent_listing_delta': {
if (attachment.isInitial || attachment.addedTypes.length === 0) {
return null
return null;
}
const count = attachment.addedTypes.length
const count = attachment.addedTypes.length;
return (
<Line>
<Text bold>{count}</Text> agent {plural(count, 'type')} available
</Line>
)
);
}
case 'queued_command': {
const text =
typeof attachment.prompt === 'string'
? attachment.prompt
: getContentText(attachment.prompt) || ''
const hasImages =
attachment.imagePasteIds && attachment.imagePasteIds.length > 0
const text = typeof attachment.prompt === 'string' ? attachment.prompt : getContentText(attachment.prompt) || '';
const hasImages = attachment.imagePasteIds && attachment.imagePasteIds.length > 0;
return (
<Box flexDirection="column">
<UserTextMessage
@@ -316,141 +278,113 @@ export function AttachmentMessage({
verbose={verbose}
isTranscriptMode={isTranscriptMode}
/>
{hasImages &&
attachment.imagePasteIds?.map(id => (
<UserImageMessage key={id} imageId={id} />
))}
{hasImages && attachment.imagePasteIds?.map(id => <UserImageMessage key={id} imageId={id} />)}
</Box>
)
);
}
case 'plan_file_reference':
return (
<Line>
Plan file referenced ({getDisplayPath(attachment.planFilePath)})
</Line>
)
return <Line>Plan file referenced ({getDisplayPath(attachment.planFilePath)})</Line>;
case 'invoked_skills': {
if (attachment.skills.length === 0) {
return null
return null;
}
const skillNames = attachment.skills.map(s => s.name).join(', ')
return <Line>Skills restored ({skillNames})</Line>
const skillNames = attachment.skills.map(s => s.name).join(', ');
return <Line>Skills restored ({skillNames})</Line>;
}
case 'diagnostics':
return <DiagnosticsDisplay attachment={attachment} verbose={verbose} />
return <DiagnosticsDisplay attachment={attachment} verbose={verbose} />;
case 'mcp_resource':
return (
<Line>
Read MCP resource <Text bold>{attachment.name}</Text> from{' '}
{attachment.server}
Read MCP resource <Text bold>{attachment.name}</Text> from {attachment.server}
</Line>
)
);
case 'command_permissions':
// The skill success message is rendered by SkillTool's renderToolResultMessage,
// so we don't render anything here to avoid duplicate messages.
return null
return null;
case 'async_hook_response': {
// SessionStart hook completions are only shown in verbose mode
if (attachment.hookEvent === 'SessionStart' && !verbose) {
return null
return null;
}
// Generally hide async hook completion messages unless in verbose mode
if (!verbose && !isTranscriptMode) {
return null
return null;
}
return (
<Line>
Async hook <Text bold>{attachment.hookEvent}</Text> completed
</Line>
)
);
}
case 'hook_blocking_error': {
// Stop hooks are rendered as a summary in SystemStopHookSummaryMessage
if (
attachment.hookEvent === 'Stop' ||
attachment.hookEvent === 'SubagentStop'
) {
return null
if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') {
return null;
}
// Show stderr to the user so they can understand why the hook blocked
const stderr = attachment.blockingError.blockingError.trim()
const stderr = attachment.blockingError.blockingError.trim();
return (
<>
<Line color="error">
{attachment.hookName} hook returned blocking error
</Line>
<Line color="error">{attachment.hookName} hook returned blocking error</Line>
{stderr ? <Line color="error">{stderr}</Line> : null}
</>
)
);
}
case 'hook_non_blocking_error': {
// Stop hooks are rendered as a summary in SystemStopHookSummaryMessage
if (
attachment.hookEvent === 'Stop' ||
attachment.hookEvent === 'SubagentStop'
) {
return null
if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') {
return null;
}
// Full hook output is logged to debug log via hookEvents.ts
return <Line color="error">{attachment.hookName} hook error</Line>
return <Line color="error">{attachment.hookName} hook error</Line>;
}
case 'hook_error_during_execution':
// Stop hooks are rendered as a summary in SystemStopHookSummaryMessage
if (
attachment.hookEvent === 'Stop' ||
attachment.hookEvent === 'SubagentStop'
) {
return null
if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') {
return null;
}
// Full hook output is logged to debug log via hookEvents.ts
return <Line>{attachment.hookName} hook warning</Line>
return <Line>{attachment.hookName} hook warning</Line>;
case 'hook_success':
// Full hook output is logged to debug log via hookEvents.ts
return null
return null;
case 'hook_stopped_continuation':
// Stop hooks are rendered as a summary in SystemStopHookSummaryMessage
if (
attachment.hookEvent === 'Stop' ||
attachment.hookEvent === 'SubagentStop'
) {
return null
if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') {
return null;
}
return (
<Line color="warning">
{attachment.hookName} hook stopped continuation: {attachment.message}
</Line>
)
);
case 'hook_system_message':
return (
<Line>
{attachment.hookName} says: {attachment.content}
</Line>
)
);
case 'hook_permission_decision': {
const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied'
const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied';
return (
<Line>
{action} by <Text bold>{attachment.hookEvent}</Text> hook
</Line>
)
);
}
case 'task_status':
return <TaskStatusMessage attachment={attachment} />
return <TaskStatusMessage attachment={attachment} />;
case 'teammate_shutdown_batch':
return (
<Box
flexDirection="row"
width="100%"
marginTop={1}
backgroundColor={bg}
>
<Box flexDirection="row" width="100%" marginTop={1} backgroundColor={bg}>
<Text dimColor>{BLACK_CIRCLE} </Text>
<Text dimColor>
{attachment.count} {plural(attachment.count, 'teammate')} shut down
gracefully
{attachment.count} {plural(attachment.count, 'teammate')} shut down gracefully
</Text>
</Box>
)
);
default:
// Exhaustiveness: every type reaching here must be in NULL_RENDERING_TYPES.
// If TS errors, a new Attachment type was added without a case above AND
@@ -461,44 +395,32 @@ export function AttachmentMessage({
// skill_discovery and teammate_mailbox are handled BEFORE the switch in
// runtime-gated blocks (feature() / isAgentSwarmsEnabled()) that TS can't
// narrow through — excluded here via type union (compile-time only, no emit).
attachment.type satisfies
| NullRenderingAttachmentType
| 'skill_discovery'
| 'teammate_mailbox'
| 'bagel_console'
return null
attachment.type satisfies NullRenderingAttachmentType | 'skill_discovery' | 'teammate_mailbox' | 'bagel_console';
return null;
}
}
type TaskStatusAttachment = Extract<Attachment, { type: 'task_status' }>
type TaskStatusAttachment = Extract<Attachment, { type: 'task_status' }>;
function TaskStatusMessage({
attachment,
}: {
attachment: TaskStatusAttachment
}): React.ReactNode {
function TaskStatusMessage({ attachment }: { attachment: TaskStatusAttachment }): React.ReactNode {
// For ants, killed task status is shown in the CoordinatorTaskPanel.
// Don't render it again in the chat.
if (process.env.USER_TYPE === 'ant' && attachment.status === 'killed') {
return null
return null;
}
// Only access teammate-specific code when swarms are enabled.
// TeammateTaskStatus subscribes to AppState; by gating the mount we
// avoid adding a store listener for every non-teammate attachment.
if (isAgentSwarmsEnabled() && attachment.taskType === 'in_process_teammate') {
return <TeammateTaskStatus attachment={attachment} />
return <TeammateTaskStatus attachment={attachment} />;
}
return <GenericTaskStatus attachment={attachment} />
return <GenericTaskStatus attachment={attachment} />;
}
function GenericTaskStatus({
attachment,
}: {
attachment: TaskStatusAttachment
}): React.ReactNode {
const bg = useSelectedMessageBg()
function GenericTaskStatus({ attachment }: { attachment: TaskStatusAttachment }): React.ReactNode {
const bg = useSelectedMessageBg();
const statusText =
attachment.status === 'completed'
? 'completed in background'
@@ -506,7 +428,7 @@ function GenericTaskStatus({
? 'stopped'
: attachment.status === 'running'
? 'still running in background'
: attachment.status
: attachment.status;
return (
<Box flexDirection="row" width="100%" marginTop={1} backgroundColor={bg}>
<Text dimColor>{BLACK_CIRCLE} </Text>
@@ -514,26 +436,19 @@ function GenericTaskStatus({
Task &quot;<Text bold>{attachment.description}</Text>&quot; {statusText}
</Text>
</Box>
)
);
}
function TeammateTaskStatus({
attachment,
}: {
attachment: TaskStatusAttachment
}): React.ReactNode {
const bg = useSelectedMessageBg()
function TeammateTaskStatus({ attachment }: { attachment: TaskStatusAttachment }): React.ReactNode {
const bg = useSelectedMessageBg();
// Narrow selector: only re-render when this specific task changes.
const task = useAppState(s => s.tasks[attachment.taskId])
const task = useAppState(s => s.tasks[attachment.taskId]);
if (task?.type !== 'in_process_teammate') {
// Fall through to generic rendering (task not yet in store, or wrong type)
return <GenericTaskStatus attachment={attachment} />
return <GenericTaskStatus attachment={attachment} />;
}
const agentColor = toInkColor(task.identity.color)
const statusText =
attachment.status === 'completed'
? 'shut down gracefully'
: attachment.status
const agentColor = toInkColor(task.identity.color);
const statusText = attachment.status === 'completed' ? 'shut down gracefully' : attachment.status;
return (
<Box flexDirection="row" width="100%" marginTop={1} backgroundColor={bg}>
<Text dimColor>{BLACK_CIRCLE} </Text>
@@ -545,7 +460,7 @@ function TeammateTaskStatus({
{statusText}
</Text>
</Box>
)
);
}
// We allow setting dimColor to false here to help work around the dim-bold bug.
// https://github.com/chalk/chalk/issues/290
@@ -554,11 +469,11 @@ function Line({
children,
color,
}: {
dimColor?: boolean
children: React.ReactNode
color?: keyof Theme
dimColor?: boolean;
children: React.ReactNode;
color?: keyof Theme;
}): React.ReactNode {
const bg = useSelectedMessageBg()
const bg = useSelectedMessageBg();
return (
<Box backgroundColor={bg}>
<MessageResponse>
@@ -567,5 +482,5 @@ function Line({
</Text>
</MessageResponse>
</Box>
)
);
}

View File

@@ -1,47 +1,44 @@
import { feature } from 'bun:bundle'
import { basename } from 'path'
import React, { useRef } from 'react'
import { useMinDisplayTime } from '../../hooks/useMinDisplayTime.js'
import { Ansi, Box, Text, useTheme } from '@anthropic/ink'
import { findToolByName, type Tools } from '../../Tool.js'
import { getReplPrimitiveTools } from '@claude-code-best/builtin-tools/tools/REPLTool/primitiveTools.js'
import type {
CollapsedReadSearchGroup,
NormalizedAssistantMessage,
} from '../../types/message.js'
import { uniq } from '../../utils/array.js'
import { getToolUseIdsFromCollapsedGroup } from '../../utils/collapseReadSearch.js'
import { getDisplayPath } from '../../utils/file.js'
import { formatDuration, formatSecondsShort } from '../../utils/format.js'
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
import type { buildMessageLookups } from '../../utils/messages.js'
import type { ThemeName } from '../../utils/theme.js'
import { CtrlOToExpand } from '../CtrlOToExpand.js'
import { useSelectedMessageBg } from '../messageActions.js'
import { PrBadge } from '../PrBadge.js'
import { ToolUseLoader } from '../ToolUseLoader.js'
import { feature } from 'bun:bundle';
import { basename } from 'path';
import React, { useRef } from 'react';
import { useMinDisplayTime } from '../../hooks/useMinDisplayTime.js';
import { Ansi, Box, Text, useTheme } from '@anthropic/ink';
import { findToolByName, type Tools } from '../../Tool.js';
import { getReplPrimitiveTools } from '@claude-code-best/builtin-tools/tools/REPLTool/primitiveTools.js';
import type { CollapsedReadSearchGroup, NormalizedAssistantMessage } from '../../types/message.js';
import { uniq } from '../../utils/array.js';
import { getToolUseIdsFromCollapsedGroup } from '../../utils/collapseReadSearch.js';
import { getDisplayPath } from '../../utils/file.js';
import { formatDuration, formatSecondsShort } from '../../utils/format.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import type { buildMessageLookups } from '../../utils/messages.js';
import type { ThemeName } from '../../utils/theme.js';
import { CtrlOToExpand } from '../CtrlOToExpand.js';
import { useSelectedMessageBg } from '../messageActions.js';
import { PrBadge } from '../PrBadge.js';
import { ToolUseLoader } from '../ToolUseLoader.js';
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemCollapsed = feature('TEAMMEM')
? (require('./teamMemCollapsed.js') as typeof import('./teamMemCollapsed.js'))
: null
: null;
/* eslint-enable @typescript-eslint/no-require-imports */
// Hold each ⤿ hint for a minimum duration so fast-completing tool calls
// (bash commands, file reads, search patterns) are actually readable instead
// of flickering past in a single frame.
const MIN_HINT_DISPLAY_MS = 700
const MIN_HINT_DISPLAY_MS = 700;
type Props = {
message: CollapsedReadSearchGroup
inProgressToolUseIDs: Set<string>
shouldAnimate: boolean
verbose: boolean
tools: Tools
lookups: ReturnType<typeof buildMessageLookups>
message: CollapsedReadSearchGroup;
inProgressToolUseIDs: Set<string>;
shouldAnimate: boolean;
verbose: boolean;
tools: Tools;
lookups: ReturnType<typeof buildMessageLookups>;
/** True if this is the currently active collapsed group (last one, still loading) */
isActiveGroup?: boolean
}
isActiveGroup?: boolean;
};
/** Render a single tool use in verbose mode */
function VerboseToolUse({
@@ -52,52 +49,38 @@ function VerboseToolUse({
shouldAnimate,
theme,
}: {
content: { type: 'tool_use'; id: string; name: string; input: unknown }
tools: Tools
lookups: ReturnType<typeof buildMessageLookups>
inProgressToolUseIDs: Set<string>
shouldAnimate: boolean
theme: ThemeName
content: { type: 'tool_use'; id: string; name: string; input: unknown };
tools: Tools;
lookups: ReturnType<typeof buildMessageLookups>;
inProgressToolUseIDs: Set<string>;
shouldAnimate: boolean;
theme: ThemeName;
}): React.ReactNode {
const bg = useSelectedMessageBg()
const bg = useSelectedMessageBg();
// Same REPL-primitive fallback as getToolSearchOrReadInfo — REPL mode strips
// these from the execution tools list, but virtual messages still need them
// to render in verbose mode.
const tool =
findToolByName(tools, content.name) ??
findToolByName(getReplPrimitiveTools(), content.name)
if (!tool) return null
const tool = findToolByName(tools, content.name) ?? findToolByName(getReplPrimitiveTools(), content.name);
if (!tool) return null;
const isResolved = lookups.resolvedToolUseIDs.has(content.id)
const isError = lookups.erroredToolUseIDs.has(content.id)
const isInProgress = inProgressToolUseIDs.has(content.id)
const isResolved = lookups.resolvedToolUseIDs.has(content.id);
const isError = lookups.erroredToolUseIDs.has(content.id);
const isInProgress = inProgressToolUseIDs.has(content.id);
const resultMsg = lookups.toolResultByToolUseID.get(content.id)
const rawToolResult =
resultMsg?.type === 'user' ? resultMsg.toolUseResult : undefined
const parsedOutput = tool.outputSchema?.safeParse(rawToolResult)
const toolResult = parsedOutput?.success ? parsedOutput.data : undefined
const resultMsg = lookups.toolResultByToolUseID.get(content.id);
const rawToolResult = resultMsg?.type === 'user' ? resultMsg.toolUseResult : undefined;
const parsedOutput = tool.outputSchema?.safeParse(rawToolResult);
const toolResult = parsedOutput?.success ? parsedOutput.data : undefined;
const parsedInput = tool.inputSchema.safeParse(content.input)
const input = parsedInput.success ? parsedInput.data : undefined
const userFacingName = tool.userFacingName(input)
const toolUseMessage = input
? tool.renderToolUseMessage(input, { theme, verbose: true })
: null
const parsedInput = tool.inputSchema.safeParse(content.input);
const input = parsedInput.success ? parsedInput.data : undefined;
const userFacingName = tool.userFacingName(input);
const toolUseMessage = input ? tool.renderToolUseMessage(input, { theme, verbose: true }) : null;
return (
<Box
key={content.id}
flexDirection="column"
marginTop={1}
backgroundColor={bg}
>
<Box key={content.id} flexDirection="column" marginTop={1} backgroundColor={bg}>
<Box flexDirection="row">
<ToolUseLoader
shouldAnimate={shouldAnimate && isInProgress}
isUnresolved={!isResolved}
isError={isError}
/>
<ToolUseLoader shouldAnimate={shouldAnimate && isInProgress} isUnresolved={!isResolved} isError={isError} />
<Text>
<Text bold>{userFacingName}</Text>
{toolUseMessage && <Text>({toolUseMessage})</Text>}
@@ -114,7 +97,7 @@ function VerboseToolUse({
</Box>
)}
</Box>
)
);
}
export function CollapsedReadSearchContent({
@@ -126,7 +109,7 @@ export function CollapsedReadSearchContent({
lookups,
isActiveGroup,
}: Props): React.ReactNode {
const bg = useSelectedMessageBg()
const bg = useSelectedMessageBg();
const {
searchCount: rawSearchCount,
readCount: rawReadCount,
@@ -136,49 +119,35 @@ export function CollapsedReadSearchContent({
memoryReadCount,
memoryWriteCount,
messages: groupMessages,
} = message
const [theme] = useTheme()
const toolUseIds = getToolUseIdsFromCollapsedGroup(message)
const anyError = toolUseIds.some(id => lookups.erroredToolUseIDs.has(id))
const hasMemoryOps =
memorySearchCount > 0 || memoryReadCount > 0 || memoryWriteCount > 0
const hasTeamMemoryOps = feature('TEAMMEM')
? teamMemCollapsed!.checkHasTeamMemOps(message)
: false
} = message;
const [theme] = useTheme();
const toolUseIds = getToolUseIdsFromCollapsedGroup(message);
const anyError = toolUseIds.some(id => lookups.erroredToolUseIDs.has(id));
const hasMemoryOps = memorySearchCount > 0 || memoryReadCount > 0 || memoryWriteCount > 0;
const hasTeamMemoryOps = feature('TEAMMEM') ? teamMemCollapsed!.checkHasTeamMemOps(message) : false;
// Track the max seen counts so they only ever increase. The debounce timer
// causes extra re-renders at arbitrary times; during a brief "invisible window"
// in the streaming executor the group count can dip, which causes jitter.
const maxReadCountRef = useRef(0)
const maxSearchCountRef = useRef(0)
const maxListCountRef = useRef(0)
const maxMcpCountRef = useRef(0)
const maxBashCountRef = useRef(0)
maxReadCountRef.current = Math.max(maxReadCountRef.current, rawReadCount)
maxSearchCountRef.current = Math.max(
maxSearchCountRef.current,
rawSearchCount,
)
maxListCountRef.current = Math.max(maxListCountRef.current, rawListCount)
maxMcpCountRef.current = Math.max(
maxMcpCountRef.current,
message.mcpCallCount ?? 0,
)
maxBashCountRef.current = Math.max(
maxBashCountRef.current,
message.bashCount ?? 0,
)
const readCount = maxReadCountRef.current
const searchCount = maxSearchCountRef.current
const listCount = maxListCountRef.current
const mcpCallCount = maxMcpCountRef.current
const maxReadCountRef = useRef(0);
const maxSearchCountRef = useRef(0);
const maxListCountRef = useRef(0);
const maxMcpCountRef = useRef(0);
const maxBashCountRef = useRef(0);
maxReadCountRef.current = Math.max(maxReadCountRef.current, rawReadCount);
maxSearchCountRef.current = Math.max(maxSearchCountRef.current, rawSearchCount);
maxListCountRef.current = Math.max(maxListCountRef.current, rawListCount);
maxMcpCountRef.current = Math.max(maxMcpCountRef.current, message.mcpCallCount ?? 0);
maxBashCountRef.current = Math.max(maxBashCountRef.current, message.bashCount ?? 0);
const readCount = maxReadCountRef.current;
const searchCount = maxSearchCountRef.current;
const listCount = maxListCountRef.current;
const mcpCallCount = maxMcpCountRef.current;
// Subtract commands surfaced as "Committed …" / "Created PR …" so the
// same command isn't counted twice. gitOpBashCount is read live (no max-ref
// needed — it's 0 until results arrive, then only grows).
const gitOpBashCount = message.gitOpBashCount ?? 0
const bashCount = isFullscreenEnvEnabled()
? Math.max(0, maxBashCountRef.current - gitOpBashCount)
: 0
const gitOpBashCount = message.gitOpBashCount ?? 0;
const bashCount = isFullscreenEnvEnabled() ? Math.max(0, maxBashCountRef.current - gitOpBashCount) : 0;
const hasNonMemoryOps =
searchCount > 0 ||
@@ -187,18 +156,16 @@ export function CollapsedReadSearchContent({
replCount > 0 ||
mcpCallCount > 0 ||
bashCount > 0 ||
gitOpBashCount > 0
gitOpBashCount > 0;
const readPaths = message.readFilePaths
const searchArgs = message.searchArgs
let incomingHint = message.latestDisplayHint
const readPaths = message.readFilePaths;
const searchArgs = message.searchArgs;
let incomingHint = message.latestDisplayHint;
if (incomingHint === undefined) {
const lastSearchRaw = searchArgs?.at(-1)
const lastSearch =
lastSearchRaw !== undefined ? `"${lastSearchRaw}"` : undefined
const lastRead = readPaths?.at(-1)
incomingHint =
lastRead !== undefined ? getDisplayPath(lastRead) : lastSearch
const lastSearchRaw = searchArgs?.at(-1);
const lastSearch = lastSearchRaw !== undefined ? `"${lastSearchRaw}"` : undefined;
const lastRead = readPaths?.at(-1);
incomingHint = lastRead !== undefined ? getDisplayPath(lastRead) : lastSearch;
}
// Active REPL calls emit repl_tool_call progress with the current inner
@@ -206,41 +173,43 @@ export function CollapsedReadSearchContent({
// so this is the only source of a live hint during execution.
if (isActiveGroup) {
for (const id of toolUseIds) {
if (!inProgressToolUseIDs.has(id)) continue
const latest = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data as Record<string, unknown> | undefined
if (!inProgressToolUseIDs.has(id)) continue;
const latest = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data as Record<string, unknown> | undefined;
if (latest?.type === 'repl_tool_call' && latest.phase === 'start') {
const input = latest.toolInput as {
command?: string
pattern?: string
file_path?: string
}
command?: string;
pattern?: string;
file_path?: string;
};
incomingHint =
input.file_path ??
(input.pattern ? `"${input.pattern}"` : undefined) ??
input.command ??
(latest.toolName as string | undefined)
(latest.toolName as string | undefined);
}
}
}
const displayedHint = useMinDisplayTime(incomingHint, MIN_HINT_DISPLAY_MS)
const displayedHint = useMinDisplayTime(incomingHint, MIN_HINT_DISPLAY_MS);
// In verbose mode, render each tool use with its 1-line result summary
if (verbose) {
const toolUses: NormalizedAssistantMessage[] = []
const toolUses: NormalizedAssistantMessage[] = [];
for (const msg of groupMessages) {
if (msg.type === 'assistant') {
toolUses.push(msg)
toolUses.push(msg);
} else if (msg.type === 'grouped_tool_use') {
toolUses.push(...msg.messages)
toolUses.push(...msg.messages);
}
}
return (
<Box flexDirection="column">
{toolUses.map(msg => {
const content = (msg.message.content as Array<{ type: string; id?: string; name?: string; input?: unknown }>)[0]
if (content?.type !== 'tool_use') return null
const content = (
msg.message.content as Array<{ type: string; id?: string; name?: string; input?: unknown }>
)[0];
if (content?.type !== 'tool_use') return null;
return (
<VerboseToolUse
key={content.id!}
@@ -251,13 +220,12 @@ export function CollapsedReadSearchContent({
shouldAnimate={shouldAnimate}
theme={theme}
/>
)
);
})}
{message.hookInfos && message.hookInfos.length > 0 && (
<>
<Text dimColor>
{' ⎿ '}Ran {message.hookCount} PreToolUse{' '}
{message.hookCount === 1 ? 'hook' : 'hooks'} (
{' ⎿ '}Ran {message.hookCount} PreToolUse {message.hookCount === 1 ? 'hook' : 'hooks'} (
{formatSecondsShort(message.hookTotalMs ?? 0)})
</Text>
{message.hookInfos.map((info, idx) => (
@@ -281,7 +249,7 @@ export function CollapsedReadSearchContent({
</Box>
))}
</Box>
)
);
}
// Non-verbose mode: Show counts with blinking grey dot while active, green dot when finalized
@@ -290,81 +258,71 @@ export function CollapsedReadSearchContent({
// Defensive: If all counts are 0, don't render the collapsed group
// This shouldn't happen in normal operation, but handles edge cases
if (!hasMemoryOps && !hasTeamMemoryOps && !hasNonMemoryOps) {
return null
return null;
}
// Find the slowest in-progress shell command in this group. BashTool yields
// progress every second but the collapsed renderer never showed it — long
// commands (npm install, tests) looked frozen. Shown after 2s so fast
// commands stay clean; the ticking counter reassures that slow ones aren't stuck.
let shellProgressSuffix = ''
let shellProgressSuffix = '';
if (isFullscreenEnvEnabled() && isActiveGroup) {
let elapsed: number | undefined
let lines = 0
let elapsed: number | undefined;
let lines = 0;
for (const id of toolUseIds) {
if (!inProgressToolUseIDs.has(id)) continue
const data = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data as Record<string, unknown> | undefined
if (
data?.type !== 'bash_progress' &&
data?.type !== 'powershell_progress'
) {
continue
if (!inProgressToolUseIDs.has(id)) continue;
const data = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data as Record<string, unknown> | undefined;
if (data?.type !== 'bash_progress' && data?.type !== 'powershell_progress') {
continue;
}
const elapsedSec = data.elapsedTimeSeconds as number | undefined
const totalLines = data.totalLines as number | undefined
const elapsedSec = data.elapsedTimeSeconds as number | undefined;
const totalLines = data.totalLines as number | undefined;
if (elapsed === undefined || (elapsedSec ?? 0) > elapsed) {
elapsed = elapsedSec
lines = totalLines ?? 0
elapsed = elapsedSec;
lines = totalLines ?? 0;
}
}
if (elapsed !== undefined && elapsed >= 2) {
const time = formatDuration(elapsed * 1000)
shellProgressSuffix =
lines > 0
? ` (${time} · ${lines} ${lines === 1 ? 'line' : 'lines'})`
: ` (${time})`
const time = formatDuration(elapsed * 1000);
shellProgressSuffix = lines > 0 ? ` (${time} · ${lines} ${lines === 1 ? 'line' : 'lines'})` : ` (${time})`;
}
}
// Build non-memory parts first (search, read, repl, mcp, bash) — these render
// before memory so the line reads "Ran 3 bash commands, recalled 1 memory".
const nonMemParts: React.ReactNode[] = []
const nonMemParts: React.ReactNode[] = [];
// Git operations lead the line — they're the load-bearing outcome.
function pushPart(key: string, verb: string, body: React.ReactNode): void {
const isFirst = nonMemParts.length === 0
if (!isFirst) nonMemParts.push(<Text key={`comma-${key}`}>, </Text>)
const isFirst = nonMemParts.length === 0;
if (!isFirst) nonMemParts.push(<Text key={`comma-${key}`}>, </Text>);
nonMemParts.push(
<Text key={key}>
{isFirst ? verb[0]!.toUpperCase() + verb.slice(1) : verb} {body}
</Text>,
)
);
}
if (isFullscreenEnvEnabled() && message.commits?.length) {
const byKind = {
committed: 'committed',
amended: 'amended commit',
'cherry-picked': 'cherry-picked',
}
};
for (const kind of ['committed', 'amended', 'cherry-picked'] as const) {
const shas = message.commits.filter(c => c.kind === kind).map(c => c.sha)
const shas = message.commits.filter(c => c.kind === kind).map(c => c.sha);
if (shas.length) {
pushPart(kind, byKind[kind], <Text bold>{shas.join(', ')}</Text>)
pushPart(kind, byKind[kind], <Text bold>{shas.join(', ')}</Text>);
}
}
}
if (isFullscreenEnvEnabled() && message.pushes?.length) {
const branches = uniq(message.pushes.map(p => p.branch))
pushPart('push', 'pushed to', <Text bold>{branches.join(', ')}</Text>)
const branches = uniq(message.pushes.map(p => p.branch));
pushPart('push', 'pushed to', <Text bold>{branches.join(', ')}</Text>);
}
if (isFullscreenEnvEnabled() && message.branches?.length) {
const byAction = { merged: 'merged', rebased: 'rebased onto' }
const byAction = { merged: 'merged', rebased: 'rebased onto' };
for (const b of message.branches) {
pushPart(
`br-${b.action}-${b.ref}`,
byAction[b.action],
<Text bold>{b.ref}</Text>,
)
pushPart(`br-${b.action}-${b.ref}`, byAction[b.action], <Text bold>{b.ref}</Text>);
}
}
if (isFullscreenEnvEnabled() && message.prs?.length) {
@@ -375,108 +333,79 @@ export function CollapsedReadSearchContent({
commented: 'commented on',
closed: 'closed',
ready: 'marked ready',
}
};
for (const pr of message.prs) {
pushPart(
`pr-${pr.action}-${pr.number}`,
verbs[pr.action],
pr.url ? (
<PrBadge number={pr.number} url={pr.url} bold />
) : (
<Text bold>PR #{pr.number}</Text>
),
)
pr.url ? <PrBadge number={pr.number} url={pr.url} bold /> : <Text bold>PR #{pr.number}</Text>,
);
}
}
if (searchCount > 0) {
const isFirst = nonMemParts.length === 0
const isFirst = nonMemParts.length === 0;
const searchVerb = isActiveGroup
? isFirst
? 'Searching for'
: 'searching for'
: isFirst
? 'Searched for'
: 'searched for'
: 'searched for';
if (!isFirst) {
nonMemParts.push(<Text key="comma-s">, </Text>)
nonMemParts.push(<Text key="comma-s">, </Text>);
}
nonMemParts.push(
<Text key="search">
{searchVerb} <Text bold>{searchCount}</Text>{' '}
{searchCount === 1 ? 'pattern' : 'patterns'}
{searchVerb} <Text bold>{searchCount}</Text> {searchCount === 1 ? 'pattern' : 'patterns'}
</Text>,
)
);
}
if (readCount > 0) {
const isFirst = nonMemParts.length === 0
const readVerb = isActiveGroup
? isFirst
? 'Reading'
: 'reading'
: isFirst
? 'Read'
: 'read'
const isFirst = nonMemParts.length === 0;
const readVerb = isActiveGroup ? (isFirst ? 'Reading' : 'reading') : isFirst ? 'Read' : 'read';
if (!isFirst) {
nonMemParts.push(<Text key="comma-r">, </Text>)
nonMemParts.push(<Text key="comma-r">, </Text>);
}
nonMemParts.push(
<Text key="read">
{readVerb} <Text bold>{readCount}</Text>{' '}
{readCount === 1 ? 'file' : 'files'}
{readVerb} <Text bold>{readCount}</Text> {readCount === 1 ? 'file' : 'files'}
</Text>,
)
);
}
if (listCount > 0) {
const isFirst = nonMemParts.length === 0
const listVerb = isActiveGroup
? isFirst
? 'Listing'
: 'listing'
: isFirst
? 'Listed'
: 'listed'
const isFirst = nonMemParts.length === 0;
const listVerb = isActiveGroup ? (isFirst ? 'Listing' : 'listing') : isFirst ? 'Listed' : 'listed';
if (!isFirst) {
nonMemParts.push(<Text key="comma-l">, </Text>)
nonMemParts.push(<Text key="comma-l">, </Text>);
}
nonMemParts.push(
<Text key="list">
{listVerb} <Text bold>{listCount}</Text>{' '}
{listCount === 1 ? 'directory' : 'directories'}
{listVerb} <Text bold>{listCount}</Text> {listCount === 1 ? 'directory' : 'directories'}
</Text>,
)
);
}
if (replCount > 0) {
const replVerb = isActiveGroup ? "REPL'ing" : "REPL'd"
const replVerb = isActiveGroup ? "REPL'ing" : "REPL'd";
if (nonMemParts.length > 0) {
nonMemParts.push(<Text key="comma-repl">, </Text>)
nonMemParts.push(<Text key="comma-repl">, </Text>);
}
nonMemParts.push(
<Text key="repl">
{replVerb} <Text bold>{replCount}</Text>{' '}
{replCount === 1 ? 'time' : 'times'}
{replVerb} <Text bold>{replCount}</Text> {replCount === 1 ? 'time' : 'times'}
</Text>,
)
);
}
if (mcpCallCount > 0) {
const serverLabel =
message.mcpServerNames
?.map(n => n.replace(/^claude\.ai /, ''))
.join(', ') || 'MCP'
const isFirst = nonMemParts.length === 0
const verb = isActiveGroup
? isFirst
? 'Querying'
: 'querying'
: isFirst
? 'Queried'
: 'queried'
const serverLabel = message.mcpServerNames?.map(n => n.replace(/^claude\.ai /, '')).join(', ') || 'MCP';
const isFirst = nonMemParts.length === 0;
const verb = isActiveGroup ? (isFirst ? 'Querying' : 'querying') : isFirst ? 'Queried' : 'queried';
if (!isFirst) {
nonMemParts.push(<Text key="comma-mcp">, </Text>)
nonMemParts.push(<Text key="comma-mcp">, </Text>);
}
nonMemParts.push(
<Text key="mcp">
@@ -488,96 +417,65 @@ export function CollapsedReadSearchContent({
</>
)}
</Text>,
)
);
}
if (isFullscreenEnvEnabled() && bashCount > 0) {
const isFirst = nonMemParts.length === 0
const verb = isActiveGroup
? isFirst
? 'Running'
: 'running'
: isFirst
? 'Ran'
: 'ran'
const isFirst = nonMemParts.length === 0;
const verb = isActiveGroup ? (isFirst ? 'Running' : 'running') : isFirst ? 'Ran' : 'ran';
if (!isFirst) {
nonMemParts.push(<Text key="comma-bash">, </Text>)
nonMemParts.push(<Text key="comma-bash">, </Text>);
}
nonMemParts.push(
<Text key="bash">
{verb} <Text bold>{bashCount}</Text> bash{' '}
{bashCount === 1 ? 'command' : 'commands'}
{verb} <Text bold>{bashCount}</Text> bash {bashCount === 1 ? 'command' : 'commands'}
</Text>,
)
);
}
// Build memory parts (auto-memory) — rendered after nonMemParts
const hasPrecedingNonMem = nonMemParts.length > 0
const memParts: React.ReactNode[] = []
const hasPrecedingNonMem = nonMemParts.length > 0;
const memParts: React.ReactNode[] = [];
if (memoryReadCount > 0) {
const isFirst = !hasPrecedingNonMem && memParts.length === 0
const verb = isActiveGroup
? isFirst
? 'Recalling'
: 'recalling'
: isFirst
? 'Recalled'
: 'recalled'
const isFirst = !hasPrecedingNonMem && memParts.length === 0;
const verb = isActiveGroup ? (isFirst ? 'Recalling' : 'recalling') : isFirst ? 'Recalled' : 'recalled';
if (!isFirst) {
memParts.push(<Text key="comma-mr">, </Text>)
memParts.push(<Text key="comma-mr">, </Text>);
}
memParts.push(
<Text key="mem-read">
{verb} <Text bold>{memoryReadCount}</Text>{' '}
{memoryReadCount === 1 ? 'memory' : 'memories'}
{verb} <Text bold>{memoryReadCount}</Text> {memoryReadCount === 1 ? 'memory' : 'memories'}
</Text>,
)
);
}
if (memorySearchCount > 0) {
const isFirst = !hasPrecedingNonMem && memParts.length === 0
const verb = isActiveGroup
? isFirst
? 'Searching'
: 'searching'
: isFirst
? 'Searched'
: 'searched'
const isFirst = !hasPrecedingNonMem && memParts.length === 0;
const verb = isActiveGroup ? (isFirst ? 'Searching' : 'searching') : isFirst ? 'Searched' : 'searched';
if (!isFirst) {
memParts.push(<Text key="comma-ms">, </Text>)
memParts.push(<Text key="comma-ms">, </Text>);
}
memParts.push(<Text key="mem-search">{`${verb} memories`}</Text>)
memParts.push(<Text key="mem-search">{`${verb} memories`}</Text>);
}
if (memoryWriteCount > 0) {
const isFirst = !hasPrecedingNonMem && memParts.length === 0
const verb = isActiveGroup
? isFirst
? 'Writing'
: 'writing'
: isFirst
? 'Wrote'
: 'wrote'
const isFirst = !hasPrecedingNonMem && memParts.length === 0;
const verb = isActiveGroup ? (isFirst ? 'Writing' : 'writing') : isFirst ? 'Wrote' : 'wrote';
if (!isFirst) {
memParts.push(<Text key="comma-mw">, </Text>)
memParts.push(<Text key="comma-mw">, </Text>);
}
memParts.push(
<Text key="mem-write">
{verb} <Text bold>{memoryWriteCount}</Text>{' '}
{memoryWriteCount === 1 ? 'memory' : 'memories'}
{verb} <Text bold>{memoryWriteCount}</Text> {memoryWriteCount === 1 ? 'memory' : 'memories'}
</Text>,
)
);
}
return (
<Box flexDirection="column" marginTop={1} backgroundColor={bg}>
<Box flexDirection="row">
{isActiveGroup ? (
<ToolUseLoader shouldAnimate isUnresolved isError={anyError} />
) : (
<Box minWidth={2} />
)}
{isActiveGroup ? <ToolUseLoader shouldAnimate isUnresolved isError={anyError} /> : <Box minWidth={2} />}
<Text dimColor={!isActiveGroup}>
{nonMemParts}
{memParts}
@@ -611,11 +509,10 @@ export function CollapsedReadSearchContent({
)}
{message.hookTotalMs !== undefined && message.hookTotalMs > 0 && (
<Text dimColor>
{' ⎿ '}Ran {message.hookCount} PreToolUse{' '}
{message.hookCount === 1 ? 'hook' : 'hooks'} (
{' ⎿ '}Ran {message.hookCount} PreToolUse {message.hookCount === 1 ? 'hook' : 'hooks'} (
{formatSecondsShort(message.hookTotalMs)})
</Text>
)}
</Box>
)
);
}

View File

@@ -1,19 +1,13 @@
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
export function CompactBoundaryMessage(): React.ReactNode {
const historyShortcut = useShortcutDisplay(
'app:toggleTranscript',
'Global',
'ctrl+o',
)
const historyShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o');
return (
<Box marginY={1}>
<Text dimColor>
Conversation compacted ({historyShortcut} for history)
</Text>
<Text dimColor> Conversation compacted ({historyShortcut} for history)</Text>
</Box>
)
);
}

View File

@@ -1,23 +1,16 @@
import type {
ToolResultBlockParam,
ToolUseBlockParam,
} from '@anthropic-ai/sdk/resources/messages/messages.mjs'
import * as React from 'react'
import {
filterToolProgressMessages,
findToolByName,
type Tools,
} from '../../Tool.js'
import type { GroupedToolUseMessage } from '../../types/message.js'
import type { buildMessageLookups } from '../../utils/messages.js'
import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs';
import * as React from 'react';
import { filterToolProgressMessages, findToolByName, type Tools } from '../../Tool.js';
import type { GroupedToolUseMessage } from '../../types/message.js';
import type { buildMessageLookups } from '../../utils/messages.js';
type Props = {
message: GroupedToolUseMessage
tools: Tools
lookups: ReturnType<typeof buildMessageLookups>
inProgressToolUseIDs: Set<string>
shouldAnimate: boolean
}
message: GroupedToolUseMessage;
tools: Tools;
lookups: ReturnType<typeof buildMessageLookups>;
inProgressToolUseIDs: Set<string>;
shouldAnimate: boolean;
};
export function GroupedToolUseContent({
message,
@@ -26,48 +19,43 @@ export function GroupedToolUseContent({
inProgressToolUseIDs,
shouldAnimate,
}: Props): React.ReactNode {
const tool = findToolByName(tools, message.toolName)
const tool = findToolByName(tools, message.toolName);
if (!tool?.renderGroupedToolUse) {
return null
return null;
}
// Build a map from tool_use_id to result data
const resultsByToolUseId = new Map<
string,
{ param: ToolResultBlockParam; output: unknown }
>()
const resultsByToolUseId = new Map<string, { param: ToolResultBlockParam; output: unknown }>();
for (const resultMsg of message.results) {
for (const _content of resultMsg.message?.content ?? []) {
const content = _content as unknown as Record<string, unknown>
const content = _content as unknown as Record<string, unknown>;
if (content.type === 'tool_result') {
resultsByToolUseId.set(content.tool_use_id as string, {
param: content as unknown as ToolResultBlockParam,
output: resultMsg.toolUseResult,
})
});
}
}
}
const toolUsesData = message.messages.map(msg => {
const _content = (msg.message?.content ?? [])[0] as unknown as Record<string, unknown>
const id = _content.id as string
const result = resultsByToolUseId.get(id)
const _content = (msg.message?.content ?? [])[0] as unknown as Record<string, unknown>;
const id = _content.id as string;
const result = resultsByToolUseId.get(id);
return {
param: _content as unknown as ToolUseBlockParam,
isResolved: lookups.resolvedToolUseIDs.has(id),
isError: lookups.erroredToolUseIDs.has(id),
isInProgress: inProgressToolUseIDs.has(id),
progressMessages: filterToolProgressMessages(
lookups.progressMessagesByToolUseID.get(id) ?? [],
),
progressMessages: filterToolProgressMessages(lookups.progressMessagesByToolUseID.get(id) ?? []),
result,
}
})
};
});
const anyInProgress = toolUsesData.some(d => d.isInProgress)
const anyInProgress = toolUsesData.some(d => d.isInProgress);
return tool.renderGroupedToolUse(toolUsesData, {
shouldAnimate: shouldAnimate && anyInProgress,
tools,
})
});
}

View File

@@ -1,35 +1,27 @@
import figures from 'figures'
import * as React from 'react'
import { useContext } from 'react'
import { useQueuedMessage } from '../../context/QueuedMessageContext.js'
import { Box, Text } from '@anthropic/ink'
import { formatBriefTimestamp } from '../../utils/formatBriefTimestamp.js'
import {
findThinkingTriggerPositions,
getRainbowColor,
isUltrathinkEnabled,
} from '../../utils/thinking.js'
import { MessageActionsSelectedContext } from '../messageActions.js'
import figures from 'figures';
import * as React from 'react';
import { useContext } from 'react';
import { useQueuedMessage } from '../../context/QueuedMessageContext.js';
import { Box, Text } from '@anthropic/ink';
import { formatBriefTimestamp } from '../../utils/formatBriefTimestamp.js';
import { findThinkingTriggerPositions, getRainbowColor, isUltrathinkEnabled } from '../../utils/thinking.js';
import { MessageActionsSelectedContext } from '../messageActions.js';
type Props = {
text: string
useBriefLayout?: boolean
timestamp?: string
}
text: string;
useBriefLayout?: boolean;
timestamp?: string;
};
export function HighlightedThinkingText({
text,
useBriefLayout,
timestamp,
}: Props): React.ReactNode {
export function HighlightedThinkingText({ text, useBriefLayout, timestamp }: Props): React.ReactNode {
// Brief/assistant mode: chat-style "You" label instead of the highlight.
// Parent drops its backgroundColor when this is true, so no grey shows
// through. No manual wrap needed — Ink wraps inside the parent Box.
const isQueued = useQueuedMessage()?.isQueued ?? false
const isSelected = useContext(MessageActionsSelectedContext)
const pointerColor = isSelected ? 'suggestion' : 'subtle'
const isQueued = useQueuedMessage()?.isQueued ?? false;
const isSelected = useContext(MessageActionsSelectedContext);
const pointerColor = isSelected ? 'suggestion' : 'subtle';
if (useBriefLayout) {
const ts = timestamp ? formatBriefTimestamp(timestamp) : ''
const ts = timestamp ? formatBriefTimestamp(timestamp) : '';
return (
<Box flexDirection="column" paddingLeft={2}>
<Box flexDirection="row">
@@ -38,12 +30,10 @@ export function HighlightedThinkingText({
</Box>
<Text color={isQueued ? 'subtle' : 'text'}>{text}</Text>
</Box>
)
);
}
const triggers = isUltrathinkEnabled()
? findThinkingTriggerPositions(text)
: []
const triggers = isUltrathinkEnabled() ? findThinkingTriggerPositions(text) : [];
if (triggers.length === 0) {
return (
@@ -51,35 +41,35 @@ export function HighlightedThinkingText({
<Text color={pointerColor}>{figures.pointer} </Text>
<Text color="text">{text}</Text>
</Text>
)
);
}
// Static rainbow (no shimmer — transcript messages don't animate)
const parts: React.ReactNode[] = []
let cursor = 0
const parts: React.ReactNode[] = [];
let cursor = 0;
for (const t of triggers) {
if (t.start > cursor) {
parts.push(
<Text key={`plain-${cursor}`} color="text">
{text.slice(cursor, t.start)}
</Text>,
)
);
}
for (let i = t.start; i < t.end; i++) {
parts.push(
<Text key={`rb-${i}`} color={getRainbowColor(i - t.start)}>
{text[i]}
</Text>,
)
);
}
cursor = t.end
cursor = t.end;
}
if (cursor < text.length) {
parts.push(
<Text key={`plain-${cursor}`} color="text">
{text.slice(cursor)}
</Text>,
)
);
}
return (
@@ -87,5 +77,5 @@ export function HighlightedThinkingText({
<Text color={pointerColor}>{figures.pointer} </Text>
{parts}
</Text>
)
);
}

View File

@@ -1,29 +1,22 @@
import * as React from 'react'
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
import type { buildMessageLookups } from 'src/utils/messages.js'
import { Box, Text } from '@anthropic/ink'
import { MessageResponse } from '../MessageResponse.js'
import * as React from 'react';
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js';
import type { buildMessageLookups } from 'src/utils/messages.js';
import { Box, Text } from '@anthropic/ink';
import { MessageResponse } from '../MessageResponse.js';
type Props = {
hookEvent: HookEvent
lookups: ReturnType<typeof buildMessageLookups>
toolUseID: string
verbose: boolean
isTranscriptMode?: boolean
}
hookEvent: HookEvent;
lookups: ReturnType<typeof buildMessageLookups>;
toolUseID: string;
verbose: boolean;
isTranscriptMode?: boolean;
};
export function HookProgressMessage({
hookEvent,
lookups,
toolUseID,
isTranscriptMode,
}: Props): React.ReactNode {
const inProgressHookCount =
lookups.inProgressHookCounts.get(toolUseID)?.get(hookEvent) ?? 0
const resolvedHookCount =
lookups.resolvedHookCounts.get(toolUseID)?.get(hookEvent) ?? 0
export function HookProgressMessage({ hookEvent, lookups, toolUseID, isTranscriptMode }: Props): React.ReactNode {
const inProgressHookCount = lookups.inProgressHookCounts.get(toolUseID)?.get(hookEvent) ?? 0;
const resolvedHookCount = lookups.resolvedHookCounts.get(toolUseID)?.get(hookEvent) ?? 0;
if (inProgressHookCount === 0) {
return null
return null;
}
if (hookEvent === 'PreToolUse' || hookEvent === 'PostToolUse') {
@@ -37,20 +30,18 @@ export function HookProgressMessage({
<Text dimColor bold>
{hookEvent}
</Text>
<Text dimColor>
{inProgressHookCount === 1 ? ' hook' : ' hooks'} ran
</Text>
<Text dimColor>{inProgressHookCount === 1 ? ' hook' : ' hooks'} ran</Text>
</Box>
</MessageResponse>
)
);
}
// Outside transcript mode, hide — completion info is shown via
// async_hook_response attachments instead.
return null
return null;
}
if (resolvedHookCount === inProgressHookCount) {
return null
return null;
}
return (
@@ -63,5 +54,5 @@ export function HookProgressMessage({
<Text dimColor>{inProgressHookCount === 1 ? ' hook…' : ' hooks…'}</Text>
</Box>
</MessageResponse>
)
);
}

View File

@@ -1,7 +1,7 @@
import * as React from 'react'
import { Markdown } from '../../components/Markdown.js'
import { Box, Text } from '@anthropic/ink'
import { jsonParse } from '../../utils/slowOperations.js'
import * as React from 'react';
import { Markdown } from '../../components/Markdown.js';
import { Box, Text } from '@anthropic/ink';
import { jsonParse } from '../../utils/slowOperations.js';
import {
type IdleNotificationMessage,
isIdleNotification,
@@ -9,29 +9,22 @@ import {
isPlanApprovalResponse,
type PlanApprovalRequestMessage,
type PlanApprovalResponseMessage,
} from '../../utils/teammateMailbox.js'
import { getShutdownMessageSummary } from './ShutdownMessage.js'
import { getTaskAssignmentSummary } from './TaskAssignmentMessage.js'
} from '../../utils/teammateMailbox.js';
import { getShutdownMessageSummary } from './ShutdownMessage.js';
import { getTaskAssignmentSummary } from './TaskAssignmentMessage.js';
type PlanApprovalRequestProps = {
request: PlanApprovalRequestMessage
}
request: PlanApprovalRequestMessage;
};
/**
* Renders a plan approval request with a planMode-colored border,
* showing the plan content and instructions for approving/rejecting.
*/
export function PlanApprovalRequestDisplay({
request,
}: PlanApprovalRequestProps): React.ReactNode {
export function PlanApprovalRequestDisplay({ request }: PlanApprovalRequestProps): React.ReactNode {
return (
<Box flexDirection="column" marginY={1}>
<Box
borderStyle="round"
borderColor="planMode"
flexDirection="column"
paddingX={1}
>
<Box borderStyle="round" borderColor="planMode" flexDirection="column" paddingX={1}>
<Box marginBottom={1}>
<Text color="planMode" bold>
Plan Approval Request from {request.from}
@@ -51,56 +44,38 @@ export function PlanApprovalRequestDisplay({
<Text dimColor>Plan file: {request.planFilePath}</Text>
</Box>
</Box>
)
);
}
type PlanApprovalResponseProps = {
response: PlanApprovalResponseMessage
senderName: string
}
response: PlanApprovalResponseMessage;
senderName: string;
};
/**
* Renders a plan approval response with a success (green) or error (red) border.
*/
export function PlanApprovalResponseDisplay({
response,
senderName,
}: PlanApprovalResponseProps): React.ReactNode {
export function PlanApprovalResponseDisplay({ response, senderName }: PlanApprovalResponseProps): React.ReactNode {
if (response.approved) {
return (
<Box flexDirection="column" marginY={1}>
<Box
borderStyle="round"
borderColor="success"
flexDirection="column"
paddingX={1}
paddingY={1}
>
<Box borderStyle="round" borderColor="success" flexDirection="column" paddingX={1} paddingY={1}>
<Box>
<Text color="success" bold>
Plan Approved by {senderName}
</Text>
</Box>
<Box marginTop={1}>
<Text>
You can now proceed with implementation. Your plan mode
restrictions have been lifted.
</Text>
<Text>You can now proceed with implementation. Your plan mode restrictions have been lifted.</Text>
</Box>
</Box>
</Box>
)
);
}
return (
<Box flexDirection="column" marginY={1}>
<Box
borderStyle="round"
borderColor="error"
flexDirection="column"
paddingX={1}
paddingY={1}
>
<Box borderStyle="round" borderColor="error" flexDirection="column" paddingX={1} paddingY={1}>
<Box>
<Text color="error" bold>
Plan Rejected by {senderName}
@@ -119,40 +94,29 @@ export function PlanApprovalResponseDisplay({
</Box>
)}
<Box marginTop={1}>
<Text dimColor>
Please revise your plan based on the feedback and call ExitPlanMode
again.
</Text>
<Text dimColor>Please revise your plan based on the feedback and call ExitPlanMode again.</Text>
</Box>
</Box>
</Box>
)
);
}
/**
* Try to parse and render a plan approval message from raw content.
* Returns the rendered component if it's a plan approval message, null otherwise.
*/
export function tryRenderPlanApprovalMessage(
content: string,
senderName: string,
): React.ReactNode | null {
const request = isPlanApprovalRequest(content)
export function tryRenderPlanApprovalMessage(content: string, senderName: string): React.ReactNode | null {
const request = isPlanApprovalRequest(content);
if (request) {
return <PlanApprovalRequestDisplay request={request} />
return <PlanApprovalRequestDisplay request={request} />;
}
const response = isPlanApprovalResponse(content)
const response = isPlanApprovalResponse(content);
if (response) {
return (
<PlanApprovalResponseDisplay
response={response}
senderName={senderName}
/>
)
return <PlanApprovalResponseDisplay response={response} senderName={senderName} />;
}
return null
return null;
}
/**
@@ -161,36 +125,36 @@ export function tryRenderPlanApprovalMessage(
* Returns null if the content is not a plan approval message.
*/
function getPlanApprovalSummary(content: string): string | null {
const request = isPlanApprovalRequest(content)
const request = isPlanApprovalRequest(content);
if (request) {
return `[Plan Approval Request from ${request.from}]`
return `[Plan Approval Request from ${request.from}]`;
}
const response = isPlanApprovalResponse(content)
const response = isPlanApprovalResponse(content);
if (response) {
if (response.approved) {
return '[Plan Approved] You can now proceed with implementation'
return '[Plan Approved] You can now proceed with implementation';
} else {
return `[Plan Rejected] ${response.feedback || 'Please revise your plan'}`
return `[Plan Rejected] ${response.feedback || 'Please revise your plan'}`;
}
}
return null
return null;
}
/**
* Get a brief summary text for an idle notification.
*/
function getIdleNotificationSummary(msg: IdleNotificationMessage): string {
const parts: string[] = ['Agent idle']
const parts: string[] = ['Agent idle'];
if (msg.completedTaskId) {
const status = msg.completedStatus || 'completed'
parts.push(`Task ${msg.completedTaskId} ${status}`)
const status = msg.completedStatus || 'completed';
parts.push(`Task ${msg.completedTaskId} ${status}`);
}
if (msg.summary) {
parts.push(`Last DM: ${msg.summary}`)
parts.push(`Last DM: ${msg.summary}`);
}
return parts.join(' · ')
return parts.join(' · ');
}
/**
@@ -199,35 +163,35 @@ function getIdleNotificationSummary(msg: IdleNotificationMessage): string {
* Otherwise returns the original content.
*/
export function formatTeammateMessageContent(content: string): string {
const planSummary = getPlanApprovalSummary(content)
const planSummary = getPlanApprovalSummary(content);
if (planSummary) {
return planSummary
return planSummary;
}
const shutdownSummary = getShutdownMessageSummary(content)
const shutdownSummary = getShutdownMessageSummary(content);
if (shutdownSummary) {
return shutdownSummary
return shutdownSummary;
}
const idleMsg = isIdleNotification(content)
const idleMsg = isIdleNotification(content);
if (idleMsg) {
return getIdleNotificationSummary(idleMsg)
return getIdleNotificationSummary(idleMsg);
}
const taskAssignmentSummary = getTaskAssignmentSummary(content)
const taskAssignmentSummary = getTaskAssignmentSummary(content);
if (taskAssignmentSummary) {
return taskAssignmentSummary
return taskAssignmentSummary;
}
// Check for teammate_terminated message
try {
const parsed = jsonParse(content) as { type?: string; message?: string }
const parsed = jsonParse(content) as { type?: string; message?: string };
if (parsed?.type === 'teammate_terminated' && parsed.message) {
return parsed.message
return parsed.message;
}
} catch {
// Not JSON
}
return content
return content;
}

View File

@@ -1,24 +1,20 @@
import React, { useEffect, useMemo, useState } from 'react'
import { extraUsage } from 'src/commands/extra-usage/index.js'
import { Box, Text } from '@anthropic/ink'
import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js'
import { shouldProcessMockLimits } from 'src/services/rateLimitMocking.js' // Used for /mock-limits command
import {
getRateLimitTier,
getSubscriptionType,
isClaudeAISubscriber,
} from 'src/utils/auth.js'
import { hasClaudeAiBillingAccess } from 'src/utils/billing.js'
import { MessageResponse } from '../MessageResponse.js'
import React, { useEffect, useMemo, useState } from 'react';
import { extraUsage } from 'src/commands/extra-usage/index.js';
import { Box, Text } from '@anthropic/ink';
import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js';
import { shouldProcessMockLimits } from 'src/services/rateLimitMocking.js'; // Used for /mock-limits command
import { getRateLimitTier, getSubscriptionType, isClaudeAISubscriber } from 'src/utils/auth.js';
import { hasClaudeAiBillingAccess } from 'src/utils/billing.js';
import { MessageResponse } from '../MessageResponse.js';
type UpsellParams = {
shouldShowUpsell: boolean
isMax20x: boolean
isExtraUsageCommandEnabled: boolean
shouldAutoOpenRateLimitOptionsMenu: boolean
isTeamOrEnterprise: boolean
hasBillingAccess: boolean
}
shouldShowUpsell: boolean;
isMax20x: boolean;
isExtraUsageCommandEnabled: boolean;
shouldAutoOpenRateLimitOptionsMenu: boolean;
isTeamOrEnterprise: boolean;
hasBillingAccess: boolean;
};
export function getUpsellMessage({
shouldShowUpsell,
@@ -28,79 +24,69 @@ export function getUpsellMessage({
isTeamOrEnterprise,
hasBillingAccess,
}: UpsellParams): string | null {
if (!shouldShowUpsell) return null
if (!shouldShowUpsell) return null;
if (isMax20x) {
if (isExtraUsageCommandEnabled) {
return '/extra-usage to finish what you\u2019re working on.'
return '/extra-usage to finish what you\u2019re working on.';
}
return '/login to switch to an API usage-billed account.'
return '/login to switch to an API usage-billed account.';
}
if (shouldAutoOpenRateLimitOptionsMenu) {
return 'Opening your options\u2026'
return 'Opening your options\u2026';
}
if (!isTeamOrEnterprise && !isExtraUsageCommandEnabled) {
return '/upgrade to increase your usage limit.'
return '/upgrade to increase your usage limit.';
}
if (isTeamOrEnterprise) {
if (!isExtraUsageCommandEnabled) return null
if (!isExtraUsageCommandEnabled) return null;
if (hasBillingAccess) {
return '/extra-usage to finish what you\u2019re working on.'
return '/extra-usage to finish what you\u2019re working on.';
}
return '/extra-usage to request more usage from your admin.'
return '/extra-usage to request more usage from your admin.';
}
return '/upgrade or /extra-usage to finish what you\u2019re working on.'
return '/upgrade or /extra-usage to finish what you\u2019re working on.';
}
type RateLimitMessageProps = {
text: string
onOpenRateLimitOptions?: () => void
}
text: string;
onOpenRateLimitOptions?: () => void;
};
export function RateLimitMessage({
text,
onOpenRateLimitOptions,
}: RateLimitMessageProps): React.ReactNode {
const subscriptionType = getSubscriptionType()
const rateLimitTier = getRateLimitTier()
const isTeamOrEnterprise =
subscriptionType === 'team' || subscriptionType === 'enterprise'
const isMax20x = rateLimitTier === 'default_claude_max_20x'
export function RateLimitMessage({ text, onOpenRateLimitOptions }: RateLimitMessageProps): React.ReactNode {
const subscriptionType = getSubscriptionType();
const rateLimitTier = getRateLimitTier();
const isTeamOrEnterprise = subscriptionType === 'team' || subscriptionType === 'enterprise';
const isMax20x = rateLimitTier === 'default_claude_max_20x';
// Always show upsell when using /mock-limits command, otherwise show for subscribers
const shouldShowUpsell = shouldProcessMockLimits() || isClaudeAISubscriber()
const shouldShowUpsell = shouldProcessMockLimits() || isClaudeAISubscriber();
const canSeeRateLimitOptionsUpsell = shouldShowUpsell && !isMax20x
const canSeeRateLimitOptionsUpsell = shouldShowUpsell && !isMax20x;
const [hasOpenedInteractiveMenu, setHasOpenedInteractiveMenu] =
useState(false)
const [hasOpenedInteractiveMenu, setHasOpenedInteractiveMenu] = useState(false);
// Check actual rate limit status - only auto-open if user is currently rate limited
// AND we've verified this with the API (resetsAt is only set after API response).
// This prevents false alerts when resuming sessions with old rate limit messages.
const claudeAiLimits = useClaudeAiLimits()
const claudeAiLimits = useClaudeAiLimits();
const isCurrentlyRateLimited =
claudeAiLimits.status === 'rejected' &&
claudeAiLimits.resetsAt !== undefined &&
!claudeAiLimits.isUsingOverage
claudeAiLimits.status === 'rejected' && claudeAiLimits.resetsAt !== undefined && !claudeAiLimits.isUsingOverage;
const shouldAutoOpenRateLimitOptionsMenu =
canSeeRateLimitOptionsUpsell &&
!hasOpenedInteractiveMenu &&
isCurrentlyRateLimited &&
onOpenRateLimitOptions
canSeeRateLimitOptionsUpsell && !hasOpenedInteractiveMenu && isCurrentlyRateLimited && onOpenRateLimitOptions;
useEffect(() => {
if (shouldAutoOpenRateLimitOptionsMenu) {
setHasOpenedInteractiveMenu(true)
onOpenRateLimitOptions()
setHasOpenedInteractiveMenu(true);
onOpenRateLimitOptions();
}
}, [shouldAutoOpenRateLimitOptionsMenu, onOpenRateLimitOptions])
}, [shouldAutoOpenRateLimitOptionsMenu, onOpenRateLimitOptions]);
const upsell = useMemo(() => {
const message = getUpsellMessage({
@@ -110,15 +96,10 @@ export function RateLimitMessage({
shouldAutoOpenRateLimitOptionsMenu: !!shouldAutoOpenRateLimitOptionsMenu,
isTeamOrEnterprise,
hasBillingAccess: hasClaudeAiBillingAccess(),
})
if (!message) return null
return <Text dimColor>{message}</Text>
}, [
shouldShowUpsell,
isMax20x,
isTeamOrEnterprise,
shouldAutoOpenRateLimitOptionsMenu,
])
});
if (!message) return null;
return <Text dimColor>{message}</Text>;
}, [shouldShowUpsell, isMax20x, isTeamOrEnterprise, shouldAutoOpenRateLimitOptionsMenu]);
return (
<MessageResponse>
@@ -127,5 +108,5 @@ export function RateLimitMessage({
{hasOpenedInteractiveMenu ? null : upsell}
</Box>
</MessageResponse>
)
);
}

View File

@@ -1,32 +1,24 @@
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import {
isShutdownApproved,
isShutdownRejected,
isShutdownRequest,
type ShutdownRejectedMessage,
type ShutdownRequestMessage,
} from '../../utils/teammateMailbox.js'
} from '../../utils/teammateMailbox.js';
type ShutdownRequestProps = {
request: ShutdownRequestMessage
}
request: ShutdownRequestMessage;
};
/**
* Renders a shutdown request with a warning-colored border.
*/
export function ShutdownRequestDisplay({
request,
}: ShutdownRequestProps): React.ReactNode {
export function ShutdownRequestDisplay({ request }: ShutdownRequestProps): React.ReactNode {
return (
<Box flexDirection="column" marginY={1}>
<Box
borderStyle="round"
borderColor="warning"
flexDirection="column"
paddingX={1}
paddingY={1}
>
<Box borderStyle="round" borderColor="warning" flexDirection="column" paddingX={1} paddingY={1}>
<Box marginBottom={1}>
<Text color="warning" bold>
Shutdown request from {request.from}
@@ -39,28 +31,20 @@ export function ShutdownRequestDisplay({
)}
</Box>
</Box>
)
);
}
type ShutdownRejectedProps = {
response: ShutdownRejectedMessage
}
response: ShutdownRejectedMessage;
};
/**
* Renders a shutdown rejected message with a subtle (grey) border.
*/
export function ShutdownRejectedDisplay({
response,
}: ShutdownRejectedProps): React.ReactNode {
export function ShutdownRejectedDisplay({ response }: ShutdownRejectedProps): React.ReactNode {
return (
<Box flexDirection="column" marginY={1}>
<Box
borderStyle="round"
borderColor="subtle"
flexDirection="column"
paddingX={1}
paddingY={1}
>
<Box borderStyle="round" borderColor="subtle" flexDirection="column" paddingX={1} paddingY={1}>
<Text color="subtle" bold>
Shutdown rejected by {response.from}
</Text>
@@ -75,39 +59,34 @@ export function ShutdownRejectedDisplay({
<Text>Reason: {response.reason}</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>
Teammate is continuing to work. You may request shutdown again
later.
</Text>
<Text dimColor>Teammate is continuing to work. You may request shutdown again later.</Text>
</Box>
</Box>
</Box>
)
);
}
/**
* Try to parse and render a shutdown message from raw content.
* Returns the rendered component if it's a shutdown message, null otherwise.
*/
export function tryRenderShutdownMessage(
content: string,
): React.ReactNode | null {
const request = isShutdownRequest(content)
export function tryRenderShutdownMessage(content: string): React.ReactNode | null {
const request = isShutdownRequest(content);
if (request) {
return <ShutdownRequestDisplay request={request} />
return <ShutdownRequestDisplay request={request} />;
}
// Shutdown approved is handled inline by the caller — skip it here
if (isShutdownApproved(content)) {
return null
return null;
}
const rejected = isShutdownRejected(content)
const rejected = isShutdownRejected(content);
if (rejected) {
return <ShutdownRejectedDisplay response={rejected} />
return <ShutdownRejectedDisplay response={rejected} />;
}
return null
return null;
}
/**
@@ -116,20 +95,20 @@ export function tryRenderShutdownMessage(
* Returns null if the content is not a shutdown message.
*/
export function getShutdownMessageSummary(content: string): string | null {
const request = isShutdownRequest(content)
const request = isShutdownRequest(content);
if (request) {
return `[Shutdown Request from ${request.from}]${request.reason ? ` ${request.reason}` : ''}`
return `[Shutdown Request from ${request.from}]${request.reason ? ` ${request.reason}` : ''}`;
}
const approved = isShutdownApproved(content)
const approved = isShutdownApproved(content);
if (approved) {
return `[Shutdown Approved] ${approved.from} is now exiting`
return `[Shutdown Approved] ${approved.from} is now exiting`;
}
const rejected = isShutdownRejected(content)
const rejected = isShutdownRejected(content);
if (rejected) {
return `[Shutdown Rejected] ${rejected.from}: ${rejected.reason}`
return `[Shutdown Rejected] ${rejected.from}: ${rejected.reason}`;
}
return null
return null;
}

View File

@@ -1,3 +1,4 @@
// Auto-generated stub — replace with real implementation
export {};
export const SnipBoundaryMessage: (props: Record<string, unknown>) => null = () => null;
export {}
export const SnipBoundaryMessage: (props: Record<string, unknown>) => null =
() => null

View File

@@ -1,68 +1,55 @@
import * as React from 'react'
import { useState } from 'react'
import { Box, Text } from '@anthropic/ink'
import { formatAPIError } from '@ant/model-provider'
import type { SystemAPIErrorMessage } from 'src/types/message.js'
import { useInterval } from 'usehooks-ts'
import { CtrlOToExpand } from '../CtrlOToExpand.js'
import { MessageResponse } from '../MessageResponse.js'
import * as React from 'react';
import { useState } from 'react';
import { Box, Text } from '@anthropic/ink';
import { formatAPIError } from '@ant/model-provider';
import type { SystemAPIErrorMessage } from 'src/types/message.js';
import { useInterval } from 'usehooks-ts';
import { CtrlOToExpand } from '../CtrlOToExpand.js';
import { MessageResponse } from '../MessageResponse.js';
const MAX_API_ERROR_CHARS = 1000
const MAX_API_ERROR_CHARS = 1000;
type Props = {
message: SystemAPIErrorMessage
verbose: boolean
}
message: SystemAPIErrorMessage;
verbose: boolean;
};
export function SystemAPIErrorMessage({
message: { retryAttempt, error, retryInMs, maxRetries },
verbose,
}: Props): React.ReactNode {
const _retryAttempt = retryAttempt as number
const _retryInMs = retryInMs as number
const _maxRetries = maxRetries as number
const _error = error as Parameters<typeof formatAPIError>[0]
const _retryAttempt = retryAttempt as number;
const _retryInMs = retryInMs as number;
const _maxRetries = maxRetries as number;
const _error = error as Parameters<typeof formatAPIError>[0];
// Hidden for early retries on external builds to avoid noise. Compute before
// useInterval so we never register a timer that just drives a null render.
const hidden = process.env.USER_TYPE === 'external' && _retryAttempt < 4
const hidden = process.env.USER_TYPE === 'external' && _retryAttempt < 4;
const [countdownMs, setCountdownMs] = useState(0)
const done = countdownMs >= _retryInMs
useInterval(
() => setCountdownMs(ms => ms + 1000),
hidden || done ? null : 1000,
)
const [countdownMs, setCountdownMs] = useState(0);
const done = countdownMs >= _retryInMs;
useInterval(() => setCountdownMs(ms => ms + 1000), hidden || done ? null : 1000);
if (hidden) {
return null
return null;
}
const retryInSecondsLive = Math.max(
0,
Math.round((_retryInMs - countdownMs) / 1000),
)
const retryInSecondsLive = Math.max(0, Math.round((_retryInMs - countdownMs) / 1000));
const formatted = formatAPIError(_error)
const truncated = !verbose && formatted.length > MAX_API_ERROR_CHARS
const formatted = formatAPIError(_error);
const truncated = !verbose && formatted.length > MAX_API_ERROR_CHARS;
return (
<MessageResponse>
<Box flexDirection="column">
<Text color="error">
{truncated
? formatted.slice(0, MAX_API_ERROR_CHARS) + '…'
: formatted}
</Text>
<Text color="error">{truncated ? formatted.slice(0, MAX_API_ERROR_CHARS) + '…' : formatted}</Text>
{truncated && <CtrlOToExpand />}
<Text dimColor>
Retrying in {retryInSecondsLive}{' '}
{retryInSecondsLive === 1 ? 'second' : 'seconds'} (attempt{' '}
{_retryAttempt}/{_maxRetries})
{process.env.API_TIMEOUT_MS
? ` · API_TIMEOUT_MS=${process.env.API_TIMEOUT_MS}ms, try increasing it`
: ''}
Retrying in {retryInSecondsLive} {retryInSecondsLive === 1 ? 'second' : 'seconds'} (attempt {_retryAttempt}/
{_maxRetries})
{process.env.API_TIMEOUT_MS ? ` · API_TIMEOUT_MS=${process.env.API_TIMEOUT_MS}ms, try increasing it` : ''}
</Text>
</Box>
</MessageResponse>
)
);
}

View File

@@ -1,27 +1,21 @@
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { Box, Link, Text, type TextProps } from '@anthropic/ink'
import { FilePathLink } from '../FilePathLink.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 { Box, Link, Text, type TextProps } from '@anthropic/ink';
import { FilePathLink } from '../FilePathLink.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 { openPath } from '../../utils/browser.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
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 { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type {
SystemMessage,
SystemStopHookSummaryMessage,
@@ -29,87 +23,68 @@ import type {
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 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'
} from '../../types/message.js';
import { SystemAPIErrorMessage } from './SystemAPIErrorMessage.js';
import { formatDuration, formatNumber, formatSecondsShort } from '../../utils/format.js';
import { getGlobalConfig } from '../../utils/config.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
}
message: SystemMessage;
addMargin: boolean;
verbose: boolean;
isTranscriptMode?: boolean;
};
export function SystemTextMessage({
message,
addMargin,
verbose,
isTranscriptMode,
}: Props): React.ReactNode {
const bg = useSelectedMessageBg()
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} />
return <TurnDurationMessage message={message} addMargin={addMargin} />;
}
if (message.subtype === 'memory_saved') {
return <MemorySavedMessage message={message} addMargin={addMargin} />
return <MemorySavedMessage message={message} addMargin={addMargin} />;
}
if (message.subtype === 'away_summary') {
return (
<Box
flexDirection="row"
marginTop={addMargin ? 1 : 0}
backgroundColor={bg}
width="100%"
>
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Box minWidth={2}>
<Text dimColor>{REFERENCE_MARK}</Text>
</Box>
<Text dimColor>{String(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 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 <ThinkingMessage message={message} addMargin={addMargin} />;
}
return null
return null;
}
if (message.subtype === 'bridge_status') {
return <BridgeStatusMessage message={message} addMargin={addMargin} />
return <BridgeStatusMessage message={message} addMargin={addMargin} />;
}
if (message.subtype === 'scheduled_task_fire') {
@@ -119,7 +94,7 @@ export function SystemTextMessage({
{TEARDROP_ASTERISK} {String(message.content ?? '')}
</Text>
</Box>
)
);
}
if (message.subtype === 'permission_retry') {
@@ -129,18 +104,18 @@ export function SystemTextMessage({
<Text>Allowed </Text>
<Text bold>{(message.commands as string[]).join(', ')}</Text>
</Box>
)
);
}
// Stop hook summaries should always be visible
const isStopHookSummary = message.subtype === 'stop_hook_summary'
const isStopHookSummary = message.subtype === 'stop_hook_summary';
if (!isStopHookSummary && !verbose && message.level === 'info') {
return null
return null;
}
if (message.subtype === 'api_error') {
return <SystemAPIErrorMessage message={message} verbose={verbose} />
return <SystemAPIErrorMessage message={message} verbose={verbose} />;
}
if (message.subtype === 'stop_hook_summary') {
@@ -151,14 +126,14 @@ export function SystemTextMessage({
verbose={verbose}
isTranscriptMode={isTranscriptMode}
/>
)
);
}
const content = message.content
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 null;
}
return (
<Box flexDirection="row" width="100%">
@@ -170,7 +145,7 @@ export function SystemTextMessage({
dimColor={message.level === 'info'}
/>
</Box>
)
);
}
function StopHookSummaryMessage({
@@ -179,83 +154,64 @@ function StopHookSummaryMessage({
verbose,
isTranscriptMode,
}: {
message: SystemStopHookSummaryMessage
addMargin: boolean
verbose: boolean
isTranscriptMode?: boolean
message: SystemStopHookSummaryMessage;
addMargin: boolean;
verbose: boolean;
isTranscriptMode?: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg()
const {
hookCount,
hookInfos,
} = message
const hookErrors = (message.hookErrors ?? []) as string[]
const preventedContinuation = message.preventedContinuation as boolean | undefined
const stopReason = message.stopReason as string | undefined
const { columns } = useTerminalSize()
const bg = useSelectedMessageBg();
const { hookCount, hookInfos } = message;
const hookErrors = (message.hookErrors ?? []) as string[];
const preventedContinuation = message.preventedContinuation as boolean | undefined;
const stopReason = message.stopReason as string | undefined;
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'
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
return null;
}
}
const totalStr =
isAnt && totalDurationMs > 0
? ` (${formatSecondsShort(totalDurationMs)})`
: ''
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'}
{' ⎿ '}Ran {hookCount} {message.hookLabel} {hookCount === 1 ? 'hook' : 'hooks'}
{totalStr}
</Text>
{isTranscriptMode &&
hookInfos.map((info, idx) => {
const durationStr =
isAnt && info.durationMs !== undefined
? ` (${formatSecondsShort(info.durationMs)})`
: ''
isAnt && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : '';
return (
<Text key={`cmd-${idx}`} dimColor>
{' ⎿ '}
{info.command === 'prompt'
? `prompt: ${info.promptText || ''}`
: info.command}
{info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command}
{durationStr}
</Text>
)
);
})}
</Box>
)
);
}
return (
<Box
flexDirection="row"
marginTop={addMargin ? 1 : 0}
backgroundColor={bg}
width="100%"
>
<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'}
Ran <Text bold>{hookCount}</Text> {message.hookLabel ?? 'stop'} {hookCount === 1 ? 'hook' : 'hooks'}
{totalStr}
{!verbose && hookInfos.length > 0 && (
<>
@@ -268,18 +224,14 @@ function StopHookSummaryMessage({
hookInfos.length > 0 &&
hookInfos.map((info, idx) => {
const durationStr =
isAnt && info.durationMs !== undefined
? ` (${formatSecondsShort(info.durationMs)})`
: ''
isAnt && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : '';
return (
<Text key={`cmd-${idx}`} dimColor>
&nbsp;
{info.command === 'prompt'
? `prompt: ${info.promptText || ''}`
: info.command}
{info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command}
{durationStr}
</Text>
)
);
})}
{preventedContinuation && stopReason && (
<Text>
@@ -296,7 +248,7 @@ function StopHookSummaryMessage({
))}
</Box>
</Box>
)
);
}
function SystemTextMessageInner({
@@ -306,22 +258,17 @@ function SystemTextMessageInner({
color,
dimColor,
}: {
content: string
addMargin: boolean
dot: boolean
color?: TextProps['color']
dimColor?: boolean
content: string;
addMargin: boolean;
dot: boolean;
color?: TextProps['color'];
dimColor?: boolean;
}): React.ReactNode {
const { columns } = useTerminalSize()
const bg = useSelectedMessageBg()
const { columns } = useTerminalSize();
const bg = useSelectedMessageBg();
return (
<Box
flexDirection="row"
marginTop={addMargin ? 1 : 0}
backgroundColor={bg}
width="100%"
>
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
{dot && (
<Box minWidth={2}>
<Text color={color} dimColor={dimColor}>
@@ -335,95 +282,79 @@ function SystemTextMessageInner({
</Text>
</Box>
</Box>
)
);
}
function TurnDurationMessage({
message,
addMargin,
}: {
message: SystemTurnDurationMessage
addMargin: boolean
message: SystemTurnDurationMessage;
addMargin: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg()
const [verb] = useState(() => sample(TURN_COMPLETION_VERBS) ?? 'Worked')
const store = useAppStateStore()
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 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 showTurnDuration = getGlobalConfig().showTurnDuration ?? true;
const duration = formatDuration(message.durationMs as number)
const hasBudget = message.budgetLimit !== undefined
const duration = formatDuration(message.durationMs as number);
const hasBudget = message.budgetLimit !== undefined;
const budgetSuffix = (() => {
if (!hasBudget) return ''
const tokens = message.budgetTokens as number
const limit = message.budgetLimit as number
if (!hasBudget) return '';
const tokens = message.budgetTokens as number;
const limit = message.budgetLimit as number;
const usage =
tokens >= limit
? `${formatNumber(tokens)} used (${formatNumber(limit)} min ${figures.tick})`
: `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round((tokens / limit) * 100)}%)`
: `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round((tokens / limit) * 100)}%)`;
const nudges =
(message.budgetNudges as number) > 0
? ` \u00B7 ${message.budgetNudges as number} ${(message.budgetNudges as number) === 1 ? 'nudge' : 'nudges'}`
: ''
return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}`
})()
: '';
return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}`;
})();
if (!showTurnDuration && !hasBudget) {
return null
return null;
}
return (
<Box
flexDirection="row"
marginTop={addMargin ? 1 : 0}
backgroundColor={bg}
width="100%"
>
<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`}
{backgroundTaskSummary && ` \u00B7 ${backgroundTaskSummary} still running`}
</Text>
</Box>
)
);
}
function MemorySavedMessage({
message,
addMargin,
}: {
message: SystemMemorySavedMessage
addMargin: boolean
message: SystemMemorySavedMessage;
addMargin: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg()
const writtenPaths = (message.writtenPaths ?? []) as string[]
const team = feature('TEAMMEM')
? teamMemSaved!.teamMemSavedPart(message)
: null
const privateCount = writtenPaths.length - (team?.count ?? 0)
const bg = useSelectedMessageBg();
const writtenPaths = (message.writtenPaths ?? []) as string[];
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,
privateCount > 0 ? `${privateCount} ${privateCount === 1 ? 'memory' : 'memories'}` : null,
team?.segment as React.ReactNode,
].filter(Boolean)
].filter(Boolean);
return (
<Box
flexDirection="column"
marginTop={addMargin ? 1 : 0}
backgroundColor={bg}
>
<Box flexDirection="column" marginTop={addMargin ? 1 : 0} backgroundColor={bg}>
<Box flexDirection="row">
<Box minWidth={2}>
<Text dimColor>{BLACK_CIRCLE}</Text>
@@ -436,75 +367,60 @@ function MemorySavedMessage({
<MemoryFileRow key={p} path={p} />
))}
</Box>
)
);
}
function MemoryFileRow({ path }: { path: string }): React.ReactNode {
const [hover, setHover] = useState(false)
const [hover, setHover] = useState(false);
return (
<MessageResponse>
<Box
onClick={() => void openPath(path)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<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
message: SystemThinkingMessage;
addMargin: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg()
const bg = useSelectedMessageBg();
return (
<Box
flexDirection="row"
marginTop={addMargin ? 1 : 0}
backgroundColor={bg}
width="100%"
>
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Box minWidth={2}>
<Text dimColor>{TEARDROP_ASTERISK}</Text>
</Box>
<Text dimColor>{String(message.content ?? '')}</Text>
</Box>
)
);
}
function BridgeStatusMessage({
message,
addMargin,
}: {
message: SystemBridgeStatusMessage
addMargin: boolean
message: SystemBridgeStatusMessage;
addMargin: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg()
const url = message.url as string
const upgradeNudge = message.upgradeNudge as string | undefined
const bg = useSelectedMessageBg();
const url = message.url as string;
const upgradeNudge = message.upgradeNudge as string | undefined;
return (
<Box
flexDirection="row"
marginTop={addMargin ? 1 : 0}
backgroundColor={bg}
width={999}
>
<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
<ThemedText color="suggestion">/remote-control</ThemedText> is active. Code in CLI or at
</Text>
<Link url={url}>{url}</Link>
{upgradeNudge && <Text dimColor> {upgradeNudge}</Text>}
</Box>
</Box>
)
);
}

View File

@@ -1,13 +1,10 @@
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import {
isTaskAssignment,
type TaskAssignmentMessage,
} from '../../utils/teammateMailbox.js'
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import { isTaskAssignment, type TaskAssignmentMessage } from '../../utils/teammateMailbox.js';
type Props = {
assignment: TaskAssignmentMessage
}
assignment: TaskAssignmentMessage;
};
/**
* Renders a task assignment with a cyan border (team-related color).
@@ -15,13 +12,7 @@ type Props = {
export function TaskAssignmentDisplay({ assignment }: Props): React.ReactNode {
return (
<Box flexDirection="column" marginY={1}>
<Box
borderStyle="round"
borderColor="cyan_FOR_SUBAGENTS_ONLY"
flexDirection="column"
paddingX={1}
paddingY={1}
>
<Box borderStyle="round" borderColor="cyan_FOR_SUBAGENTS_ONLY" flexDirection="column" paddingX={1} paddingY={1}>
<Box marginBottom={1}>
<Text color="cyan_FOR_SUBAGENTS_ONLY" bold>
Task #{assignment.taskId} assigned by {assignment.assignedBy}
@@ -37,29 +28,27 @@ export function TaskAssignmentDisplay({ assignment }: Props): React.ReactNode {
)}
</Box>
</Box>
)
);
}
/**
* Try to parse and render a task assignment message from raw content.
*/
export function tryRenderTaskAssignmentMessage(
content: string,
): React.ReactNode | null {
const assignment = isTaskAssignment(content)
export function tryRenderTaskAssignmentMessage(content: string): React.ReactNode | null {
const assignment = isTaskAssignment(content);
if (assignment) {
return <TaskAssignmentDisplay assignment={assignment} />
return <TaskAssignmentDisplay assignment={assignment} />;
}
return null
return null;
}
/**
* Get a brief summary text for a task assignment message.
*/
export function getTaskAssignmentSummary(content: string): string | null {
const assignment = isTaskAssignment(content)
const assignment = isTaskAssignment(content);
if (assignment) {
return `[Task Assigned] #${assignment.taskId} - ${assignment.subject}`
return `[Task Assigned] #${assignment.taskId} - ${assignment.subject}`;
}
return null
return null;
}

View File

@@ -1,36 +1,33 @@
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { BLACK_CIRCLE } from '../../constants/figures.js'
import { Box, Text, type TextProps } from '@anthropic/ink'
import { extractTag } from '../../utils/messages.js'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { BLACK_CIRCLE } from '../../constants/figures.js';
import { Box, Text, type TextProps } from '@anthropic/ink';
import { extractTag } from '../../utils/messages.js';
type Props = {
addMargin: boolean
param: TextBlockParam
}
addMargin: boolean;
param: TextBlockParam;
};
function getStatusColor(status: string | null): TextProps['color'] {
switch (status) {
case 'completed':
return 'success'
return 'success';
case 'failed':
return 'error'
return 'error';
case 'killed':
return 'warning'
return 'warning';
default:
return 'text'
return 'text';
}
}
export function UserAgentNotificationMessage({
addMargin,
param: { text },
}: Props): React.ReactNode {
const summary = extractTag(text, 'summary')
if (!summary) return null
export function UserAgentNotificationMessage({ addMargin, param: { text } }: Props): React.ReactNode {
const summary = extractTag(text, 'summary');
if (!summary) return null;
const status = extractTag(text, 'status')
const color = getStatusColor(status)
const status = extractTag(text, 'status');
const color = getStatusColor(status);
return (
<Box marginTop={addMargin ? 1 : 0}>
@@ -38,5 +35,5 @@ export function UserAgentNotificationMessage({
<Text color={color}>{BLACK_CIRCLE}</Text> {summary}
</Text>
</Box>
)
);
}

View File

@@ -1,20 +1,17 @@
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import { extractTag } from '../../utils/messages.js'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import { extractTag } from '../../utils/messages.js';
type Props = {
addMargin: boolean
param: TextBlockParam
}
addMargin: boolean;
param: TextBlockParam;
};
export function UserBashInputMessage({
param: { text },
addMargin,
}: Props): React.ReactNode {
const input = extractTag(text, 'bash-input')
export function UserBashInputMessage({ param: { text }, addMargin }: Props): React.ReactNode {
const input = extractTag(text, 'bash-input');
if (!input) {
return null
return null;
}
return (
<Box
@@ -26,5 +23,5 @@ export function UserBashInputMessage({
<Text color="bashBorder">! </Text>
<Text color="text">{input}</Text>
</Box>
)
);
}

View File

@@ -1,20 +1,12 @@
import * as React from 'react'
import BashToolResultMessage from '@claude-code-best/builtin-tools/tools/BashTool/BashToolResultMessage.js'
import { extractTag } from '../../utils/messages.js'
import * as React from 'react';
import BashToolResultMessage from '@claude-code-best/builtin-tools/tools/BashTool/BashToolResultMessage.js';
import { extractTag } from '../../utils/messages.js';
export function UserBashOutputMessage({
content,
verbose,
}: {
content: string
verbose?: boolean
}): React.ReactNode {
const rawStdout = extractTag(content, 'bash-stdout') ?? ''
export function UserBashOutputMessage({ content, verbose }: { content: string; verbose?: boolean }): React.ReactNode {
const rawStdout = extractTag(content, 'bash-stdout') ?? '';
// Unwrap <persisted-output> if present — keep the inner content (file path +
// preview) for the user; the wrapper tag itself is model-facing signaling.
const stdout = extractTag(rawStdout, 'persisted-output') ?? rawStdout
const stderr = extractTag(content, 'bash-stderr') ?? ''
return (
<BashToolResultMessage content={{ stdout, stderr }} verbose={!!verbose} />
)
const stdout = extractTag(rawStdout, 'persisted-output') ?? rawStdout;
const stderr = extractTag(content, 'bash-stderr') ?? '';
return <BashToolResultMessage content={{ stdout, stderr }} verbose={!!verbose} />;
}

View File

@@ -1,42 +1,37 @@
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { CHANNEL_ARROW } from '../../constants/figures.js'
import { CHANNEL_TAG } from '../../constants/xml.js'
import { Box, Text } from '@anthropic/ink'
import { truncateToWidth } from '../../utils/format.js'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { CHANNEL_ARROW } from '../../constants/figures.js';
import { CHANNEL_TAG } from '../../constants/xml.js';
import { Box, Text } from '@anthropic/ink';
import { truncateToWidth } from '../../utils/format.js';
type Props = {
addMargin: boolean
param: TextBlockParam
}
addMargin: boolean;
param: TextBlockParam;
};
// <channel source="..." user="..." chat_id="...">content</channel>
// source is always first (wrapChannelMessage writes it), user is optional.
const CHANNEL_RE = new RegExp(
`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`,
)
const USER_ATTR_RE = /\buser="([^"]+)"/
const CHANNEL_RE = new RegExp(`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`);
const USER_ATTR_RE = /\buser="([^"]+)"/;
// Plugin-provided servers get names like plugin:slack-channel:slack via
// addPluginScopeToServers — show just the leaf. Matches the suffix-match
// logic in isServerInChannels.
function displayServerName(name: string): string {
const i = name.lastIndexOf(':')
return i === -1 ? name : name.slice(i + 1)
const i = name.lastIndexOf(':');
return i === -1 ? name : name.slice(i + 1);
}
const TRUNCATE_AT = 60
const TRUNCATE_AT = 60;
export function UserChannelMessage({
addMargin,
param: { text },
}: Props): React.ReactNode {
const m = CHANNEL_RE.exec(text)
if (!m) return null
const [, source, attrs, content] = m
const user = USER_ATTR_RE.exec(attrs ?? '')?.[1]
const body = (content ?? '').trim().replace(/\s+/g, ' ')
const truncated = truncateToWidth(body, TRUNCATE_AT)
export function UserChannelMessage({ addMargin, param: { text } }: Props): React.ReactNode {
const m = CHANNEL_RE.exec(text);
if (!m) return null;
const [, source, attrs, content] = m;
const user = USER_ATTR_RE.exec(attrs ?? '')?.[1];
const body = (content ?? '').trim().replace(/\s+/g, ' ');
const truncated = truncateToWidth(body, TRUNCATE_AT);
return (
<Box marginTop={addMargin ? 1 : 0}>
<Text>
@@ -48,5 +43,5 @@ export function UserChannelMessage({
{truncated}
</Text>
</Box>
)
);
}

View File

@@ -1,25 +1,22 @@
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import figures from 'figures'
import * as React from 'react'
import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js'
import { Box, Text } from '@anthropic/ink'
import { extractTag } from '../../utils/messages.js'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import figures from 'figures';
import * as React from 'react';
import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js';
import { Box, Text } from '@anthropic/ink';
import { extractTag } from '../../utils/messages.js';
type Props = {
addMargin: boolean
param: TextBlockParam
}
addMargin: boolean;
param: TextBlockParam;
};
export function UserCommandMessage({
addMargin,
param: { text },
}: Props): React.ReactNode {
const commandMessage = extractTag(text, COMMAND_MESSAGE_TAG)
const args = extractTag(text, 'command-args')
const isSkillFormat = extractTag(text, 'skill-format') === 'true'
export function UserCommandMessage({ addMargin, param: { text } }: Props): React.ReactNode {
const commandMessage = extractTag(text, COMMAND_MESSAGE_TAG);
const args = extractTag(text, 'command-args');
const isSkillFormat = extractTag(text, 'skill-format') === 'true';
if (!commandMessage) {
return null
return null;
}
// Skills use "Skill(name)" format
@@ -36,22 +33,17 @@ export function UserCommandMessage({
<Text color="text">Skill({commandMessage})</Text>
</Text>
</Box>
)
);
}
// Slash command format: show as " /command args"
const content = `/${[commandMessage, args].filter(Boolean).join(' ')}`
const content = `/${[commandMessage, args].filter(Boolean).join(' ')}`;
return (
<Box
flexDirection="column"
marginTop={addMargin ? 1 : 0}
backgroundColor="userMessageBackground"
paddingRight={1}
>
<Box flexDirection="column" marginTop={addMargin ? 1 : 0} backgroundColor="userMessageBackground" paddingRight={1}>
<Text>
<Text color="subtle">{figures.pointer} </Text>
<Text color="text">{content}</Text>
</Text>
</Box>
)
);
}

View File

@@ -1,3 +1,4 @@
// Auto-generated stub — replace with real implementation
export {};
export const UserCrossSessionMessage: (props: Record<string, unknown>) => null = () => null;
export {}
export const UserCrossSessionMessage: (props: Record<string, unknown>) => null =
() => null

View File

@@ -1,3 +1,5 @@
// Auto-generated stub — replace with real implementation
export {};
export const UserForkBoilerplateMessage: (props: Record<string, unknown>) => null = () => null;
export {}
export const UserForkBoilerplateMessage: (
props: Record<string, unknown>,
) => null = () => null

View File

@@ -1,3 +1,5 @@
// Auto-generated stub — replace with real implementation
export {};
export const UserGitHubWebhookMessage: (props: Record<string, unknown>) => null = () => null;
export {}
export const UserGitHubWebhookMessage: (
props: Record<string, unknown>,
) => null = () => null

View File

@@ -1,13 +1,13 @@
import * as React from 'react'
import { pathToFileURL } from 'url'
import { Box, Link, supportsHyperlinks, Text } from '@anthropic/ink'
import { getStoredImagePath } from '../../utils/imageStore.js'
import { MessageResponse } from '../MessageResponse.js'
import * as React from 'react';
import { pathToFileURL } from 'url';
import { Box, Link, supportsHyperlinks, Text } from '@anthropic/ink';
import { getStoredImagePath } from '../../utils/imageStore.js';
import { MessageResponse } from '../MessageResponse.js';
type Props = {
imageId?: number
addMargin?: boolean
}
imageId?: number;
addMargin?: boolean;
};
/**
* Renders an image attachment in user messages.
@@ -15,12 +15,9 @@ type Props = {
* Uses MessageResponse styling to appear connected to the message above,
* unless addMargin is true (image starts a new user turn without text).
*/
export function UserImageMessage({
imageId,
addMargin,
}: Props): React.ReactNode {
const label = imageId ? `[Image #${imageId}]` : '[Image]'
const imagePath = imageId ? getStoredImagePath(imageId) : null
export function UserImageMessage({ imageId, addMargin }: Props): React.ReactNode {
const label = imageId ? `[Image #${imageId}]` : '[Image]';
const imagePath = imageId ? getStoredImagePath(imageId) : null;
const content =
imagePath && supportsHyperlinks() ? (
@@ -29,13 +26,13 @@ export function UserImageMessage({
</Link>
) : (
<Text>{label}</Text>
)
);
// When this image starts a new user turn (no text before it),
// show with margin instead of the connected line style
if (addMargin) {
return <Box marginTop={1}>{content}</Box>
return <Box marginTop={1}>{content}</Box>;
}
return <MessageResponse>{content}</MessageResponse>
return <MessageResponse>{content}</MessageResponse>;
}

View File

@@ -1,44 +1,39 @@
import * as React from 'react'
import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'
import { NO_CONTENT_MESSAGE } from '../../constants/messages.js'
import { Box, Text } from '@anthropic/ink'
import { extractTag } from '../../utils/messages.js'
import { Markdown } from '../Markdown.js'
import { MessageResponse } from '../MessageResponse.js'
import * as React from 'react';
import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js';
import { NO_CONTENT_MESSAGE } from '../../constants/messages.js';
import { Box, Text } from '@anthropic/ink';
import { extractTag } from '../../utils/messages.js';
import { Markdown } from '../Markdown.js';
import { MessageResponse } from '../MessageResponse.js';
type Props = {
content: string
}
content: string;
};
export function UserLocalCommandOutputMessage({
content,
}: Props): React.ReactNode {
const stdout = extractTag(content, 'local-command-stdout')
const stderr = extractTag(content, 'local-command-stderr')
export function UserLocalCommandOutputMessage({ content }: Props): React.ReactNode {
const stdout = extractTag(content, 'local-command-stdout');
const stderr = extractTag(content, 'local-command-stderr');
if (!stdout && !stderr) {
return (
<MessageResponse>
<Text dimColor>{NO_CONTENT_MESSAGE}</Text>
</MessageResponse>
)
);
}
const lines: React.ReactNode[] = []
const lines: React.ReactNode[] = [];
if (stdout?.trim()) {
lines.push(<IndentedContent key="stdout">{stdout.trim()}</IndentedContent>)
lines.push(<IndentedContent key="stdout">{stdout.trim()}</IndentedContent>);
}
if (stderr?.trim()) {
lines.push(<IndentedContent key="stderr">{stderr.trim()}</IndentedContent>)
lines.push(<IndentedContent key="stderr">{stderr.trim()}</IndentedContent>);
}
return lines
return lines;
}
function IndentedContent({ children }: { children: string }): React.ReactNode {
if (
children.startsWith(`${DIAMOND_OPEN} `) ||
children.startsWith(`${DIAMOND_FILLED} `)
) {
return <CloudLaunchContent>{children}</CloudLaunchContent>
if (children.startsWith(`${DIAMOND_OPEN} `) || children.startsWith(`${DIAMOND_FILLED} `)) {
return <CloudLaunchContent>{children}</CloudLaunchContent>;
}
return (
<Box flexDirection="row">
@@ -47,21 +42,17 @@ function IndentedContent({ children }: { children: string }): React.ReactNode {
<Markdown>{children}</Markdown>
</Box>
</Box>
)
);
}
function CloudLaunchContent({
children,
}: {
children: string
}): React.ReactNode {
const diamond = children[0]!
const nl = children.indexOf('\n')
const header = nl === -1 ? children.slice(2) : children.slice(2, nl)
const rest = nl === -1 ? '' : children.slice(nl + 1).trim()
const sep = header.indexOf(' · ')
const label = sep === -1 ? header : header.slice(0, sep)
const suffix = sep === -1 ? '' : header.slice(sep)
function CloudLaunchContent({ children }: { children: string }): React.ReactNode {
const diamond = children[0]!;
const nl = children.indexOf('\n');
const header = nl === -1 ? children.slice(2) : children.slice(2, nl);
const rest = nl === -1 ? '' : children.slice(nl + 1).trim();
const sep = header.indexOf(' · ');
const label = sep === -1 ? header : header.slice(0, sep);
const suffix = sep === -1 ? '' : header.slice(sep);
return (
<Box flexDirection="column">
<Text>
@@ -76,5 +67,5 @@ function CloudLaunchContent({
</Box>
)}
</Box>
)
);
}

View File

@@ -1,28 +1,25 @@
import sample from 'lodash-es/sample.js'
import * as React from 'react'
import { useMemo } from 'react'
import { Box, Text } from '@anthropic/ink'
import { extractTag } from '../../utils/messages.js'
import { MessageResponse } from '../MessageResponse.js'
import sample from 'lodash-es/sample.js';
import * as React from 'react';
import { useMemo } from 'react';
import { Box, Text } from '@anthropic/ink';
import { extractTag } from '../../utils/messages.js';
import { MessageResponse } from '../MessageResponse.js';
function getSavingMessage(): string {
return sample(['Got it.', 'Good to know.', 'Noted.'])
return sample(['Got it.', 'Good to know.', 'Noted.']);
}
type Props = {
addMargin: boolean
text: string
}
addMargin: boolean;
text: string;
};
export function UserMemoryInputMessage({
text,
addMargin,
}: Props): React.ReactNode {
const input = extractTag(text, 'user-memory-input')
const savingText = useMemo(() => getSavingMessage(), [])
export function UserMemoryInputMessage({ text, addMargin }: Props): React.ReactNode {
const input = extractTag(text, 'user-memory-input');
const savingText = useMemo(() => getSavingMessage(), []);
if (!input) {
return null
return null;
}
return (
@@ -40,5 +37,5 @@ export function UserMemoryInputMessage({
<Text dimColor>{savingText}</Text>
</MessageResponse>
</Box>
)
);
}

View File

@@ -1,24 +1,15 @@
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import { Markdown } from '../Markdown.js'
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import { Markdown } from '../Markdown.js';
type Props = {
addMargin: boolean
planContent: string
}
addMargin: boolean;
planContent: string;
};
export function UserPlanMessage({
addMargin,
planContent,
}: Props): React.ReactNode {
export function UserPlanMessage({ addMargin, planContent }: Props): React.ReactNode {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="planMode"
marginTop={addMargin ? 1 : 0}
paddingX={1}
>
<Box flexDirection="column" borderStyle="round" borderColor="planMode" marginTop={addMargin ? 1 : 0} paddingX={1}>
<Box marginBottom={1}>
<Text bold color="planMode">
Plan to implement
@@ -26,5 +17,5 @@ export function UserPlanMessage({
</Box>
<Markdown>{planContent}</Markdown>
</Box>
)
);
}

View File

@@ -1,22 +1,22 @@
import { feature } from 'bun:bundle'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import React, { useContext, useMemo } from 'react'
import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js'
import { Box } from '@anthropic/ink'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { useAppState } from '../../state/AppState.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { logError } from '../../utils/log.js'
import { countCharInString } from '../../utils/stringUtils.js'
import { MessageActionsSelectedContext } from '../messageActions.js'
import { HighlightedThinkingText } from './HighlightedThinkingText.js'
import { feature } from 'bun:bundle';
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import React, { useContext, useMemo } from 'react';
import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js';
import { Box } from '@anthropic/ink';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js';
import { useAppState } from '../../state/AppState.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
import { logError } from '../../utils/log.js';
import { countCharInString } from '../../utils/stringUtils.js';
import { MessageActionsSelectedContext } from '../messageActions.js';
import { HighlightedThinkingText } from './HighlightedThinkingText.js';
type Props = {
addMargin: boolean
param: TextBlockParam
isTranscriptMode?: boolean
timestamp?: string
}
addMargin: boolean;
param: TextBlockParam;
isTranscriptMode?: boolean;
timestamp?: string;
};
// Hard cap on displayed prompt text. Piping large files via stdin
// (e.g. `cat 11k-line-file | claude`) creates a single user message whose
@@ -26,16 +26,11 @@ type Props = {
// avoids this via <Static> (print-and-forget to terminal scrollback).
// Head+tail because `{ cat file; echo prompt; } | claude` puts the user's
// actual question at the end.
const MAX_DISPLAY_CHARS = 10_000
const TRUNCATE_HEAD_CHARS = 2_500
const TRUNCATE_TAIL_CHARS = 2_500
const MAX_DISPLAY_CHARS = 10_000;
const TRUNCATE_HEAD_CHARS = 2_500;
const TRUNCATE_TAIL_CHARS = 2_500;
export function UserPromptMessage({
addMargin,
param: { text },
isTranscriptMode,
timestamp,
}: Props): React.ReactNode {
export function UserPromptMessage({ addMargin, param: { text }, isTranscriptMode, timestamp }: Props): React.ReactNode {
// REPL.tsx passes isBriefOnly={viewedTeammateTask ? false : isBriefOnly}
// but that prop isn't threaded this deep — replicate the override by
// reading viewingAgentTaskId directly. Computed here (not in the child)
@@ -48,65 +43,45 @@ export function UserPromptMessage({
// bypasses React.memo). Runtime-gated like isBriefEnabled() but inlined
// to avoid pulling BriefTool.ts → prompt.ts tool-name strings into
// external builds.
const isBriefOnly =
feature('KAIROS') || feature('KAIROS_BRIEF')
?
useAppState(s => s.isBriefOnly)
: false
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
const viewingAgentTaskId =
feature('KAIROS') || feature('KAIROS_BRIEF')
?
useAppState(s => s.viewingAgentTaskId)
: null
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.viewingAgentTaskId) : null;
// Hoisted to mount-time — per-message component, re-renders on every scroll.
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
?
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false;
const useBriefLayout =
feature('KAIROS') || feature('KAIROS_BRIEF')
? (getKairosActive() ||
(getUserMsgOptIn() &&
(briefEnvEnabled ||
getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_kairos_brief',
false,
)))) &&
(briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
isBriefOnly &&
!isTranscriptMode &&
!viewingAgentTaskId
: false
: false;
// Truncate before the early return so the hook order is stable.
const displayText = useMemo(() => {
if (text.length <= MAX_DISPLAY_CHARS) return text
const head = text.slice(0, TRUNCATE_HEAD_CHARS)
const tail = text.slice(-TRUNCATE_TAIL_CHARS)
const hiddenLines =
countCharInString(text, '\n', TRUNCATE_HEAD_CHARS) -
countCharInString(tail, '\n')
return `${head}\n… +${hiddenLines} lines …\n${tail}`
}, [text])
if (text.length <= MAX_DISPLAY_CHARS) return text;
const head = text.slice(0, TRUNCATE_HEAD_CHARS);
const tail = text.slice(-TRUNCATE_TAIL_CHARS);
const hiddenLines = countCharInString(text, '\n', TRUNCATE_HEAD_CHARS) - countCharInString(tail, '\n');
return `${head}\n… +${hiddenLines} lines …\n${tail}`;
}, [text]);
const isSelected = useContext(MessageActionsSelectedContext)
const isSelected = useContext(MessageActionsSelectedContext);
if (!text) {
logError(new Error('No content found in user prompt message'))
return null
logError(new Error('No content found in user prompt message'));
return null;
}
return (
<Box
flexDirection="column"
marginTop={addMargin ? 1 : 0}
backgroundColor={
isSelected
? 'messageActionsBackground'
: useBriefLayout
? undefined
: 'userMessageBackground'
}
backgroundColor={isSelected ? 'messageActionsBackground' : useBriefLayout ? undefined : 'userMessageBackground'}
paddingRight={useBriefLayout ? 0 : 1}
>
<HighlightedThinkingText
@@ -115,5 +90,5 @@ export function UserPromptMessage({
timestamp={useBriefLayout ? timestamp : undefined}
/>
</Box>
)
);
}

View File

@@ -1,91 +1,83 @@
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { REFRESH_ARROW } from '../../constants/figures.js'
import { Box, Text } from '@anthropic/ink'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { REFRESH_ARROW } from '../../constants/figures.js';
import { Box, Text } from '@anthropic/ink';
type Props = {
addMargin: boolean
param: TextBlockParam
}
addMargin: boolean;
param: TextBlockParam;
};
type ParsedUpdate = {
kind: 'resource' | 'polling'
server: string
kind: 'resource' | 'polling';
server: string;
/** URI for resource updates, tool name for polling updates */
target: string
reason?: string
}
target: string;
reason?: string;
};
// Parse resource and polling updates from XML format
function parseUpdates(text: string): ParsedUpdate[] {
const updates: ParsedUpdate[] = []
const updates: ParsedUpdate[] = [];
// Match <mcp-resource-update server="..." uri="...">
const resourceRegex =
/<mcp-resource-update\s+server="([^"]+)"\s+uri="([^"]+)"[^>]*>(?:[\s\S]*?<reason>([^<]+)<\/reason>)?/g
let match
/<mcp-resource-update\s+server="([^"]+)"\s+uri="([^"]+)"[^>]*>(?:[\s\S]*?<reason>([^<]+)<\/reason>)?/g;
let match;
while ((match = resourceRegex.exec(text)) !== null) {
updates.push({
kind: 'resource',
server: match[1] ?? '',
target: match[2] ?? '',
reason: match[3],
})
});
}
// Match <mcp-polling-update type="tool" server="..." tool="...">
const pollingRegex =
/<mcp-polling-update\s+type="([^"]+)"\s+server="([^"]+)"\s+tool="([^"]+)"[^>]*>(?:[\s\S]*?<reason>([^<]+)<\/reason>)?/g
/<mcp-polling-update\s+type="([^"]+)"\s+server="([^"]+)"\s+tool="([^"]+)"[^>]*>(?:[\s\S]*?<reason>([^<]+)<\/reason>)?/g;
while ((match = pollingRegex.exec(text)) !== null) {
updates.push({
kind: 'polling',
server: match[2] ?? '',
target: match[3] ?? '',
reason: match[4],
})
});
}
return updates
return updates;
}
// Format URI for display - show just the meaningful part
function formatUri(uri: string): string {
// For file:// URIs, show just the filename
if (uri.startsWith('file://')) {
const path = uri.slice(7)
const parts = path.split('/')
return parts[parts.length - 1] || path
const path = uri.slice(7);
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
// For other URIs, show the whole thing but truncated
if (uri.length > 40) {
return uri.slice(0, 39) + '\u2026'
return uri.slice(0, 39) + '\u2026';
}
return uri
return uri;
}
export function UserResourceUpdateMessage({
addMargin,
param: { text },
}: Props): React.ReactNode {
const updates = parseUpdates(text)
if (updates.length === 0) return null
export function UserResourceUpdateMessage({ addMargin, param: { text } }: Props): React.ReactNode {
const updates = parseUpdates(text);
if (updates.length === 0) return null;
return (
<Box flexDirection="column" marginTop={addMargin ? 1 : 0}>
{updates.map((update, i) => (
<Box key={i}>
<Text>
<Text color="success">{REFRESH_ARROW}</Text>{' '}
<Text dimColor>{update.server}:</Text>{' '}
<Text color="suggestion">
{update.kind === 'resource'
? formatUri(update.target)
: update.target}
</Text>
<Text color="success">{REFRESH_ARROW}</Text> <Text dimColor>{update.server}:</Text>{' '}
<Text color="suggestion">{update.kind === 'resource' ? formatUri(update.target) : update.target}</Text>
{update.reason && <Text dimColor> · {update.reason}</Text>}
</Text>
</Box>
))}
</Box>
)
);
}

View File

@@ -1,34 +1,34 @@
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import figures from 'figures'
import * as React from 'react'
import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js'
import { Ansi, Box, Text, type TextProps } from '@anthropic/ink'
import { toInkColor } from '../../utils/ink.js'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import figures from 'figures';
import * as React from 'react';
import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js';
import { Ansi, Box, Text, type TextProps } from '@anthropic/ink';
import { toInkColor } from '../../utils/ink.js';
import { jsonParse } from '../../utils/slowOperations.js'
import { isShutdownApproved } from '../../utils/teammateMailbox.js'
import { MessageResponse } from '../MessageResponse.js'
import { tryRenderPlanApprovalMessage } from './PlanApprovalMessage.js'
import { tryRenderShutdownMessage } from './ShutdownMessage.js'
import { tryRenderTaskAssignmentMessage } from './TaskAssignmentMessage.js'
import { jsonParse } from '../../utils/slowOperations.js';
import { isShutdownApproved } from '../../utils/teammateMailbox.js';
import { MessageResponse } from '../MessageResponse.js';
import { tryRenderPlanApprovalMessage } from './PlanApprovalMessage.js';
import { tryRenderShutdownMessage } from './ShutdownMessage.js';
import { tryRenderTaskAssignmentMessage } from './TaskAssignmentMessage.js';
type Props = {
addMargin: boolean
param: TextBlockParam
isTranscriptMode?: boolean
}
addMargin: boolean;
param: TextBlockParam;
isTranscriptMode?: boolean;
};
type ParsedMessage = {
teammateId: string
content: string
color?: string
summary?: string
}
teammateId: string;
content: string;
color?: string;
summary?: string;
};
const TEAMMATE_MSG_REGEX = new RegExp(
`<${TEAMMATE_MESSAGE_TAG}\\s+teammate_id="([^"]+)"(?:\\s+color="([^"]+)")?(?:\\s+summary="([^"]+)")?>\\n?([\\s\\S]*?)\\n?<\\/${TEAMMATE_MESSAGE_TAG}>`,
'g',
)
);
/**
* Parse all teammate messages from XML format:
@@ -36,7 +36,7 @@ const TEAMMATE_MSG_REGEX = new RegExp(
* Supports multiple messages in a single text block.
*/
function parseTeammateMessages(text: string): ParsedMessage[] {
const messages: ParsedMessage[] = []
const messages: ParsedMessage[] = [];
// Use matchAll to find all matches (this is a RegExp method, not child_process)
for (const match of text.matchAll(TEAMMATE_MSG_REGEX)) {
if (match[1] && match[4]) {
@@ -45,114 +45,97 @@ function parseTeammateMessages(text: string): ParsedMessage[] {
color: match[2], // may be undefined
summary: match[3], // may be undefined
content: match[4].trim(),
})
});
}
}
return messages
return messages;
}
function getDisplayName(teammateId: string): string {
if (teammateId === 'leader') {
return 'leader'
return 'leader';
}
return teammateId
return teammateId;
}
export function UserTeammateMessage({
addMargin,
param: { text },
isTranscriptMode,
}: Props): React.ReactNode {
export function UserTeammateMessage({ addMargin, param: { text }, isTranscriptMode }: Props): React.ReactNode {
const messages = parseTeammateMessages(text).filter(msg => {
// Pre-filter shutdown lifecycle messages to avoid empty wrapper
// Box elements creating blank lines between model turns
if (isShutdownApproved(msg.content)) {
return false
return false;
}
try {
const parsed = jsonParse(msg.content)
if (parsed?.type === 'teammate_terminated') return false
const parsed = jsonParse(msg.content);
if (parsed?.type === 'teammate_terminated') return false;
} catch {
// Not JSON, keep the message
}
return true
})
return true;
});
if (messages.length === 0) {
return null
return null;
}
return (
<Box flexDirection="column" marginTop={addMargin ? 1 : 0} width="100%">
{messages.map((msg, index) => {
const inkColor = toInkColor(msg.color)
const displayName = getDisplayName(msg.teammateId)
const inkColor = toInkColor(msg.color);
const displayName = getDisplayName(msg.teammateId);
// Try to render as plan approval message (request or response)
const planApprovalElement = tryRenderPlanApprovalMessage(
msg.content,
displayName,
)
const planApprovalElement = tryRenderPlanApprovalMessage(msg.content, displayName);
if (planApprovalElement) {
return (
<React.Fragment key={index}>{planApprovalElement}</React.Fragment>
)
return <React.Fragment key={index}>{planApprovalElement}</React.Fragment>;
}
// Try to render as shutdown message (request or rejected)
const shutdownElement = tryRenderShutdownMessage(msg.content)
const shutdownElement = tryRenderShutdownMessage(msg.content);
if (shutdownElement) {
return <React.Fragment key={index}>{shutdownElement}</React.Fragment>
return <React.Fragment key={index}>{shutdownElement}</React.Fragment>;
}
// Try to render as task assignment message
const taskAssignmentElement = tryRenderTaskAssignmentMessage(
msg.content,
)
const taskAssignmentElement = tryRenderTaskAssignmentMessage(msg.content);
if (taskAssignmentElement) {
return (
<React.Fragment key={index}>{taskAssignmentElement}</React.Fragment>
)
return <React.Fragment key={index}>{taskAssignmentElement}</React.Fragment>;
}
// Try to parse as structured JSON message
let parsedIdleNotification: { type?: string } | null = null
let parsedIdleNotification: { type?: string } | null = null;
try {
parsedIdleNotification = jsonParse(msg.content)
parsedIdleNotification = jsonParse(msg.content);
} catch {
// Not JSON
}
// Hide idle notifications - they are processed silently
if (parsedIdleNotification?.type === 'idle_notification') {
return null
return null;
}
// Task completed notification - show which task was completed
if (parsedIdleNotification?.type === 'task_completed') {
const taskCompleted = parsedIdleNotification as {
type: string
from: string
taskId: string
taskSubject?: string
}
type: string;
from: string;
taskId: string;
taskSubject?: string;
};
return (
<Box key={index} flexDirection="column" marginTop={1}>
<Text
color={inkColor}
>{`@${displayName}${figures.pointer}`}</Text>
<Text color={inkColor}>{`@${displayName}${figures.pointer}`}</Text>
<MessageResponse>
<Text color="success"></Text>
<Text>
{' '}
Completed task #{taskCompleted.taskId}
{taskCompleted.taskSubject && (
<Text dimColor> ({taskCompleted.taskSubject})</Text>
)}
{taskCompleted.taskSubject && <Text dimColor> ({taskCompleted.taskSubject})</Text>}
</Text>
</MessageResponse>
</Box>
)
);
}
// Default: plain text message (truncated)
@@ -165,19 +148,19 @@ export function UserTeammateMessage({
summary={msg.summary}
isTranscriptMode={isTranscriptMode}
/>
)
);
})}
</Box>
)
);
}
type TeammateMessageContentProps = {
displayName: string
inkColor: TextProps['color']
content: string
summary?: string
isTranscriptMode?: boolean
}
displayName: string;
inkColor: TextProps['color'];
content: string;
summary?: string;
isTranscriptMode?: boolean;
};
export function TeammateMessageContent({
displayName,
@@ -200,5 +183,5 @@ export function TeammateMessageContent({
</Box>
)}
</Box>
)
);
}

View File

@@ -1,41 +1,37 @@
import { feature } from 'bun:bundle'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { NO_CONTENT_MESSAGE } from '../../constants/messages.js'
import { feature } from 'bun:bundle';
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { NO_CONTENT_MESSAGE } from '../../constants/messages.js';
import {
COMMAND_MESSAGE_TAG,
LOCAL_COMMAND_CAVEAT_TAG,
TASK_NOTIFICATION_TAG,
TEAMMATE_MESSAGE_TAG,
TICK_TAG,
} from '../../constants/xml.js'
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
import {
extractTag,
INTERRUPT_MESSAGE,
INTERRUPT_MESSAGE_FOR_TOOL_USE,
} from '../../utils/messages.js'
import { InterruptedByUser } from '../InterruptedByUser.js'
import { MessageResponse } from '../MessageResponse.js'
import { UserAgentNotificationMessage } from './UserAgentNotificationMessage.js'
import { UserBashInputMessage } from './UserBashInputMessage.js'
import { UserBashOutputMessage } from './UserBashOutputMessage.js'
import { UserCommandMessage } from './UserCommandMessage.js'
import { UserLocalCommandOutputMessage } from './UserLocalCommandOutputMessage.js'
import { UserMemoryInputMessage } from './UserMemoryInputMessage.js'
import { UserPlanMessage } from './UserPlanMessage.js'
import { UserPromptMessage } from './UserPromptMessage.js'
import { UserResourceUpdateMessage } from './UserResourceUpdateMessage.js'
import { UserTeammateMessage } from './UserTeammateMessage.js'
} from '../../constants/xml.js';
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
import { extractTag, INTERRUPT_MESSAGE, INTERRUPT_MESSAGE_FOR_TOOL_USE } from '../../utils/messages.js';
import { InterruptedByUser } from '../InterruptedByUser.js';
import { MessageResponse } from '../MessageResponse.js';
import { UserAgentNotificationMessage } from './UserAgentNotificationMessage.js';
import { UserBashInputMessage } from './UserBashInputMessage.js';
import { UserBashOutputMessage } from './UserBashOutputMessage.js';
import { UserCommandMessage } from './UserCommandMessage.js';
import { UserLocalCommandOutputMessage } from './UserLocalCommandOutputMessage.js';
import { UserMemoryInputMessage } from './UserMemoryInputMessage.js';
import { UserPlanMessage } from './UserPlanMessage.js';
import { UserPromptMessage } from './UserPromptMessage.js';
import { UserResourceUpdateMessage } from './UserResourceUpdateMessage.js';
import { UserTeammateMessage } from './UserTeammateMessage.js';
type Props = {
addMargin: boolean
param: TextBlockParam
verbose: boolean
planContent?: string
isTranscriptMode?: boolean
timestamp?: string
}
addMargin: boolean;
param: TextBlockParam;
verbose: boolean;
planContent?: string;
isTranscriptMode?: boolean;
timestamp?: string;
};
export function UserTextMessage({
addMargin,
@@ -46,49 +42,40 @@ export function UserTextMessage({
timestamp,
}: Props): React.ReactNode {
if (param.text.trim() === NO_CONTENT_MESSAGE) {
return null
return null;
}
// Plan to implement message (cleared context flow)
if (planContent) {
return <UserPlanMessage addMargin={addMargin} planContent={planContent} />
return <UserPlanMessage addMargin={addMargin} planContent={planContent} />;
}
if (extractTag(param.text, TICK_TAG)) {
return null
return null;
}
// Hide synthetic caveat messages (should be filtered by isMeta, this is defensive)
if (param.text.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`)) {
return null
return null;
}
// Show bash output
if (
param.text.startsWith('<bash-stdout') ||
param.text.startsWith('<bash-stderr')
) {
return <UserBashOutputMessage content={param.text} verbose={verbose} />
if (param.text.startsWith('<bash-stdout') || param.text.startsWith('<bash-stderr')) {
return <UserBashOutputMessage content={param.text} verbose={verbose} />;
}
// Show command output
if (
param.text.startsWith('<local-command-stdout') ||
param.text.startsWith('<local-command-stderr')
) {
return <UserLocalCommandOutputMessage content={param.text} />
if (param.text.startsWith('<local-command-stdout') || param.text.startsWith('<local-command-stderr')) {
return <UserLocalCommandOutputMessage content={param.text} />;
}
// Handle interruption messages specially
if (
param.text === INTERRUPT_MESSAGE ||
param.text === INTERRUPT_MESSAGE_FOR_TOOL_USE
) {
if (param.text === INTERRUPT_MESSAGE || param.text === INTERRUPT_MESSAGE_FOR_TOOL_USE) {
return (
<MessageResponse height={1}>
<InterruptedByUser />
</MessageResponse>
)
);
}
// GitHub webhook events (check_run, review comments, pushes) delivered via
@@ -101,51 +88,39 @@ export function UserTextMessage({
if (param.text.startsWith('<github-webhook-activity>')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { UserGitHubWebhookMessage } =
require('./UserGitHubWebhookMessage.js') as typeof import('./UserGitHubWebhookMessage.js')
require('./UserGitHubWebhookMessage.js') as typeof import('./UserGitHubWebhookMessage.js');
/* eslint-enable @typescript-eslint/no-require-imports */
return <UserGitHubWebhookMessage addMargin={addMargin} param={param} />
return <UserGitHubWebhookMessage addMargin={addMargin} param={param} />;
}
}
// Bash inputs!
if (param.text.includes('<bash-input>')) {
return <UserBashInputMessage addMargin={addMargin} param={param} />
return <UserBashInputMessage addMargin={addMargin} param={param} />;
}
// Slash commands/
if (param.text.includes(`<${COMMAND_MESSAGE_TAG}>`)) {
return <UserCommandMessage addMargin={addMargin} param={param} />
return <UserCommandMessage addMargin={addMargin} param={param} />;
}
if (param.text.includes('<user-memory-input>')) {
return <UserMemoryInputMessage addMargin={addMargin} text={param.text} />
return <UserMemoryInputMessage addMargin={addMargin} text={param.text} />;
}
// Teammate messages - only check when swarms enabled
if (
isAgentSwarmsEnabled() &&
param.text.includes(`<${TEAMMATE_MESSAGE_TAG}`)
) {
return (
<UserTeammateMessage
addMargin={addMargin}
param={param}
isTranscriptMode={isTranscriptMode}
/>
)
if (isAgentSwarmsEnabled() && param.text.includes(`<${TEAMMATE_MESSAGE_TAG}`)) {
return <UserTeammateMessage addMargin={addMargin} param={param} isTranscriptMode={isTranscriptMode} />;
}
// Task notifications (agent completions, bash completions, etc.)
if (param.text.includes(`<${TASK_NOTIFICATION_TAG}`)) {
return <UserAgentNotificationMessage addMargin={addMargin} param={param} />
return <UserAgentNotificationMessage addMargin={addMargin} param={param} />;
}
// MCP resource and polling update notifications
if (
param.text.includes('<mcp-resource-update') ||
param.text.includes('<mcp-polling-update')
) {
return <UserResourceUpdateMessage addMargin={addMargin} param={param} />
if (param.text.includes('<mcp-resource-update') || param.text.includes('<mcp-polling-update')) {
return <UserResourceUpdateMessage addMargin={addMargin} param={param} />;
}
// Fork child's first message: collapse the rules/format boilerplate, show
@@ -155,9 +130,9 @@ export function UserTextMessage({
if (param.text.includes('<fork-boilerplate>')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { UserForkBoilerplateMessage } =
require('./UserForkBoilerplateMessage.js') as typeof import('./UserForkBoilerplateMessage.js')
require('./UserForkBoilerplateMessage.js') as typeof import('./UserForkBoilerplateMessage.js');
/* eslint-enable @typescript-eslint/no-require-imports */
return <UserForkBoilerplateMessage addMargin={addMargin} param={param} />
return <UserForkBoilerplateMessage addMargin={addMargin} param={param} />;
}
}
@@ -168,9 +143,9 @@ export function UserTextMessage({
if (param.text.includes('<cross-session-message')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { UserCrossSessionMessage } =
require('./UserCrossSessionMessage.js') as typeof import('./UserCrossSessionMessage.js')
require('./UserCrossSessionMessage.js') as typeof import('./UserCrossSessionMessage.js');
/* eslint-enable @typescript-eslint/no-require-imports */
return <UserCrossSessionMessage addMargin={addMargin} param={param} />
return <UserCrossSessionMessage addMargin={addMargin} param={param} />;
}
}
@@ -178,20 +153,14 @@ export function UserTextMessage({
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
if (param.text.includes('<channel source="')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { UserChannelMessage } =
require('./UserChannelMessage.js') as typeof import('./UserChannelMessage.js')
const { UserChannelMessage } = require('./UserChannelMessage.js') as typeof import('./UserChannelMessage.js');
/* eslint-enable @typescript-eslint/no-require-imports */
return <UserChannelMessage addMargin={addMargin} param={param} />
return <UserChannelMessage addMargin={addMargin} param={param} />;
}
}
// User prompts>
return (
<UserPromptMessage
addMargin={addMargin}
param={param}
isTranscriptMode={isTranscriptMode}
timestamp={timestamp}
/>
)
<UserPromptMessage addMargin={addMargin} param={param} isTranscriptMode={isTranscriptMode} timestamp={timestamp} />
);
}

View File

@@ -1,11 +1,11 @@
import * as React from 'react'
import { Markdown } from 'src/components/Markdown.js'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { Box, Text } from '@anthropic/ink'
import * as React from 'react';
import { Markdown } from 'src/components/Markdown.js';
import { MessageResponse } from 'src/components/MessageResponse.js';
import { Box, Text } from '@anthropic/ink';
type Props = {
plan: string
}
plan: string;
};
export function RejectedPlanMessage({ plan }: Props): React.ReactNode {
return (
@@ -23,5 +23,5 @@ export function RejectedPlanMessage({ plan }: Props): React.ReactNode {
</Box>
</Box>
</MessageResponse>
)
);
}

View File

@@ -1,11 +1,11 @@
import * as React from 'react'
import { Text } from '@anthropic/ink'
import { MessageResponse } from '../../MessageResponse.js'
import * as React from 'react';
import { Text } from '@anthropic/ink';
import { MessageResponse } from '../../MessageResponse.js';
export function RejectedToolUseMessage(): React.ReactNode {
return (
<MessageResponse height={1}>
<Text dimColor>Tool use rejected</Text>
</MessageResponse>
)
);
}

View File

@@ -1,11 +1,11 @@
import * as React from 'react'
import { InterruptedByUser } from 'src/components/InterruptedByUser.js'
import { MessageResponse } from 'src/components/MessageResponse.js'
import * as React from 'react';
import { InterruptedByUser } from 'src/components/InterruptedByUser.js';
import { MessageResponse } from 'src/components/MessageResponse.js';
export function UserToolCanceledMessage(): React.ReactNode {
return (
<MessageResponse height={1}>
<InterruptedByUser />
</MessageResponse>
)
);
}

View File

@@ -1,34 +1,30 @@
import { feature } from 'bun:bundle'
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { BULLET_OPERATOR } from '../../../constants/figures.js'
import { Text } from '@anthropic/ink'
import {
filterToolProgressMessages,
type Tool,
type Tools,
} from '../../../Tool.js'
import type { ProgressMessage } from '../../../types/message.js'
import { feature } from 'bun:bundle';
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { BULLET_OPERATOR } from '../../../constants/figures.js';
import { Text } from '@anthropic/ink';
import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js';
import type { ProgressMessage } from '../../../types/message.js';
import {
INTERRUPT_MESSAGE_FOR_TOOL_USE,
isClassifierDenial,
PLAN_REJECTION_PREFIX,
REJECT_MESSAGE_WITH_REASON_PREFIX,
} from '../../../utils/messages.js'
import { FallbackToolUseErrorMessage } from '../../FallbackToolUseErrorMessage.js'
import { InterruptedByUser } from '../../InterruptedByUser.js'
import { MessageResponse } from '../../MessageResponse.js'
import { RejectedPlanMessage } from './RejectedPlanMessage.js'
import { RejectedToolUseMessage } from './RejectedToolUseMessage.js'
} from '../../../utils/messages.js';
import { FallbackToolUseErrorMessage } from '../../FallbackToolUseErrorMessage.js';
import { InterruptedByUser } from '../../InterruptedByUser.js';
import { MessageResponse } from '../../MessageResponse.js';
import { RejectedPlanMessage } from './RejectedPlanMessage.js';
import { RejectedToolUseMessage } from './RejectedToolUseMessage.js';
type Props = {
progressMessagesForMessage: ProgressMessage[]
tool?: Tool // undefined when resuming an old conversation that uses an old tool
tools: Tools
param: ToolResultBlockParam
verbose: boolean
isTranscriptMode?: boolean
}
progressMessagesForMessage: ProgressMessage[];
tool?: Tool; // undefined when resuming an old conversation that uses an old tool
tools: Tools;
param: ToolResultBlockParam;
verbose: boolean;
isTranscriptMode?: boolean;
};
export function UserToolErrorMessage({
progressMessagesForMessage,
@@ -38,58 +34,38 @@ export function UserToolErrorMessage({
verbose,
isTranscriptMode,
}: Props): React.ReactNode {
if (
typeof param.content === 'string' &&
param.content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE)
) {
if (typeof param.content === 'string' && param.content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE)) {
return (
<MessageResponse height={1}>
<InterruptedByUser />
</MessageResponse>
)
);
}
if (
typeof param.content === 'string' &&
param.content.startsWith(PLAN_REJECTION_PREFIX)
) {
if (typeof param.content === 'string' && param.content.startsWith(PLAN_REJECTION_PREFIX)) {
// Extract the plan content from the error message
const planContent = param.content.substring(PLAN_REJECTION_PREFIX.length)
return <RejectedPlanMessage plan={planContent} />
const planContent = param.content.substring(PLAN_REJECTION_PREFIX.length);
return <RejectedPlanMessage plan={planContent} />;
}
if (
typeof param.content === 'string' &&
param.content.startsWith(REJECT_MESSAGE_WITH_REASON_PREFIX)
) {
return <RejectedToolUseMessage />
if (typeof param.content === 'string' && param.content.startsWith(REJECT_MESSAGE_WITH_REASON_PREFIX)) {
return <RejectedToolUseMessage />;
}
if (
feature('TRANSCRIPT_CLASSIFIER') &&
typeof param.content === 'string' &&
isClassifierDenial(param.content)
) {
if (feature('TRANSCRIPT_CLASSIFIER') && typeof param.content === 'string' && isClassifierDenial(param.content)) {
return (
<MessageResponse height={1}>
<Text dimColor>
Denied by auto mode classifier {BULLET_OPERATOR} /feedback if
incorrect
</Text>
<Text dimColor>Denied by auto mode classifier {BULLET_OPERATOR} /feedback if incorrect</Text>
</MessageResponse>
)
);
}
return (
tool?.renderToolUseErrorMessage?.(param.content, {
progressMessagesForMessage: filterToolProgressMessages(
progressMessagesForMessage,
),
progressMessagesForMessage: filterToolProgressMessages(progressMessagesForMessage),
tools,
verbose,
isTranscriptMode,
}) ?? (
<FallbackToolUseErrorMessage result={param.content} verbose={verbose} />
)
)
}) ?? <FallbackToolUseErrorMessage result={param.content} verbose={verbose} />
);
}

View File

@@ -1,25 +1,21 @@
import * as React from 'react'
import { useTerminalSize } from '../../../hooks/useTerminalSize.js'
import { useTheme } from '@anthropic/ink'
import {
filterToolProgressMessages,
type Tool,
type Tools,
} from '../../../Tool.js'
import type { ProgressMessage } from '../../../types/message.js'
import type { buildMessageLookups } from '../../../utils/messages.js'
import { FallbackToolUseRejectedMessage } from '../../FallbackToolUseRejectedMessage.js'
import * as React from 'react';
import { useTerminalSize } from '../../../hooks/useTerminalSize.js';
import { useTheme } from '@anthropic/ink';
import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js';
import type { ProgressMessage } from '../../../types/message.js';
import type { buildMessageLookups } from '../../../utils/messages.js';
import { FallbackToolUseRejectedMessage } from '../../FallbackToolUseRejectedMessage.js';
type Props = {
input: { [key: string]: unknown }
progressMessagesForMessage: ProgressMessage[]
style?: 'condensed'
tool?: Tool
tools: Tools
lookups: ReturnType<typeof buildMessageLookups>
verbose: boolean
isTranscriptMode?: boolean
}
input: { [key: string]: unknown };
progressMessagesForMessage: ProgressMessage[];
style?: 'condensed';
tool?: Tool;
tools: Tools;
lookups: ReturnType<typeof buildMessageLookups>;
verbose: boolean;
isTranscriptMode?: boolean;
};
export function UserToolRejectMessage({
input,
@@ -30,16 +26,16 @@ export function UserToolRejectMessage({
verbose,
isTranscriptMode,
}: Props): React.ReactNode {
const { columns } = useTerminalSize()
const [theme] = useTheme()
const { columns } = useTerminalSize();
const [theme] = useTheme();
if (!tool || !tool.renderToolUseRejectedMessage) {
return <FallbackToolUseRejectedMessage />
return <FallbackToolUseRejectedMessage />;
}
const parsedInput = tool.inputSchema.safeParse(input)
const parsedInput = tool.inputSchema.safeParse(input);
if (!parsedInput.success) {
return <FallbackToolUseRejectedMessage />
return <FallbackToolUseRejectedMessage />;
}
return (
@@ -48,12 +44,10 @@ export function UserToolRejectMessage({
messages: [],
tools,
verbose,
progressMessagesForMessage: filterToolProgressMessages(
progressMessagesForMessage,
),
progressMessagesForMessage: filterToolProgressMessages(progressMessagesForMessage),
style,
theme,
isTranscriptMode,
}) ?? <FallbackToolUseRejectedMessage />
)
);
}

View File

@@ -1,34 +1,31 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import type { Tools } from '../../../Tool.js'
import type {
NormalizedUserMessage,
ProgressMessage,
} from '../../../types/message.js'
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import type { Tools } from '../../../Tool.js';
import type { NormalizedUserMessage, ProgressMessage } from '../../../types/message.js';
import {
type buildMessageLookups,
CANCEL_MESSAGE,
INTERRUPT_MESSAGE_FOR_TOOL_USE,
REJECT_MESSAGE,
} from '../../../utils/messages.js'
import { UserToolCanceledMessage } from './UserToolCanceledMessage.js'
import { UserToolErrorMessage } from './UserToolErrorMessage.js'
import { UserToolRejectMessage } from './UserToolRejectMessage.js'
import { UserToolSuccessMessage } from './UserToolSuccessMessage.js'
import { useGetToolFromMessages } from './utils.js'
} from '../../../utils/messages.js';
import { UserToolCanceledMessage } from './UserToolCanceledMessage.js';
import { UserToolErrorMessage } from './UserToolErrorMessage.js';
import { UserToolRejectMessage } from './UserToolRejectMessage.js';
import { UserToolSuccessMessage } from './UserToolSuccessMessage.js';
import { useGetToolFromMessages } from './utils.js';
type Props = {
param: ToolResultBlockParam
message: NormalizedUserMessage
lookups: ReturnType<typeof buildMessageLookups>
progressMessagesForMessage: ProgressMessage[]
style?: 'condensed'
tools: Tools
verbose: boolean
width: number | string
isTranscriptMode?: boolean
shouldCollapseDiffs?: boolean
}
param: ToolResultBlockParam;
message: NormalizedUserMessage;
lookups: ReturnType<typeof buildMessageLookups>;
progressMessagesForMessage: ProgressMessage[];
style?: 'condensed';
tools: Tools;
verbose: boolean;
width: number | string;
isTranscriptMode?: boolean;
shouldCollapseDiffs?: boolean;
};
export function UserToolResultMessage({
param,
@@ -42,21 +39,17 @@ export function UserToolResultMessage({
isTranscriptMode,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups)
const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups);
if (!toolUse) {
return null
return null;
}
if (typeof param.content === 'string' && param.content.startsWith(CANCEL_MESSAGE)) {
return <UserToolCanceledMessage />;
}
if (
typeof param.content === 'string' &&
param.content.startsWith(CANCEL_MESSAGE)
) {
return <UserToolCanceledMessage />
}
if (
(typeof param.content === 'string' &&
param.content.startsWith(REJECT_MESSAGE)) ||
(typeof param.content === 'string' && param.content.startsWith(REJECT_MESSAGE)) ||
param.content === INTERRUPT_MESSAGE_FOR_TOOL_USE
) {
return (
@@ -70,7 +63,7 @@ export function UserToolResultMessage({
verbose={verbose}
isTranscriptMode={isTranscriptMode}
/>
)
);
}
if (param.is_error) {
@@ -83,7 +76,7 @@ export function UserToolResultMessage({
verbose={verbose}
isTranscriptMode={isTranscriptMode}
/>
)
);
}
return (
@@ -100,5 +93,5 @@ export function UserToolResultMessage({
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
);
}

View File

@@ -1,40 +1,33 @@
import { feature } from 'bun:bundle'
import figures from 'figures'
import * as React from 'react'
import { SentryErrorBoundary } from 'src/components/SentryErrorBoundary.js'
import { Box, Text, useTheme } from '@anthropic/ink'
import { useAppState } from '../../../state/AppState.js'
import {
filterToolProgressMessages,
type Tool,
type Tools,
} from '../../../Tool.js'
import type {
NormalizedUserMessage,
ProgressMessage,
} from '../../../types/message.js'
import { feature } from 'bun:bundle';
import figures from 'figures';
import * as React from 'react';
import { SentryErrorBoundary } from 'src/components/SentryErrorBoundary.js';
import { Box, Text, useTheme } from '@anthropic/ink';
import { useAppState } from '../../../state/AppState.js';
import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js';
import type { NormalizedUserMessage, ProgressMessage } from '../../../types/message.js';
import {
deleteClassifierApproval,
getClassifierApproval,
getYoloClassifierApproval,
} from '../../../utils/classifierApprovals.js'
import type { buildMessageLookups } from '../../../utils/messages.js'
import { MessageResponse } from '../../MessageResponse.js'
import { HookProgressMessage } from '../HookProgressMessage.js'
} from '../../../utils/classifierApprovals.js';
import type { buildMessageLookups } from '../../../utils/messages.js';
import { MessageResponse } from '../../MessageResponse.js';
import { HookProgressMessage } from '../HookProgressMessage.js';
type Props = {
message: NormalizedUserMessage
lookups: ReturnType<typeof buildMessageLookups>
toolUseID: string
progressMessagesForMessage: ProgressMessage[]
style?: 'condensed'
tool?: Tool
tools: Tools
verbose: boolean
width: number | string
isTranscriptMode?: boolean
shouldCollapseDiffs?: boolean
}
message: NormalizedUserMessage;
lookups: ReturnType<typeof buildMessageLookups>;
toolUseID: string;
progressMessagesForMessage: ProgressMessage[];
style?: 'condensed';
tool?: Tool;
tools: Tools;
verbose: boolean;
width: number | string;
isTranscriptMode?: boolean;
shouldCollapseDiffs?: boolean;
};
export function UserToolSuccessMessage({
message,
@@ -49,78 +42,62 @@ export function UserToolSuccessMessage({
isTranscriptMode,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const [theme] = useTheme()
const [theme] = useTheme();
// Hook stays inside feature() ternary so external builds don't pay a
// per-scrollback-message store subscription — same pattern as
// UserPromptMessage.tsx.
const isBriefOnly =
feature('KAIROS') || feature('KAIROS_BRIEF')
?
useAppState(s => s.isBriefOnly)
: false
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
// Capture classifier approval once on mount, then delete from Map to prevent linear growth.
// useState lazy initializer ensures the value persists across re-renders.
const [classifierRule] = React.useState(() =>
getClassifierApproval(toolUseID),
)
const [yoloReason] = React.useState(() =>
getYoloClassifierApproval(toolUseID),
)
const [classifierRule] = React.useState(() => getClassifierApproval(toolUseID));
const [yoloReason] = React.useState(() => getYoloClassifierApproval(toolUseID));
React.useEffect(() => {
deleteClassifierApproval(toolUseID)
}, [toolUseID])
deleteClassifierApproval(toolUseID);
}, [toolUseID]);
if (!message.toolUseResult || !tool) {
return null
return null;
}
// Resumed transcripts deserialize toolUseResult via raw JSON.parse with no
// validation (parseJSONL). A partial/corrupt/old-format result crashes
// renderToolResultMessage on first field access (anthropics/claude-code#39817).
// Validate against outputSchema before rendering — mirrors CollapsedReadSearchContent.
const parsedOutput = tool.outputSchema?.safeParse(message.toolUseResult)
const parsedOutput = tool.outputSchema?.safeParse(message.toolUseResult);
if (parsedOutput && !parsedOutput.success) {
return null
return null;
}
const toolResult = parsedOutput?.data ?? message.toolUseResult
const toolResult = parsedOutput?.data ?? message.toolUseResult;
// Collapse diff display for old messages (verbose/ctrl+o overrides)
const effectiveStyle =
shouldCollapseDiffs && !verbose ? 'condensed' : style
const effectiveStyle = shouldCollapseDiffs && !verbose ? 'condensed' : style;
const renderedMessage =
tool.renderToolResultMessage?.(
toolResult as never,
filterToolProgressMessages(progressMessagesForMessage),
{
style: effectiveStyle,
theme,
tools,
verbose,
isTranscriptMode,
isBriefOnly,
input: lookups.toolUseByToolUseID.get(toolUseID)?.input,
},
) ?? null
tool.renderToolResultMessage?.(toolResult as never, filterToolProgressMessages(progressMessagesForMessage), {
style: effectiveStyle,
theme,
tools,
verbose,
isTranscriptMode,
isBriefOnly,
input: lookups.toolUseByToolUseID.get(toolUseID)?.input,
}) ?? null;
// Don't render anything if the tool result message is null
if (renderedMessage === null) {
return null
return null;
}
// Tools that return '' from userFacingName opt out of tool chrome and
// render like plain assistant text. Skip the tool-result width constraint
// so MarkdownTable's SAFETY_MARGIN=4 (tuned for the assistant-text 2-col
// dot gutter) holds — otherwise tables wrap their box-drawing chars.
const rendersAsAssistantText = tool.userFacingName(undefined) === ''
const rendersAsAssistantText = tool.userFacingName(undefined) === '';
return (
<Box flexDirection="column">
<Box
flexDirection="column"
width={rendersAsAssistantText ? undefined : width}
>
<Box flexDirection="column" width={rendersAsAssistantText ? undefined : width}>
{renderedMessage}
{feature('BASH_CLASSIFIER')
? classifierRule && (
@@ -151,5 +128,5 @@ export function UserToolSuccessMessage({
/>
</SentryErrorBoundary>
</Box>
)
);
}

View File

@@ -1,7 +1,7 @@
import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import { useMemo } from 'react'
import { findToolByName, type Tool, type Tools } from '../../../Tool.js'
import type { buildMessageLookups } from '../../../utils/messages.js'
import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import { useMemo } from 'react';
import { findToolByName, type Tool, type Tools } from '../../../Tool.js';
import type { buildMessageLookups } from '../../../utils/messages.js';
export function useGetToolFromMessages(
toolUseID: string,
@@ -9,14 +9,14 @@ export function useGetToolFromMessages(
lookups: ReturnType<typeof buildMessageLookups>,
): { tool: Tool; toolUse: ToolUseBlockParam } | null {
return useMemo(() => {
const toolUse = lookups.toolUseByToolUseID.get(toolUseID)
const toolUse = lookups.toolUseByToolUseID.get(toolUseID);
if (!toolUse) {
return null
return null;
}
const tool = findToolByName(tools, toolUse.name)
const tool = findToolByName(tools, toolUse.name);
if (!tool) {
return null
return null;
}
return { tool, toolUse }
}, [toolUseID, lookups, tools])
return { tool, toolUse };
}, [toolUseID, lookups, tools]);
}

View File

@@ -63,6 +63,8 @@ export function isNullRenderingAttachment(
): boolean {
return (
msg.type === 'attachment' &&
NULL_RENDERING_ATTACHMENT_TYPES.has(msg.attachment!.type as Attachment['type'])
NULL_RENDERING_ATTACHMENT_TYPES.has(
msg.attachment!.type as Attachment['type'],
)
)
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { Text } from '@anthropic/ink'
import type { CollapsedReadSearchGroup } from '../../types/message.js'
import React from 'react';
import { Text } from '@anthropic/ink';
import type { CollapsedReadSearchGroup } from '../../types/message.js';
/**
* Plain function (not a React component) so the React Compiler won't
@@ -12,7 +12,7 @@ export function checkHasTeamMemOps(message: CollapsedReadSearchGroup): boolean {
(message.teamMemorySearchCount ?? 0) > 0 ||
(message.teamMemoryReadCount ?? 0) > 0 ||
(message.teamMemoryWriteCount ?? 0) > 0
)
);
}
/**
@@ -25,74 +25,54 @@ export function TeamMemCountParts({
isActiveGroup,
hasPrecedingParts,
}: {
message: CollapsedReadSearchGroup
isActiveGroup: boolean | undefined
hasPrecedingParts: boolean
message: CollapsedReadSearchGroup;
isActiveGroup: boolean | undefined;
hasPrecedingParts: boolean;
}): React.ReactNode {
const tmReadCount = message.teamMemoryReadCount ?? 0
const tmSearchCount = message.teamMemorySearchCount ?? 0
const tmWriteCount = message.teamMemoryWriteCount ?? 0
const tmReadCount = message.teamMemoryReadCount ?? 0;
const tmSearchCount = message.teamMemorySearchCount ?? 0;
const tmWriteCount = message.teamMemoryWriteCount ?? 0;
if (tmReadCount === 0 && tmSearchCount === 0 && tmWriteCount === 0) {
return null
return null;
}
const nodes: React.ReactNode[] = []
let count = hasPrecedingParts ? 1 : 0
const nodes: React.ReactNode[] = [];
let count = hasPrecedingParts ? 1 : 0;
if (tmReadCount > 0) {
const verb = isActiveGroup
? count === 0
? 'Recalling'
: 'recalling'
: count === 0
? 'Recalled'
: 'recalled'
const verb = isActiveGroup ? (count === 0 ? 'Recalling' : 'recalling') : count === 0 ? 'Recalled' : 'recalled';
if (count > 0) {
nodes.push(<Text key="comma-tmr">, </Text>)
nodes.push(<Text key="comma-tmr">, </Text>);
}
nodes.push(
<Text key="team-mem-read">
{verb} <Text bold>{tmReadCount}</Text> team{' '}
{tmReadCount === 1 ? 'memory' : 'memories'}
{verb} <Text bold>{tmReadCount}</Text> team {tmReadCount === 1 ? 'memory' : 'memories'}
</Text>,
)
count++
);
count++;
}
if (tmSearchCount > 0) {
const verb = isActiveGroup
? count === 0
? 'Searching'
: 'searching'
: count === 0
? 'Searched'
: 'searched'
const verb = isActiveGroup ? (count === 0 ? 'Searching' : 'searching') : count === 0 ? 'Searched' : 'searched';
if (count > 0) {
nodes.push(<Text key="comma-tms">, </Text>)
nodes.push(<Text key="comma-tms">, </Text>);
}
nodes.push(<Text key="team-mem-search">{`${verb} team memories`}</Text>)
count++
nodes.push(<Text key="team-mem-search">{`${verb} team memories`}</Text>);
count++;
}
if (tmWriteCount > 0) {
const verb = isActiveGroup
? count === 0
? 'Writing'
: 'writing'
: count === 0
? 'Wrote'
: 'wrote'
const verb = isActiveGroup ? (count === 0 ? 'Writing' : 'writing') : count === 0 ? 'Wrote' : 'wrote';
if (count > 0) {
nodes.push(<Text key="comma-tmw">, </Text>)
nodes.push(<Text key="comma-tmw">, </Text>);
}
nodes.push(
<Text key="team-mem-write">
{verb} <Text bold>{tmWriteCount}</Text> team{' '}
{tmWriteCount === 1 ? 'memory' : 'memories'}
{verb} <Text bold>{tmWriteCount}</Text> team {tmWriteCount === 1 ? 'memory' : 'memories'}
</Text>,
)
);
}
return <>{nodes}</>
return <>{nodes}</>;
}