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

@@ -12,9 +12,9 @@ import { getCompanion } from './companion.js';
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
import { RARITY_COLORS } from './types.js';
const TICK_MS = 500;
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
const TICK_MS = 1000;
const BUBBLE_SHOW = 10; // ticks → ~10s at 1000ms
const FADE_WINDOW = 3; // last ~3s the bubble dims so you know it's about to go
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.

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>,
]
: []),

View File

@@ -1742,7 +1742,7 @@ async function* queryLoop(
updatedToolUseContext,
null,
queuedAutonomyClaim.attachmentCommands,
[...messagesForQuery, ...assistantMessages, ...toolResults],
messagesForQuery.concat(assistantMessages, toolResults),
querySource,
)) {
yield attachment

View File

@@ -1829,6 +1829,9 @@ async function* queryModel(
let ttftMs = 0
let partialMessage: BetaMessage | undefined
const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = []
// Accumulate streaming deltas in arrays to avoid O(n²) string concatenation.
// Joined and assigned to contentBlock fields at content_block_stop.
const streamingDeltas = new Map<number, string[]>()
let usage: NonNullableUsage = EMPTY_USAGE
let costUSD = 0
let stopReason: BetaStopReason | null = null
@@ -2115,6 +2118,8 @@ async function* queryModel(
}
break
}
// Initialize delta accumulator for this content block
streamingDeltas.set(part.index, [])
break
case 'content_block_delta': {
const contentBlock = contentBlocks[part.index]
@@ -2144,8 +2149,9 @@ async function* queryModel(
})
throw new Error('Content block is not a connector_text block')
}
;(contentBlock as { connector_text: string }).connector_text +=
delta.connector_text
streamingDeltas
.get(part.index)
?.push(delta.connector_text as string)
} else {
switch (delta.type) {
case 'citations_delta':
@@ -2175,7 +2181,9 @@ async function* queryModel(
})
throw new Error('Content block input is not a string')
}
contentBlock.input += delta.partial_json
streamingDeltas
.get(part.index)
?.push(delta.partial_json as string)
break
case 'text_delta':
if (contentBlock.type !== 'text') {
@@ -2189,7 +2197,7 @@ async function* queryModel(
})
throw new Error('Content block is not a text block')
}
;(contentBlock as { text: string }).text += delta.text
streamingDeltas.get(part.index)?.push(delta.text!)
break
case 'signature_delta':
if (
@@ -2224,8 +2232,7 @@ async function* queryModel(
})
throw new Error('Content block is not a thinking block')
}
;(contentBlock as { thinking: string }).thinking +=
delta.thinking
streamingDeltas.get(part.index)?.push(delta.thinking!)
break
}
}
@@ -2257,6 +2264,32 @@ async function* queryModel(
})
throw new Error('Message not found')
}
// Join accumulated streaming deltas into the contentBlock fields
// to avoid O(n²) string concatenation during streaming.
const deltas = streamingDeltas.get(part.index)
if (deltas && deltas.length > 0) {
const joined = deltas.join('')
switch (contentBlock.type) {
case 'text':
;(contentBlock as { text: string }).text = joined
break
case 'thinking':
;(contentBlock as { thinking: string }).thinking = joined
break
case 'tool_use':
case 'server_tool_use':
contentBlock.input = joined
break
default:
if ((contentBlock.type as string) === 'connector_text') {
;(
contentBlock as { connector_text: string }
).connector_text = joined
}
break
}
streamingDeltas.delete(part.index)
}
const m: AssistantMessage = {
message: {
...partialMessage,

View File

@@ -610,6 +610,13 @@ class Project {
queue = []
this.writeQueues.set(filePath, queue)
}
// Drop oldest entries when queue exceeds limit to prevent unbounded memory growth
if (queue.length >= 1000) {
const dropped = queue.splice(0, queue.length - 999)
for (const d of dropped) {
d.resolve()
}
}
queue.push({ entry, resolve })
this.scheduleDrain()
})

View File

@@ -85,7 +85,7 @@ export function normalizeFullWidthSpace(input: string): string {
// Keep in-memory accumulation modest to avoid blowing up RSS.
// Overflow beyond this limit is spilled to disk by ShellCommand.
const MAX_STRING_LENGTH = 2 ** 25
const MAX_STRING_LENGTH = 2 ** 21
/**
* Safely joins an array of strings with a delimiter, truncating if the result exceeds maxSize.