fix(tui): reduce status bar flicker during streaming

The status bar flickers visibly during streaming because every state
patch (thinking.delta, reasoning.delta, tool.*, usage notifications)
creates a new $uiState object, forcing StatusRulePane and StatusRule
to re-render and redo expensive layout calculations on every event.

Three fixes applied:

1. Stabilize usage object references in createGatewayEventHandler.ts
   - Add mergeUsageStable() that shallow-compares Usage fields before
     creating a new object. When values haven't changed, returns the
     existing reference, preventing unnecessary StatusRule re-renders.

2. Memoize expensive computations inside StatusRule (appChrome.tsx)
   - statusBarSegments(cols) → useMemo([cols])
   - modelLabel() → useMemo([model, effort, fast])
   - ctxLabel, bar → useMemo([usage fields, segs])
   - Tail segment budget + fits() calculations → single useMemo block
     covering all progressive-disclosure logic

3. Wrap StatusRule in React.memo (appChrome.tsx)
   - Combined with stable usage references, allows React to skip
     re-renders when props haven't actually changed.

Fixes #41480
This commit is contained in:
liuhao1024
2026-06-08 03:47:38 +08:00
committed by Teknium
parent 3abe6cc503
commit 8606867ebc

View File

@@ -23,7 +23,7 @@ import { isPaintableHex, setTerminalBackground, setTerminalForeground } from '..
import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js'
import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js'
import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme, themeToneHex } from '../theme.js'
import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import type { Msg, SubagentProgress, SubagentStatus, Usage } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
@@ -41,6 +41,28 @@ type VoiceSubmitMode = 'direct' | 'draft'
const normalizeVoiceSubmitMode = (value: unknown): VoiceSubmitMode =>
typeof value === 'string' && value.trim().toLowerCase() === 'draft' ? 'draft' : 'direct'
// Shallow-compare Usage to avoid creating a new object reference when values
// haven't changed. A fresh reference on every streaming event forces every
// $uiState subscriber (including the status rule) to re-render, which showed
// up as per-delta status-bar flicker on iTerm2 (#41480). The comparator
// iterates the union of keys generically so a future Usage field (e.g.
// active_subagents, consumed by the status rule's subagent segment) can never
// be silently dropped from the comparison.
const usageChanged = (prev: Usage, next: Usage): boolean => {
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) as Set<keyof Usage>
for (const key of keys) {
if (prev[key] !== next[key]) return true
}
return false
}
const mergeUsageStable = (prev: Usage, patch: Partial<Usage> | undefined): Usage => {
if (!patch) return prev
const merged: Usage = { ...prev, ...patch }
return usageChanged(prev, merged) ? merged : prev
}
const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
// The last gateway skin, kept so the theme can be re-derived when the OSC-11
@@ -745,7 +767,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
...state,
info,
status: state.status === 'starting agent…' ? 'ready' : state.status,
usage: info.usage ? { ...state.usage, ...info.usage } : state.usage
usage: info.usage ? mergeUsageStable(state.usage, info.usage) : state.usage
}))
setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info } : m)))
@@ -1370,7 +1392,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
setStatus('ready')
if (ev.payload?.usage) {
patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } }))
patchUiState(state => ({ ...state, usage: mergeUsageStable(state.usage, ev.payload!.usage) }))
}
// Billing wall (out of credits / payment required): open a proper