Skip to content

Commit 82937a8

Browse files
committed
perf(ui): 终端交互层全面性能优化
- 流式输出:将 streamingChunksBuffer 移至模块级,避免 Zustand set() 中数组扩展开销 - 消除闪屏:finalization 移除 50ms delay + clearTerminal,改用 eraseScreen - Raw 渲染器:新增 rawStreamRenderer.ts,流式尾部直接 stdout 输出绕过 React/Ink - Spinner 降频:80ms → 150ms(12.5fps → 6.7fps,视觉无差异) - 语法高亮缓存:CodeHighlighter 新增 LRU 缓存(容量 200),避免重复 lowlight 计算 - 主题同步:useTheme 移除 setTheme 副作用,统一到 BladeInterface 单个 useEffect - Lint 修复:AutoMemoryManager catch 类型安全、blade.tsx 移除未使用变量、测试空块注释
1 parent e1fb04d commit 82937a8

11 files changed

Lines changed: 414 additions & 87 deletions

File tree

packages/cli/src/blade.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,6 @@ export async function main() {
4444
// 但允许在容器/沙箱/CI 等天然 root 环境中运行
4545
if (process.getuid && process.getuid() === 0) {
4646
const isSudo = !!process.env.SUDO_USER;
47-
const isContainer =
48-
!!process.env.container ||
49-
!!process.env.DOCKER_CONTAINER ||
50-
!!process.env.KUBERNETES_SERVICE_HOST;
51-
const isCI = !!process.env.CI;
5247
const isAllowRoot = !!process.env.BLADE_ALLOW_ROOT;
5348

5449
// 只有通过 sudo 提权运行时才阻止,天然 root 环境放行

packages/cli/src/memory/AutoMemoryManager.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ export class AutoMemoryManager {
6565
}
6666

6767
return result || null;
68-
} catch (err: any) {
69-
if (err.code === 'ENOENT') return null;
68+
} catch (err: unknown) {
69+
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') return null;
7070
throw err;
7171
}
7272
}
@@ -81,8 +81,8 @@ export class AutoMemoryManager {
8181
try {
8282
const content = await fs.readFile(filePath, 'utf-8');
8383
return content || null;
84-
} catch (err: any) {
85-
if (err.code === 'ENOENT') return null;
84+
} catch (err: unknown) {
85+
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') return null;
8686
throw err;
8787
}
8888
}

packages/cli/src/store/selectors/index.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -153,27 +153,25 @@ export const useCurrentModelId = () =>
153153
useBladeStore((state) => state.config.config?.currentModelId);
154154

155155
/**
156-
* 派生选择器:当前主题对象
156+
* 派生选择器:当前主题对象(纯读取,无副作用)
157157
* 订阅 Store 中的主题名称变化,并返回完整的 Theme 对象
158158
*
159-
* 内部自动同步 themeManager(如果名称不一致)
159+
* 注意:themeManager 的同步由 useThemeSync hook 在 App 层统一处理,
160+
* 此处只做纯读取,避免每个使用 useTheme 的组件在渲染时触发副作用。
160161
*/
161162
export const useTheme = () =>
162163
useBladeStore((state) => {
163-
const themeName = state.config.config?.theme ?? 'default';
164-
165-
// 确保 themeManager 与 Store 同步
166-
if (themeManager.getCurrentThemeName() !== themeName) {
167-
try {
168-
themeManager.setTheme(themeName);
169-
} catch {
170-
// 主题不存在,保持当前主题
171-
}
172-
}
173-
164+
// 纯读取,不再在选择器中调用 themeManager.setTheme()
174165
return themeManager.getTheme();
175166
});
176167

168+
/**
169+
* 获取当前 Store 中配置的主题名称
170+
* 用于 useThemeSync hook 对比和同步
171+
*/
172+
export const useThemeName = () =>
173+
useBladeStore((state) => state.config.config?.theme ?? 'default');
174+
177175
// ==================== Focus 选择器 ====================
178176

