mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -1742,7 +1742,7 @@ async function* queryLoop(
|
||||
updatedToolUseContext,
|
||||
null,
|
||||
queuedAutonomyClaim.attachmentCommands,
|
||||
[...messagesForQuery, ...assistantMessages, ...toolResults],
|
||||
messagesForQuery.concat(assistantMessages, toolResults),
|
||||
querySource,
|
||||
)) {
|
||||
yield attachment
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user