diff --git a/src/commands/clear/caches.ts b/src/commands/clear/caches.ts index 7f06c8a5da..00e89cb235 100644 --- a/src/commands/clear/caches.ts +++ b/src/commands/clear/caches.ts @@ -29,6 +29,7 @@ import { clearCommandPrefixCaches } from '../../utils/bash/commands.js' import { resetGetMemoryFilesCache } from '../../utils/claudemd.js' import { clearRepositoryCaches } from '../../utils/detectRepository.js' import { clearResolveGitDirCache } from '../../utils/git/gitFilesystem.js' +import { clearRenderCache } from '../../utils/hooks/renderCache.js' import { clearStoredImagePaths } from '../../utils/imageStore.js' import { clearSessionEnvVars } from '../../utils/sessionEnvVars.js' @@ -86,6 +87,9 @@ export function clearSessionCaches( // Clear stored image paths cache clearStoredImagePaths() + // 清空 AssistantRender 显示层渲染缓存与重绘纪元(/clear、resume 时全部失效) + clearRenderCache() + // Clear all session ingress caches (lastUuidMap, sequentialAppendBySession) clearAllSessions() // Clear swarm permission pending callbacks diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx index 49774ae491..0b16a5248f 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -59,6 +59,8 @@ export type Props = { onOpenRateLimitOptions?: () => void; lastThinkingBlockId: string | null; latestBashOutputUUID: string | null; + /** AssistantRender 渲染缓存版本号(由 Messages.tsx 下发,变化时强制重绘) */ + renderCacheVersion: number; columns: number; isLoading: boolean; lookups: ReturnType; @@ -310,6 +312,11 @@ export function areMessageRowPropsEqual(prev: Props, next: Props): boolean { const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid; if (prevIsLatestBash !== nextIsLatestBash) return false; + // AssistantRender 渲染缓存版本变化(hook 命中或清除)时强制重绘。 + // 注意不能用 getMessageRenderCacheState(prev/next.message) 对比: + // prev/next 持同一 message 引用,两次查询拿到的是同一份实时缓存态,恒等。 + if (prev.renderCacheVersion !== next.renderCacheVersion) return false; + // lastThinkingBlockId affects thinking block visibility — but only for // messages that HAVE thinking content. Checking unconditionally busts the // memo for every scrollback message whenever thinking starts/stops (CC-941). diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index 6c5af9fab3..e09a88df47 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -56,6 +56,7 @@ import { Divider } from '@anthropic/ink'; import type { UnseenDivider } from './FullscreenLayout.js'; import { LogoV2 } from './LogoV2/LogoV2.js'; import { StreamingMarkdown } from './Markdown.js'; +import { getRenderCacheVersion, getRepaintEpoch } from '../utils/hooks/renderCache.js'; import { hasContentAfterIndex, MessageRow } from './MessageRow.js'; import { InVirtualListContext, @@ -781,7 +782,15 @@ const MessagesImpl = ({ return () => progress(null); }, [progress]); - const messageKey = useCallback((msg: RenderableMessage) => `${msg.uuid}-${conversationId}`, [conversationId]); + // AssistantRender 重绘纪元并入行 key:命中的行 key 变化 → 重挂载, + // 绕过 React.memo 与 OffscreenFreeze(静态定格行对 prop 变化免疫) + const messageKey = useCallback( + (msg: RenderableMessage) => { + const epoch = getRepaintEpoch(msg.uuid); + return epoch > 0 ? `${msg.uuid}-${conversationId}-r${epoch}` : `${msg.uuid}-${conversationId}`; + }, + [conversationId], + ); const renderMessageRow = (msg: RenderableMessage, index: number) => { const prevType = index > 0 ? renderableMessages[index - 1]?.type : undefined; @@ -817,6 +826,7 @@ const MessagesImpl = ({ onOpenRateLimitOptions={onOpenRateLimitOptions} lastThinkingBlockId={lastThinkingBlockId} latestBashOutputUUID={latestBashOutputUUID} + renderCacheVersion={getRenderCacheVersion()} columns={columns} isLoading={isLoading} lookups={lookups} diff --git a/src/components/__tests__/MessageRowRenderCache.test.tsx b/src/components/__tests__/MessageRowRenderCache.test.tsx new file mode 100644 index 0000000000..0f95d51c96 --- /dev/null +++ b/src/components/__tests__/MessageRowRenderCache.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; +import { randomUUID } from 'crypto'; +import type { RenderableMessage } from 'src/types/message.js'; +import { areMessageRowPropsEqual } from 'src/components/MessageRow.js'; +import { clearRenderCache, getRenderCacheVersion, setMessageRenderCache } from 'src/utils/hooks/renderCache.js'; + +function assistantMessage(text: string): RenderableMessage { + return { + type: 'assistant', + uuid: randomUUID(), + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + } as unknown as RenderableMessage; +} + +// 最小 Props:比较器只消费其中少数字段,其余以空值填充 +function makeProps(message: RenderableMessage, renderCacheVersion = 0) { + return { + message, + isUserContinuation: false, + hasContentAfter: false, + tools: [], + commands: [], + verbose: false, + inProgressToolUseIDs: new Set(), + streamingToolUseIDs: new Set(), + screen: 'main', + canAnimate: false, + lastThinkingBlockId: null, + latestBashOutputUUID: null, + renderCacheVersion, + columns: 80, + isLoading: false, + lookups: { resolvedToolUseIDs: new Set() }, + } as unknown as Parameters[0]; +} + +describe('areMessageRowPropsEqual (AssistantRender cache version)', () => { + test('renderCacheVersion bump forces re-render', () => { + clearRenderCache(); + const message = assistantMessage('render-target'); + const prev = makeProps(message, getRenderCacheVersion()); + expect(areMessageRowPropsEqual(prev, prev)).toBe(true); + setMessageRenderCache('render-target', 'ASCII'); + const next = makeProps(message, getRenderCacheVersion()); + expect(areMessageRowPropsEqual(prev, next)).toBe(false); + }); + + test('same renderCacheVersion with static message bails out', () => { + clearRenderCache(); + setMessageRenderCache('stable', 'ASCII'); + const version = getRenderCacheVersion(); + const message = assistantMessage('stable'); + const prev = makeProps(message, version); + const next = makeProps(message, version); + expect(areMessageRowPropsEqual(prev, next)).toBe(true); + }); +}); diff --git a/src/components/messages/AssistantTextMessage.tsx b/src/components/messages/AssistantTextMessage.tsx index bed210b674..c4aee8d29a 100644 --- a/src/components/messages/AssistantTextMessage.tsx +++ b/src/components/messages/AssistantTextMessage.tsx @@ -18,6 +18,7 @@ import { TOKEN_REVOKED_ERROR_MESSAGE, } from '../../services/api/errors.js'; import { isEmptyMessageText, NO_RESPONSE_REQUESTED } from '../../utils/messages.js'; +import { getCachedRenderedText } from '../../utils/hooks/renderCache.js'; import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js'; import { getDefaultSonnetModel, renderModelName } from '../../utils/model/model.js'; import { isMacOsKeychainLocked } from '../../utils/secureStorage/macOsKeychainStorage.js'; @@ -30,6 +31,15 @@ import { RateLimitMessage } from './RateLimitMessage.js'; const MAX_API_ERROR_CHARS = 1000; +// 显示层降级:渲染缓存查取异常时回退原文 +function getRenderedText(text: string): string | undefined { + try { + return getCachedRenderedText(text); + } catch { + return undefined; + } +} + type Props = { param: TextBlockParam; addMargin: boolean; @@ -60,6 +70,8 @@ export function AssistantTextMessage({ onOpenRateLimitOptions, }: Props): React.ReactNode { const isSelected = useContext(MessageActionsSelectedContext); + // AssistantRender 缓存命中时用 hook 重渲染文本替换原文显示 + const renderedText = getRenderedText(text); if (isEmptyMessageText(text)) { return null; } @@ -187,7 +199,7 @@ export function AssistantTextMessage({ )} - {text} + {renderedText ?? text} diff --git a/src/components/messages/nullRenderingAttachments.ts b/src/components/messages/nullRenderingAttachments.ts index b463b1a23d..4d2eaff711 100644 --- a/src/components/messages/nullRenderingAttachments.ts +++ b/src/components/messages/nullRenderingAttachments.ts @@ -14,6 +14,7 @@ import type { Message, NormalizedMessage } from '../../types/message.js' const NULL_RENDERING_TYPES = [ 'hook_success', 'hook_additional_context', + 'hook_output_rendered', 'hook_cancelled', 'command_permissions', 'agent_mention', diff --git a/src/entrypoints/agentSdkTypes.js b/src/entrypoints/agentSdkTypes.js index dbb2da1735..8ce3b8afce 100644 --- a/src/entrypoints/agentSdkTypes.js +++ b/src/entrypoints/agentSdkTypes.js @@ -29,6 +29,7 @@ export const HOOK_EVENTS = [ 'InstructionsLoaded', 'CwdChanged', 'FileChanged', + 'AssistantRender', ] export const EXIT_REASONS = [ diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index 0cf484fd97..8b5b36b15b 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -387,6 +387,7 @@ export const HOOK_EVENTS = [ 'InstructionsLoaded', 'CwdChanged', 'FileChanged', + 'AssistantRender', ] as const export const HookEventSchema = lazySchema(() => z.enum(HOOK_EVENTS)) @@ -771,6 +772,31 @@ export const SessionEndHookInputSchema = lazySchema(() => ), ) +// AssistantRender 事件输入:回合末 assistant 消息的非空 text 块原文 +export const AssistantRenderHookInputSchema = lazySchema(() => + BaseHookInputSchema().and( + z.object({ + hook_event_name: z.literal('AssistantRender'), + message_id: z + .string() + .optional() + .describe( + 'API message id (msg_...) of the assistant message being rendered', + ), + text_blocks: z + .array( + z.object({ + block_index: z.number().int(), + text: z.string(), + }), + ) + .describe( + 'Non-empty text content blocks of the assistant message, in order', + ), + }), + ), +) + export const HookInputSchema = lazySchema(() => z.union([ PreToolUseHookInputSchema(), @@ -800,6 +826,7 @@ export const HookInputSchema = lazySchema(() => WorktreeRemoveHookInputSchema(), CwdChangedHookInputSchema(), FileChangedHookInputSchema(), + AssistantRenderHookInputSchema(), ]), ) @@ -911,6 +938,28 @@ export const FileChangedHookSpecificOutputSchema = lazySchema(() => }), ) +// AssistantRender 事件输出:updatedBlocks 提供对应 text 块的显示层替换文本 +export const AssistantRenderHookSpecificOutputSchema = lazySchema(() => + z + .object({ + hookEventName: z.literal('AssistantRender'), + updatedBlocks: z + .array( + z.object({ + blockIndex: z.number().int(), + text: z.string(), + }), + ) + .optional() + .describe( + 'Re-rendered replacement text for the requested text blocks. Blocks not listed are displayed unchanged.', + ), + }) + .describe( + 'Hook-specific output for the AssistantRender event. Return this to replace the display-layer text of assistant message blocks; transcript and model context keep the original text.', + ), +) + export const SyncHookJSONOutputSchema = lazySchema(() => z.object({ continue: z.boolean().optional(), @@ -936,6 +985,7 @@ export const SyncHookJSONOutputSchema = lazySchema(() => CwdChangedHookSpecificOutputSchema(), FileChangedHookSpecificOutputSchema(), WorktreeCreateHookSpecificOutputSchema(), + AssistantRenderHookSpecificOutputSchema(), ]) .optional(), }), diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index 9d84226f30..80dbcb69a9 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -250,6 +250,11 @@ export type HookInput = file_path: string event: 'change' | 'add' | 'unlink' }) + | (HookInputBase & { + hook_event_name: 'AssistantRender' + message_id?: string + text_blocks: { block_index: number; text: string }[] + }) export type AsyncHookJSONOutput = { async: true @@ -315,6 +320,10 @@ export type SyncHookJSONOutput = { | { hookEventName: 'CwdChanged'; watchPaths?: string[] } | { hookEventName: 'FileChanged'; watchPaths?: string[] } | { hookEventName: 'WorktreeCreate'; worktreePath: string } + | { + hookEventName: 'AssistantRender' + updatedBlocks?: { blockIndex: number; text: string }[] + } } export type HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput @@ -330,6 +339,11 @@ export type SessionStartHookInput = HookInput export type SessionEndHookInput = HookInput & { exit_reason: string } export type SetupHookInput = HookInput export type StopHookInput = HookInput +export type AssistantRenderHookInput = HookInput & { + hook_event_name: 'AssistantRender' + message_id?: string + text_blocks: { block_index: number; text: string }[] +} export type StopFailureHookInput = HookInput export type SubagentStartHookInput = HookInput export type SubagentStopHookInput = HookInput diff --git a/src/entrypoints/sdk/coreTypes.ts b/src/entrypoints/sdk/coreTypes.ts index 28e9a0dc02..e8a2263daa 100644 --- a/src/entrypoints/sdk/coreTypes.ts +++ b/src/entrypoints/sdk/coreTypes.ts @@ -50,6 +50,7 @@ export const HOOK_EVENTS = [ 'InstructionsLoaded', 'CwdChanged', 'FileChanged', + 'AssistantRender', ] as const export const EXIT_REASONS = [ diff --git a/src/query.ts b/src/query.ts index 79d17b5793..665d7ed69b 100644 --- a/src/query.ts +++ b/src/query.ts @@ -4,6 +4,7 @@ import type { ToolUseBlock, } from '@anthropic-ai/sdk/resources/index.mjs' import type { CanUseToolFn } from './hooks/useCanUseTool.js' +import { randomUUID } from 'crypto' import { FallbackTriggeredError } from './services/api/withRetry.js' import { calculateTokenWarningState, @@ -101,7 +102,10 @@ import { ESCALATED_MAX_TOKENS } from './utils/context.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from './services/analytics/growthbook.js' import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool/prompt.js' import { executePostSamplingHooks } from './utils/hooks/postSamplingHooks.js' -import { executeStopFailureHooks } from './utils/hooks.js' +import { + executeAssistantRenderHooks, + executeStopFailureHooks, +} from './utils/hooks.js' import type { QuerySource } from './constants/querySource.js' import type { QueuedCommand } from './types/textInputTypes.js' import { createDumpPromptsFetch } from './services/api/dumpPrompts.js' @@ -1565,6 +1569,27 @@ async function* queryLoop( stopHookActive, ) + // AssistantRender:回合末对 assistant 输出做显示层重渲染(第 28 个 hook 事件)。 + // 必须排在 Stop hooks 之后:mermaid 类插件的 Stop 钩子负责把渲染产物落盘, + // 本事件随后读取产物回填显示缓存——放之前会同回合读不到产物,永远滞后一回合。 + // 命中时 yield 零渲染附件触发状态更新 → Ink 重绘 → 组件读渲染缓存就地替换。 + if (!toolUseContext.abortController.signal.aborted) { + const renderedBlocks = await executeAssistantRenderHooks( + assistantMessages, + toolUseContext, + ) + if (renderedBlocks > 0) { + yield createAttachmentMessage({ + type: 'hook_output_rendered', + message: `${renderedBlocks} text block(s) re-rendered`, + count: renderedBlocks, + hookName: 'AssistantRender', + toolUseID: randomUUID(), + hookEvent: 'AssistantRender', + }) + } + } + if (stopHookResult.preventContinuation) { return { reason: 'stop_hook_prevented' } } diff --git a/src/types/hooks.ts b/src/types/hooks.ts index d69d6d3b70..8aec19cf90 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -160,6 +160,18 @@ export const syncHookResponseSchema = lazySchema(() => hookEventName: z.literal('WorktreeCreate'), worktreePath: z.string(), }), + // AssistantRender:显示层重渲染 assistant text 块(transcript/模型上下文保留原文) + z.object({ + hookEventName: z.literal('AssistantRender'), + updatedBlocks: z + .array( + z.object({ + blockIndex: z.number().int(), + text: z.string(), + }), + ) + .optional(), + }), ]) .optional(), }), diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index 4cff94eb95..06a59f7ec2 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -378,6 +378,15 @@ export type HookAttachment = toolUseID: string hookEvent: HookEvent } + | { + // AssistantRender 命中后的重绘通知(正常视图零渲染,仅触发状态更新) + type: 'hook_output_rendered' + message: string + count: number + hookName: string + toolUseID: string + hookEvent: HookEvent + } | HookSystemMessageAttachment | HookPermissionDecisionAttachment diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 4e51ab67da..1f8341db80 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -97,6 +97,7 @@ import type { ConfigChangeHookInput, CwdChangedHookInput, FileChangedHookInput, + AssistantRenderHookInput, InstructionsLoadedHookInput, UserPromptSubmitHookInput, PermissionRequestHookInput, @@ -148,6 +149,10 @@ import { all } from './generators.js' import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js' import { execPromptHook } from './hooks/execPromptHook.js' import type { Message, AssistantMessage } from '../types/message.js' +import { + markMessageForRepaint, + setMessageRenderCache, +} from './hooks/renderCache.js' import { execAgentHook } from './hooks/execAgentHook.js' import { execHttpHook } from './hooks/execHttpHook.js' import type { ShellCommand } from './ShellCommand.js' @@ -165,6 +170,8 @@ import { isEnvTruthy } from './envUtils.js' import { errorMessage, getErrnoCode } from './errors.js' const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 +// AssistantRender 是显示路径钩子:回合末同步等待,超时上限收紧避免阻塞回合结束 +const ASSISTANT_RENDER_HOOK_TIMEOUT_MS = 30 * 1000 /** * SessionEnd hooks run during shutdown/clear and need a much tighter bound @@ -349,6 +356,7 @@ export interface HookResult { initialUserMessage?: string updatedInput?: Record updatedMCPToolOutput?: unknown + updatedBlocks?: { blockIndex: number; text: string }[] permissionRequestResult?: PermissionRequestResult elicitationResponse?: ElicitationResponse watchPaths?: string[] @@ -369,6 +377,7 @@ export type AggregatedHookResult = { initialUserMessage?: string // 会话启动等场景预置的首条用户侧文案,供首轮上下文使用 updatedInput?: Record // 钩子改写后的工具入参;可在 allow/ask 时与权限一起产出,也可单独改参 updatedMCPToolOutput?: unknown // PostToolUse 钩子对 MCP 工具原始输出的替换内容 + updatedBlocks?: { blockIndex: number; text: string }[] // AssistantRender 钩子对 assistant text 块的显示层替换文本 permissionRequestResult?: PermissionRequestResult // PermissionRequest 事件钩子的 allow/deny 及可选改参 watchPaths?: string[] // SessionStart 等声明的监视路径,供文件变更相关逻辑使用 elicitationResponse?: ElicitationResponse // Elicitation 钩子的交互/采集结果(MCP elicit 流程) @@ -564,6 +573,10 @@ interface TypedSyncHookOutput { hookEventName: 'WorktreeCreate' worktreePath: string } + | { + hookEventName: 'AssistantRender' + updatedBlocks?: { blockIndex: number; text: string }[] + } } function processHookJSONOutput({ @@ -734,6 +747,12 @@ function processHookJSONOutput({ case 'PostToolUseFailure': result.additionalContext = json.hookSpecificOutput.additionalContext break + case 'AssistantRender': + // 提取显示层替换文本(未返回 updatedBlocks 时块保持原文) + if (json.hookSpecificOutput.updatedBlocks) { + result.updatedBlocks = json.hookSpecificOutput.updatedBlocks + } + break case 'PermissionDenied': result.retry = json.hookSpecificOutput.retry break @@ -2954,6 +2973,16 @@ async function* executeHooks({ } } + // Yield updatedBlocks if provided (from AssistantRender hooks) + if (result.updatedBlocks) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) re-rendered ${result.updatedBlocks.length} text block(s)`, + ) + yield { + updatedBlocks: result.updatedBlocks, + } + } + // Check for permission behavior with precedence: deny > ask > allow if (result.permissionBehavior) { logForDebugging( @@ -4110,6 +4139,109 @@ export async function* executeSubagentStartHooks( }) } +/** + * Execute AssistantRender hooks(第 28 个 hook 事件)。 + * + * 回合末逐条 assistant 消息触发:把非空 text 块原文交给 hook 重渲染, + * 返回的 updatedBlocks 写入 renderCache 供显示层就地替换。 + * transform 只进缓存、不写回 messages,transcript 与模型上下文始终保留原文。 + * + * @returns 命中替换的 text 块数量(0 = 无变化,调用方可跳过重绘通知) + */ +export async function executeAssistantRenderHooks( + assistantMessages: AssistantMessage[], + toolUseContext?: ToolUseContext, + timeoutMs: number = ASSISTANT_RENDER_HOOK_TIMEOUT_MS, +): Promise { + if (shouldDisableAllHooksIncludingManaged()) return 0 + const appState = toolUseContext?.getAppState() + // 与 executeHooks 的会话口径保持一致(agentId 优先),避免门控与执行漂移 + const sessionId = toolUseContext?.agentId ?? getSessionId() + // 无 hook 注册时零开销短路(不构造输入、不执行匹配) + if (!hasHookForEvent('AssistantRender', appState, sessionId)) return 0 + // 单脚本语义:渲染不是管道——各 hook 拿到的都是原始 text_blocks,多个脚本只会 + // 重复渲染并以「后写覆盖」收场。仅允许一个有效 hook(同脚本跨 scope 重复注册 + // 已被 getMatchingHooks 去重合并,不触发本限制);检测到多个时记错误日志并整体不渲染。 + const probeInput: AssistantRenderHookInput = { + ...createBaseHookInput(undefined, undefined, toolUseContext), + hook_event_name: 'AssistantRender', + text_blocks: [], + } + const effectiveHookCount = ( + await getMatchingHooks(appState, sessionId, 'AssistantRender', probeInput) + ).length + if (effectiveHookCount > 1) { + // 多注册 = misconfig:整体不渲染,并向用户发出可见警告(debug 日志同步留痕) + const skipReason = `AssistantRender allows only one hook, but ${effectiveHookCount} are registered — skipping rendering entirely` + logForDebugging(skipReason, { level: 'error' }) + console.warn(chalk.yellow(`⚠️ ${skipReason}`)) + return 0 + } + if (effectiveHookCount === 0) return 0 + + let updatedCount = 0 + for (const message of assistantMessages) { + // API 错误消息没有可渲染价值 + if (message.isApiErrorMessage === true) continue + const content = message.message?.content + if (!Array.isArray(content)) continue + + // 提取非空 text 块(记录块索引,供 hook 按块回填) + const textBlocks: { blockIndex: number; text: string }[] = [] + content.forEach((block, blockIndex) => { + if ( + block.type === 'text' && + typeof block.text === 'string' && + block.text.trim().length > 0 + ) { + textBlocks.push({ blockIndex, text: block.text }) + } + }) + if (textBlocks.length === 0) continue + + const hookInput: AssistantRenderHookInput = { + ...createBaseHookInput(undefined, undefined, toolUseContext), + hook_event_name: 'AssistantRender', + message_id: message.message.id, + text_blocks: textBlocks.map(({ blockIndex, text }) => ({ + block_index: blockIndex, + text, + })), + } + + // 聚合各 hook 返回的 updatedBlocks:同块多 hook 时后返回者覆盖 + const updatedByIndex = new Map() + for await (const chunk of executeHooks({ + hookInput, + toolUseID: randomUUID(), + timeoutMs, + toolUseContext, + })) { + if (chunk.updatedBlocks) { + for (const updated of chunk.updatedBlocks) { + updatedByIndex.set(updated.blockIndex, updated.text) + } + } + } + + // 命中且内容有变化才写缓存并计数 + let messageUpdated = 0 + for (const { blockIndex, text } of textBlocks) { + const updated = updatedByIndex.get(blockIndex) + if (updated !== undefined && updated !== text) { + setMessageRenderCache(text, updated) + messageUpdated++ + } + } + if (messageUpdated > 0) { + // 标记重绘纪元:静态定格行(滚出视口被冻结)靠行 key 变化重挂载强制重印 + markMessageForRepaint(message.uuid) + updatedCount += messageUpdated + } + } + return updatedCount +} + /** * Execute pre-compact hooks if configured * @param compactData The compact data to pass to hooks diff --git a/src/utils/hooks/__tests__/assistantRenderHooks.test.ts b/src/utils/hooks/__tests__/assistantRenderHooks.test.ts new file mode 100644 index 0000000000..34acac8880 --- /dev/null +++ b/src/utils/hooks/__tests__/assistantRenderHooks.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from 'bun:test' +import { randomUUID } from 'crypto' +import { getSessionId } from 'src/bootstrap/state.js' +import type { AppState } from 'src/state/AppState.js' +import type { AssistantMessage } from 'src/types/message.js' +import type { HookCommand } from 'src/utils/settings/types.js' +import { + clearRenderCache, + getCachedRenderedText, +} from 'src/utils/hooks/renderCache.js' +import { + executeAssistantRenderHooks, + shouldSkipHookDueToTrust, +} from 'src/utils/hooks.js' +import { addSessionHook } from 'src/utils/hooks/sessionHooks.js' + +// 全家桶运行时,先加载的测试文件会进程级 mock config.ts / bootstrap/state.js +// (bun mock.module 为 last-write-wins),把信任检查打成 false,使 spawn 类用例 +// 被 shouldSkipHookDueToTrust 短路。这里以同一个信任门状态作金丝雀: +// 被污染 → 跳过 spawn 用例(隔离运行 `bun test <本文件>` 时全量执行)。 +const testWithSpawn = test.skipIf(shouldSkipHookDueToTrust()) + +// 会话级 command hook 注册(进程内 appState,不落盘,测试结束即弃) +function makeAppState(): { + appState: AppState + setAppState: (updater: (prev: AppState) => AppState) => void +} { + let state = { sessionHooks: new Map() } as unknown as AppState + const setAppState = (updater: (prev: AppState) => AppState) => { + state = updater(state) + } + return { appState: state, setAppState } +} + +function assistantMessage(blocks: unknown[]): AssistantMessage { + return { + type: 'assistant', + uuid: randomUUID(), + message: { + role: 'assistant', + content: blocks, + } as AssistantMessage['message'], + } +} + +function makeToolUseContext(appState: AppState) { + return { + getAppState: () => appState, + } as unknown as Parameters[1] +} + +// 固定输出 AssistantRender 协议 JSON 的 command hook(stdin 用不上,直接 echo) +function echoRenderHook(blockIndex: number, text: string): HookCommand { + return { + type: 'command', + command: `echo '{"continue":true,"hookSpecificOutput":{"hookEventName":"AssistantRender","updatedBlocks":[{"blockIndex":${blockIndex},"text":${JSON.stringify(text)}}]}}'`, + } +} + +// 不返回 updatedBlocks 的 command hook(验证原文保留路径) +function plainEchoHook(): HookCommand { + return { type: 'command', command: `echo '{"continue":true}'` } +} + +describe('executeAssistantRenderHooks', () => { + test('no hooks registered → returns 0, cache untouched', async () => { + clearRenderCache() + const { appState } = makeAppState() + const messages = [assistantMessage([{ type: 'text', text: 'plain' }])] + const count = await executeAssistantRenderHooks( + messages, + makeToolUseContext(appState), + ) + expect(count).toBe(0) + expect(getCachedRenderedText('plain')).toBeUndefined() + }) + + test('message without text blocks is skipped', async () => { + clearRenderCache() + const { appState, setAppState } = makeAppState() + addSessionHook( + setAppState, + getSessionId(), + 'AssistantRender', + '', + echoRenderHook(0, 'SHOULD-NOT-BE-USED'), + ) + const messages = [ + assistantMessage([ + { type: 'tool_use', id: 't1', name: 'Bash', input: {} }, + ]), + ] + const count = await executeAssistantRenderHooks( + messages, + makeToolUseContext(appState), + ) + expect(count).toBe(0) + }) + + testWithSpawn('hook updatedBlocks → cache write + count', async () => { + clearRenderCache() + const { appState, setAppState } = makeAppState() + addSessionHook( + setAppState, + getSessionId(), + 'AssistantRender', + '', + echoRenderHook(0, 'RENDERED-ASCII'), + ) + const messages = [assistantMessage([{ type: 'text', text: 'raw source' }])] + const count = await executeAssistantRenderHooks( + messages, + makeToolUseContext(appState), + ) + expect(count).toBe(1) + expect(getCachedRenderedText('raw source')).toBe('RENDERED-ASCII') + }) + + test('multiple distinct hooks registered → rendering skipped entirely', async () => { + clearRenderCache() + const { appState, setAppState } = makeAppState() + addSessionHook( + setAppState, + getSessionId(), + 'AssistantRender', + '', + echoRenderHook(0, 'SHOULD-NOT-RUN-A'), + ) + addSessionHook( + setAppState, + getSessionId(), + 'AssistantRender', + '', + echoRenderHook(0, 'SHOULD-NOT-RUN-B'), + ) + const messages = [assistantMessage([{ type: 'text', text: 'multi' }])] + const count = await executeAssistantRenderHooks( + messages, + makeToolUseContext(appState), + ) + expect(count).toBe(0) + expect(getCachedRenderedText('multi')).toBeUndefined() + }) + + testWithSpawn('hook without updatedBlocks → original preserved', async () => { + clearRenderCache() + const { appState, setAppState } = makeAppState() + addSessionHook( + setAppState, + getSessionId(), + 'AssistantRender', + '', + plainEchoHook(), + ) + const messages = [assistantMessage([{ type: 'text', text: 'keep me' }])] + const count = await executeAssistantRenderHooks( + messages, + makeToolUseContext(appState), + ) + expect(count).toBe(0) + expect(getCachedRenderedText('keep me')).toBeUndefined() + }) + + testWithSpawn( + 'multi-block partial replacement only counts changed blocks', + async () => { + clearRenderCache() + const { appState, setAppState } = makeAppState() + // 只回填 blockIndex 1 + addSessionHook( + setAppState, + getSessionId(), + 'AssistantRender', + '', + echoRenderHook(1, 'SECOND-BLOCK-ASCII'), + ) + const messages = [ + assistantMessage([ + { type: 'text', text: 'first block' }, + { type: 'text', text: 'second block' }, + ]), + ] + const count = await executeAssistantRenderHooks( + messages, + makeToolUseContext(appState), + ) + expect(count).toBe(1) + expect(getCachedRenderedText('first block')).toBeUndefined() + expect(getCachedRenderedText('second block')).toBe('SECOND-BLOCK-ASCII') + }, + ) + + test('replacement identical to original is not counted', async () => { + clearRenderCache() + const { appState, setAppState } = makeAppState() + addSessionHook( + setAppState, + getSessionId(), + 'AssistantRender', + '', + echoRenderHook(0, 'unchanged source'), + ) + const messages = [ + assistantMessage([{ type: 'text', text: 'unchanged source' }]), + ] + const count = await executeAssistantRenderHooks( + messages, + makeToolUseContext(appState), + ) + expect(count).toBe(0) + }) +}) diff --git a/src/utils/hooks/__tests__/renderCache.test.ts b/src/utils/hooks/__tests__/renderCache.test.ts new file mode 100644 index 0000000000..4379050662 --- /dev/null +++ b/src/utils/hooks/__tests__/renderCache.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' +import { randomUUID } from 'crypto' +import type { AssistantMessage } from 'src/types/message.js' +import { + clearRenderCache, + getCachedRenderedText, + setMessageRenderCache, +} from 'src/utils/hooks/renderCache.js' + +function assistantMessage(text: string): AssistantMessage { + return { + type: 'assistant', + uuid: randomUUID(), + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + } as AssistantMessage +} + +describe('renderCache', () => { + test('set/get roundtrip', () => { + clearRenderCache() + setMessageRenderCache('original', 'rendered') + expect(getCachedRenderedText('original')).toBe('rendered') + }) + + test('miss returns undefined', () => { + clearRenderCache() + expect(getCachedRenderedText('not-cached')).toBeUndefined() + }) + + test('identical content shares one entry (content-addressed)', () => { + clearRenderCache() + setMessageRenderCache('same-text', 'A') + // 同内容再次写入覆盖同一条目 + setMessageRenderCache('same-text', 'B') + expect(getCachedRenderedText('same-text')).toBe('B') + }) + + test('clearRenderCache empties the cache', () => { + clearRenderCache() + setMessageRenderCache('x', 'y') + clearRenderCache() + expect(getCachedRenderedText('x')).toBeUndefined() + }) +}) diff --git a/src/utils/hooks/hooksConfigManager.ts b/src/utils/hooks/hooksConfigManager.ts index e4b9a80dcb..6853afc941 100644 --- a/src/utils/hooks/hooksConfigManager.ts +++ b/src/utils/hooks/hooksConfigManager.ts @@ -261,6 +261,11 @@ export const getHookEventMetadata = memoize( description: 'Input to command is JSON with file_path and event (change, add, unlink).\nCLAUDE_ENV_FILE is set — write bash exports there to apply env to subsequent BashTool commands.\nThe matcher field specifies filenames to watch in the current directory (e.g. ".envrc|.env").\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to dynamically update the watch list.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', }, + AssistantRender: { + summary: 'After an assistant message completes (display re-render)', + description: + 'Input to command is JSON with message_id and text_blocks (non-empty text blocks of the assistant message).\nReturn {"hookSpecificOutput":{"hookEventName":"AssistantRender","updatedBlocks":[{"blockIndex":0,"text":"..."}]}} to replace the display-layer text of blocks; transcript and model context keep the original text.\nOnly a single hook registration is supported — if multiple distinct hooks are registered, rendering is skipped entirely and an error is logged.\nExit code 0 - stdout/stderr not shown\nOther exit codes - show stderr to user only', + }, } }, toolNames => toolNames.slice().sort().join(','), @@ -295,6 +300,7 @@ export function groupHooksByEventAndMatcher( ElicitationResult: {}, ConfigChange: {}, WorktreeCreate: {}, + AssistantRender: {}, WorktreeRemove: {}, InstructionsLoaded: {}, CwdChanged: {}, diff --git a/src/utils/hooks/renderCache.ts b/src/utils/hooks/renderCache.ts new file mode 100644 index 0000000000..98a42def24 --- /dev/null +++ b/src/utils/hooks/renderCache.ts @@ -0,0 +1,63 @@ +import { createHash } from 'node:crypto' + +/** + * AssistantRender hook 的显示层渲染缓存。 + * + * key = sha1(原文)[:16](内容寻址,不依赖消息 uuid,resume/rewind 语义自然正确); + * value = hook 返回的替换文本。transform 只进本缓存、绝不写回 messages 数组, + * 因此 transcript 与模型上下文始终保留原文,仅终端显示被替换。 + */ +const renderCache = new Map() + +function renderCacheKey(text: string): string { + return createHash('sha1').update(text, 'utf8').digest('hex').slice(0, 16) +} + +/** 取原文对应的渲染文本;未命中返回 undefined(显示层回退原文) */ +export function getCachedRenderedText(text: string): string | undefined { + return renderCache.get(renderCacheKey(text)) +} + +/** 写入缓存:AssistantRender hook 返回 updatedBlocks 时按 (原文, 替换文本) 记录 */ +export function setMessageRenderCache( + original: string, + rendered: string, +): void { + renderCache.set(renderCacheKey(original), rendered) + renderCacheVersion++ +} + +/** + * 渲染缓存版本号:每次写入/清空自增。 + * MessageRow 的 memo 比较器无法感知模块内缓存变化(prev/next 持同一 message 引用), + * 因此由 Messages.tsx 把版本号作为 prop 下发,版本变化即强制该行重绘。 + */ +let renderCacheVersion = 0 + +export function getRenderCacheVersion(): number { + return renderCacheVersion +} + +/** + * 重绘纪元表:uuid → 命中写入时的全局版本号。 + * 静态定格行(滚出视口被 OffscreenFreeze 冻结)对 prop 变化免疫, + * 由 Messages 把纪元并入行 key 触发该行重挂载(一次性重印,绕过 memo 与冻结)。 + */ +const repaintEpochs = new Map() + +/** 执行器命中替换时调用:标记该消息需要重印 */ +export function markMessageForRepaint(uuid: string): void { + repaintEpochs.set(uuid, renderCacheVersion) +} + +/** 该消息的重绘纪元(0 = 从未命中,key 不加后缀) */ +export function getRepaintEpoch(uuid: string): number { + return repaintEpochs.get(uuid) ?? 0 +} + +/** 清空缓存(会话重置等场景) */ +export function clearRenderCache(): void { + renderCache.clear() + repaintEpochs.clear() + renderCacheVersion++ +} diff --git a/src/utils/plugins/loadPluginHooks.ts b/src/utils/plugins/loadPluginHooks.ts index c4e732bd35..70671a18e1 100644 --- a/src/utils/plugins/loadPluginHooks.ts +++ b/src/utils/plugins/loadPluginHooks.ts @@ -56,6 +56,7 @@ function convertPluginHooksToMatchers( InstructionsLoaded: [], CwdChanged: [], FileChanged: [], + AssistantRender: [], } if (!plugin.hooksConfig) { @@ -118,6 +119,7 @@ export const loadPluginHooks = memoize(async (): Promise => { InstructionsLoaded: [], CwdChanged: [], FileChanged: [], + AssistantRender: [], } // Process each enabled plugin diff --git a/src/utils/settings/settings.ts b/src/utils/settings/settings.ts index f656e6a6ac..d383517027 100644 --- a/src/utils/settings/settings.ts +++ b/src/utils/settings/settings.ts @@ -605,6 +605,7 @@ export function getManagedSettingsKeysForLogging( 'TeammateIdle', 'TaskCreated', 'TaskCompleted', + 'AssistantRender', ]), }