179177
/**

packages/cli/src/store/slices/sessionSlice.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ import type {
2323

2424
const STREAMING_LINE_BUFFER_LIMIT = 2000;
2525

26+
// ==================== 流式 chunks 模块级缓冲 ====================
27+
// 将 chunks 累积移出 Zustand store,避免每次 delta 都展开数组触发状态更新开销
28+
// 仅在 finalizeStreamingMessage 时读取
29+
let streamingChunksBuffer: string[] = [];
30+
31+
/**
32+
* 获取并清空流式 chunks 缓冲区(供 finalize 使用)
33+
*/
34+
export function drainStreamingChunksBuffer(): string[] {
35+
const chunks = streamingChunksBuffer;
36+
streamingChunksBuffer = [];
37+
return chunks;
38+
}
39+
2640
/**
2741
* 初始 Token 使用量
2842
*/
@@ -360,6 +374,8 @@ export const createSessionSlice: StateCreator<BladeStore, [], [], SessionSlice>
360374
content: '', // 空内容,后续增量填充
361375
timestamp: Date.now(),
362376
};
377+
// 清空模块级 chunks 缓冲区
378+
streamingChunksBuffer = [];
363379
set((state) => ({
364380
session: {
365381
...state.session,
@@ -390,11 +406,14 @@ export const createSessionSlice: StateCreator<BladeStore, [], [], SessionSlice>
390406
appendAssistantContent: (delta: string) => {
391407
const streamingId = get().session.currentStreamingMessageId;
392408
const nextStreamingId = streamingId ?? `assistant-${Date.now()}-${Math.random()}`;
409+
410+
// chunks 累积在模块级缓冲区,不写入 store(减少数组展开开销)
411+
streamingChunksBuffer.push(delta);
412+
393413
set((state) => {
394414
const normalizeLine = (line: string) =>
395415
line.endsWith('\r') ? line.slice(0, -1) : line;
396416

397-
const currentChunks = streamingId ? state.session.currentStreamingChunks : [];
398417
const currentLines = streamingId ? state.session.currentStreamingLines : [];
399418
const currentTail = streamingId ? state.session.currentStreamingTail : '';
400419
const currentLineCount = streamingId
@@ -406,7 +425,6 @@ export const createSessionSlice: StateCreator<BladeStore, [], [], SessionSlice>
406425
const parts = combined.split('\n');
407426
const completedParts = parts.slice(0, -1).map(normalizeLine);
408427
const nextTail = normalizeLine(parts[parts.length - 1] ?? '');
409-
const nextChunks = [...currentChunks, delta];
410428
let nextLines = currentLines;
411429
if (completedParts.length > 0) {
412430
nextLines = [...currentLines, ...completedParts];
@@ -420,7 +438,6 @@ export const createSessionSlice: StateCreator<BladeStore, [], [], SessionSlice>
420438
session: {
421439
...state.session,
422440
currentStreamingMessageId: nextStreamingId,
423-
currentStreamingChunks: nextChunks,
424441
currentStreamingLines: nextLines,
425442
currentStreamingTail: nextTail,
426443
currentStreamingLineCount: currentLineCount + completedParts.length,
@@ -440,9 +457,11 @@ export const createSessionSlice: StateCreator<BladeStore, [], [], SessionSlice>
440457
* @param extraThinking 可选的额外 thinking 内容(缓冲区剩余)
441458
*/
442459
finalizeStreamingMessage: (extraContent?: string, extraThinking?: string) => {
460+
// 从模块级缓冲区读取 chunks 并清空
461+
const chunks = drainStreamingChunksBuffer();
443462
set((state) => {
444463
const streamingId = state.session.currentStreamingMessageId;
445-
const baseContent = state.session.currentStreamingChunks.join('');
464+
const baseContent = chunks.join('');
446465
const streamingContent = baseContent + (extraContent || '');
447466
const thinkingContent =
448467
(state.session.currentThinkingContent || '') + (extraThinking || '');

packages/cli/src/ui/components/BladeInterface.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
usePermissionMode,
2222
useSessionActions,
2323
useSessionSelectorData,
24+
useThemeName,
2425
} from '../../store/selectors/index.js';
2526
import { FocusId } from '../../store/types.js';
2627
import { configActions, getMessages } from '../../store/vanilla.js';
@@ -50,6 +51,7 @@ import { SessionSelector } from './SessionSelector.js';
5051
import { SkillsManager } from './SkillsManager.js';
5152
import { SpecStatusPanel } from './SpecStatusPanel.js';
5253
import { SubagentProgress } from './SubagentProgress.js';
54+
import { themeManager } from '../themes/ThemeManager.js';
5355
import { ThemeSelector } from './ThemeSelector.js';
5456

5557
// 创建 BladeInterface 专用 Logger
@@ -106,6 +108,19 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
106108
// 是否正在处理
107109
const isProcessing = useIsProcessing();
108110

111+
// 主题同步:统一在此处将 Store 中的主题名同步到 themeManager
112+
// 避免在每个 useTheme 选择器中触发副作用
113+
const themeName = useThemeName();
114+
useEffect(() => {
115+
if (themeManager.getCurrentThemeName() !== themeName) {
116+
try {
117+
themeManager.setTheme(themeName);
118+
} catch {
119+
// 主题不存在,保持当前主题
120+
}
121+
}
122+
}, [themeName]);
123+
109124
const { exit } = useApp();
110125

111126
// ==================== Custom Hooks ====================

packages/cli/src/ui/components/CodeHighlighter.tsx

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,41 @@ import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from './MaxSizedBox.js';
2020
// 创建 lowlight 实例
2121
const lowlight = createLowlight(common);
2222

23+
// ==================== HAST 结果 LRU 缓存 ====================
24+
// 缓存 lowlight 的 HAST 解析结果,避免重复解析相同代码行
25+
const HIGHLIGHT_CACHE_CAPACITY = 200;
26+
const highlightCache = new Map<string, unknown>(); // key → HAST root node
27+
28+
function getCachedHighlight(
29+
line: string,
30+
language: string | undefined
31+
): unknown | undefined {
32+
const key = `${language ?? '__auto__'}:${line}`;
33+
const cached = highlightCache.get(key);
34+
if (cached !== undefined) {
35+
// LRU: 移到末尾
36+
highlightCache.delete(key);
37+
highlightCache.set(key, cached);
38+
}
39+
return cached;
40+
}
41+
42+
function setCachedHighlight(
43+
line: string,
44+
language: string | undefined,
45+
result: unknown
46+
): void {
47+
const key = `${language ?? '__auto__'}:${line}`;
48+
if (highlightCache.size >= HIGHLIGHT_CACHE_CAPACITY) {
49+
// 删除最旧的条目(Map 的第一个 key)
50+
const firstKey = highlightCache.keys().next().value;
51+
if (firstKey !== undefined) {
52+
highlightCache.delete(firstKey);
53+
}
54+
}
55+
highlightCache.set(key, result);
56+
}
57+
2358
interface CodeHighlighterProps {
2459
content: string;
2560
language?: string;
@@ -124,27 +159,30 @@ function highlightLine(
124159
const colors = syntaxColors || themeManager.getTheme().colors.syntax;
125160

126161
try {
162+
// 查询 HAST 缓存
163+
const cached = getCachedHighlight(line, language);
164+
if (cached) {
165+
return renderHastNode(cached, colors);
166+
}
167+
168+
let result: unknown;
127169
if (!language || !lowlight.registered(language)) {
128-
// 尝试自动检测语言
129-
const result = lowlight.highlightAuto(line);
130-
if (!result.children || result.children.length === 0) {
131-
return (
132-
<Text color={colors.default} wrap="wrap">
133-
{line}
134-
</Text>
135-
);
136-
}
137-
return renderHastNode(result, colors);
170+
result = lowlight.highlightAuto(line);
171+
} else {
172+
result = lowlight.highlight(language, line);
138173
}
139174

140-
const result = lowlight.highlight(language, line);
141-
if (!result.children || result.children.length === 0) {
175+
const root = result as HastRootNode;
176+
if (!root.children || root.children.length === 0) {
142177
return (
143178
<Text color={colors.default} wrap="wrap">
144179
{line}
145180
</Text>
146181
);
147182
}
183+
184+
// 缓存 HAST 结果
185+
setCachedHighlight(line, language, result);
148186
return renderHastNode(result, colors);
149187
} catch (_error) {
150188
return (

packages/cli/src/ui/components/LoadingIndicator.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = React.memo(
7070
paused
7171
);
7272

73-
// 动画效果:每 80ms 切换一帧
73+
// 动画效果:每 150ms 切换一帧(降低频率减少 React 重渲染)
7474
// 当 paused=true 时暂停动画,避免被遮挡时仍触发重渲染
7575
useEffect(() => {
7676
if (!visible || paused) {
@@ -80,7 +80,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = React.memo(
8080

8181
const timer = setInterval(() => {
8282
setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
83-
}, 80);
83+
}, 150);
8484

8585
return () => clearInterval(timer);
8686
}, [visible, paused]);

0 commit comments

Comments
 (0)