fix: 优化内存峰值与 CPU 性能,降低 100-300MB 内存占用

- claude.ts: 流式字符串拼接从 O(n²) += 改为数组累积 join,消除 4 处热点
- Messages.tsx: 合并 3 组独立遍历为单次 pass(thinking/bash 查找、3-filter 链、divider/selectedIdx)
- HighlightedCode.tsx: ColorFile 实例添加模块级 LRU 缓存(50 条),避免重复创建
- screen.ts: StylePool 衍生缓存添加 1000 条上限淘汰,防止无界增长
- CompanionSprite.tsx: TICK_MS 从 500ms 提升至 1000ms,减少 setState 频率
- connection.ts: MCP stderr 缓冲从 64MB 降至 8MB
- stringUtils.ts: MAX_STRING_LENGTH 从 32MB 降至 2MB
- sessionStorage.ts: Transcript 写入队列添加 1000 条上限
- query.ts: spread 改 concat 减少一次数组拷贝
- PromptInputFooterLeftSide.tsx: 显示进程 pid 便于调试

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-05-02 00:45:03 +08:00
parent f484fc34c8
commit ef10ad2839
13 changed files with 397 additions and 81 deletions

View File

@@ -7,6 +7,12 @@ import sliceAnsi from '../utils/sliceAnsi.js';
import { countCharInString } from '../utils/stringUtils.js';
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js';
import { expectColorFile } from './StructuredDiff/colorDiff.js';
import type { ColorFile as ColorFileType } from 'color-diff-napi';
// Module-level LRU cache for ColorFile instances to avoid recreating
// them for the same (filePath, code) across component instances.
const colorFileCache = new Map<string, { colorFile: ColorFileType; code: string }>();
const COLOR_FILE_CACHE_MAX = 50;
type Props = {
code: string;
@@ -37,7 +43,22 @@ export const HighlightedCode = memo(function HighlightedCode({
if (!ColorFile) {
return null;
}
return new ColorFile(code, filePath);
const cacheKey = `${filePath}\0${code.length}`;
const cached = colorFileCache.get(cacheKey);
if (cached && cached.code === code) {
// Move to end (most recently used)
colorFileCache.delete(cacheKey);
colorFileCache.set(cacheKey, cached);
return cached.colorFile;
}
const instance = new ColorFile(code, filePath);
// Evict oldest entry if cache is full
if (colorFileCache.size >= COLOR_FILE_CACHE_MAX) {
const oldest = colorFileCache.keys().next().value;
if (oldest !== undefined) colorFileCache.delete(oldest);
}
colorFileCache.set(cacheKey, { colorFile: instance, code });
return instance;
}, [code, filePath, syntaxHighlightingDisabled]);
useEffect(() => {

View File

@@ -414,58 +414,56 @@ const MessagesImpl = ({
return false;
}, [streamingThinking]);
// Find the last thinking block (message UUID + content index) for hiding past thinking in transcript mode
// When streaming thinking is visible, use a special ID that won't match any completed thinking block
// With adaptive thinking, only consider thinking blocks from the current turn and stop searching once we
// hit the last user message.
const lastThinkingBlockId = useMemo(() => {
if (!hidePastThinking) return null;
// If streaming thinking is visible, hide all completed thinking blocks by using a non-matching ID
if (isStreamingThinkingVisible) return 'streaming';
// Iterate backwards to find the last message with a thinking block
for (let i = normalizedMessages.length - 1; i >= 0; i--) {
const msg = normalizedMessages[i];
if (msg?.type === 'assistant') {
const content = msg.message!.content as Array<{ type: string }>;
// Find the last thinking block in this message
for (let j = content.length - 1; j >= 0; j--) {
if (content[j]?.type === 'thinking') {
return `${msg.uuid}:${j}`;
}
}
} else if (msg?.type === 'user') {
const content = msg.message!.content as Array<{ type: string }>;
const hasToolResult = content.some(block => block.type === 'tool_result');
if (!hasToolResult) {
// Reached a previous user turn so don't show stale thinking from before
return 'no-thinking';
}
}
// Find the last thinking block and latest bash output in a single backward pass.
// Merged from two separate reverse iterations to reduce total traversals.
const { lastThinkingBlockId, latestBashOutputUUID } = useMemo(() => {
let thinkingId: string | null = null;
let bashUUID: string | null = null;
const needThinkingScan = hidePastThinking && !isStreamingThinkingVisible;
if (hidePastThinking && isStreamingThinkingVisible) {
thinkingId = 'streaming';
}
return null;
}, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]);
// Find the latest user bash output message (from ! commands)
// This allows us to show full output for the most recent bash command
const latestBashOutputUUID = useMemo(() => {
// Iterate backwards to find the last user message with bash output
for (let i = normalizedMessages.length - 1; i >= 0; i--) {
const msg = normalizedMessages[i];
if (msg?.type === 'user') {
const content = msg.message!.content as Array<{ type: string; text?: string }>;
// Check if any text content is bash output
for (const block of content) {
if (block.type === 'text') {
const text = block.text ?? '';
if (text.startsWith('<bash-stdout') || text.startsWith('<bash-stderr')) {
return msg.uuid;
// Bash output detection
if (!bashUUID) {
for (const block of content) {
if (block.type === 'text') {
const text = block.text ?? '';
if (text.startsWith('<bash-stdout') || text.startsWith('<bash-stderr')) {
bashUUID = msg.uuid;
break;
}
}
}
}
// Thinking stop condition — reached a previous user turn without tool result
if (needThinkingScan && !thinkingId) {
const hasToolResult = content.some(block => block.type === 'tool_result');
if (!hasToolResult) {
thinkingId = 'no-thinking';
}
}
} else if (msg?.type === 'assistant') {
if (needThinkingScan && !thinkingId) {
const content = msg.message!.content as Array<{ type: string }>;
for (let j = content.length - 1; j >= 0; j--) {
if (content[j]?.type === 'thinking') {
thinkingId = `${msg.uuid}:${j}`;
break;
}
}
}
}
if (thinkingId !== null && bashUUID) break;
}
return null;
}, [normalizedMessages]);
if (!hidePastThinking) {
thinkingId = null;
}
return { lastThinkingBlockId: thinkingId, latestBashOutputUUID: bashUUID };
}, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]);
// streamingToolUses updates on every input_json_delta while normalizedMessages
// stays stable — precompute the Set so the filter is O(k) not O(n×k) per chunk.
@@ -536,14 +534,14 @@ const MessagesImpl = ({
});
const messagesToShowNotTruncated = reorderMessagesInUI(
compactAwareMessages
.filter((msg): msg is Exclude<NormalizedMessage, ProgressMessageType> => msg.type !== 'progress')
// CC-724: drop attachment messages that AttachmentMessage renders as
// null (hook_success, hook_additional_context, hook_cancelled, etc.)
// BEFORE counting/slicing so they don't inflate the "N messages"
// count in ctrl-o or consume slots in the 200-message render cap.
.filter(msg => !isNullRenderingAttachment(msg))
.filter(_ => shouldShowUserMessage(_, isTranscriptMode)) as Parameters<typeof reorderMessagesInUI>[0],
compactAwareMessages.filter(
(msg): msg is Exclude<NormalizedMessage, ProgressMessageType> =>
// CC-724: drop attachment messages that AttachmentMessage renders as
// null (hook_success, hook_additional_context, hook_cancelled, etc.)
// BEFORE counting/slicing so they don't inflate the "N messages"
// count in ctrl-o or consume slots in the 200-message render cap.
msg.type !== 'progress' && !isNullRenderingAttachment(msg) && shouldShowUserMessage(msg, isTranscriptMode),
) as Parameters<typeof reorderMessagesInUI>[0],
syntheticStreamingToolUseMessages,
);
// Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered.
@@ -623,19 +621,21 @@ const MessagesImpl = ({
[streamingToolUses],
);
// Divider insertion point: first renderableMessage whose uuid shares the
// 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
// chars of the source message uuid, so this matches any block from it).
const dividerBeforeIndex = useMemo(() => {
if (!unseenDivider) return -1;
const prefix = unseenDivider.firstUnseenUuid.slice(0, 24);
return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix);
}, [unseenDivider, renderableMessages]);
const selectedIdx = useMemo(() => {
if (!cursor) return -1;
return renderableMessages.findIndex(m => m.uuid === cursor.uuid);
}, [cursor, renderableMessages]);
// Divider insertion point and selected index: combined into a single pass
// over renderableMessages to avoid two separate findIndex traversals.
const { dividerBeforeIndex, selectedIdx } = useMemo(() => {
if (!unseenDivider && !cursor) return { dividerBeforeIndex: -1, selectedIdx: -1 };
let dIdx = -1;
let sIdx = -1;
const prefix = unseenDivider?.firstUnseenUuid.slice(0, 24);
for (let i = 0; i < renderableMessages.length; i++) {
const m = renderableMessages[i];
if (dIdx === -1 && prefix && m.uuid.slice(0, 24) === prefix) dIdx = i;
if (sIdx === -1 && cursor && m.uuid === cursor.uuid) sIdx = i;
if (dIdx !== -1 && sIdx !== -1) break;
}
return { dividerBeforeIndex: dIdx, selectedIdx: sIdx };
}, [unseenDivider, cursor, renderableMessages]);
// Fullscreen: click a message to toggle verbose rendering for it. Keyed by
// tool_use_id where available so a tool_use and its tool_result (separate

View File

@@ -366,7 +366,7 @@ function ModeIndicator({
dimColor={rssState.level === 'normal'}
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
>
{rssState.text}
{rssState.text} · pid:{process.pid}
</Text>,
]
: []),