Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第10章:UI 组件

本章深入分析 Claude Code 的 UI 组件设计,涵盖核心组件、交互组件和布局组件的实现原理。

10.1 components/ 目录结构

整体架构

Claude Code 的 UI 组件库是一个大型 React/Ink 组件集合,位于 src/components/ 目录:

graph TD
    subgraph Core["核心层"]
        APP[App.tsx<br/>顶层 Context 包装]
        MSG[Message.tsx<br/>消息渲染入口]
        MSGS[Messages.tsx<br/>消息列表管理]
    end
    
    subgraph Input["输入组件"]
        TI[TextInput.tsx<br/>文本输入框]
        BTI[BaseTextInput.tsx<br/>输入基础组件]
        VI[VimTextInput.tsx<br/>Vim 模式输入]
        SB[SearchBox.tsx<br/>搜索框]
    end
    
    subgraph Output["输出组件"]
        SL[StatusLine.tsx<br/>状态栏]
        VML[VirtualMessageList.tsx<br/>虚拟消息列表]
        MD[Markdown.tsx<br/>Markdown 渲染]
        HC[HighlightedCode.tsx<br/>代码高亮]
    end
    
    subgraph Interaction["交互组件"]
        SP[Spinner.tsx<br/>加载动画]
        TP[TokenWarning.tsx<br/>Token 警告]
        FD[Feedback.tsx<br/>反馈收集]
        DLG[Dialog.tsx<br/>对话框系统]
    end
    
    subgraph Messages["消息渲染"]
        ATM[AssistantTextMessage]
        ATU[AssistantToolUseMessage]
        UTM[UserTextMessage]
        UTR[UserToolResultMessage]
        STM[SystemTextMessage]
    end
    
    APP --> MSGS
    MSGS --> VML
    VML --> MSG
    MSG --> Messages
    
    TI --> BTI
    VML --> SL
    
    Messages --> ATM
    Messages --> ATU
    Messages --> UTM
    Messages --> UTR
    Messages --> STM

文件组织

src/components/
├── App.tsx                    # 顶层 Context Provider 包装
├── Message.tsx                # 单条消息渲染(79KB)
├── Messages.tsx               # 消息列表管理(147KB)
├── VirtualMessageList.tsx     # 虚拟滚动列表(148KB)
├── StatusLine.tsx             # 状态栏(49KB)
├── TextInput.tsx              # 文本输入组件(21KB)
├── BaseTextInput.tsx          # 输入基础逻辑(19KB)
├── VimTextInput.tsx           # Vim 模式输入(16KB)
├── Spinner.tsx                # 加载动画(87KB)
├── Markdown.tsx               # Markdown 渲染(28KB)
├── HighlightedCode.tsx        # 语法高亮代码(17KB)
├── FileEditToolDiff.tsx       # 文件编辑差异展示(22KB)
├── StructuredDiff.tsx         # 结构化 Diff(25KB)
├── Feedback.tsx               # 用户反馈(87KB)
├── TokenWarning.tsx           # Token 警告提示(21KB)
├── FullscreenLayout.tsx       # 全屏布局(84KB)
├── Stats.tsx                  # 统计信息展示(152KB)
├── GlobalSearchDialog.tsx     # 全局搜索对话框(43KB)
├── ModelPicker.tsx            # 模型选择器(54KB)
├── ThemePicker.tsx            # 主题选择器(35KB)
├── messages/                  # 消息类型子组件
│   ├── AssistantTextMessage.tsx
│   ├── AssistantToolUseMessage.tsx
│   ├── AssistantThinkingMessage.tsx
│   ├── UserTextMessage.tsx
│   ├── UserToolResultMessage/
│   ├── SystemTextMessage.tsx
│   └── AttachmentMessage.tsx
├── design-system/             # 设计系统基础组件
│   ├── Dialog.tsx
│   ├── ListItem.tsx
│   ├── Tabs.tsx
│   ├── ThemedText.tsx
│   ├── ThemedBox.tsx
│   ├── ProgressBar.tsx
│   └── ThemeProvider.tsx
├── hooks/                     # 组件专用 Hooks
│   ├── HooksConfigMenu.tsx
│   ├── PromptDialog.tsx
│   └── SelectHookMode.tsx
├── shell/                     # Shell 输出组件
├── mcp/                       # MCP 相关组件
├── permissions/               # 权限相关组件
├── skills/                    # Skill 组件
├── tasks/                     # 任务状态组件
└── ui/                        # 通用 UI 元素

Note

组件目录规模庞大(150+ 文件),体现了 Claude Code 作为交互式 CLI 工具的复杂度。核心组件如 Message.tsxMessages.tsxVirtualMessageList.tsx 均超过 100KB,说明消息渲染是系统最复杂的部分。

10.2 核心组件解析

App.tsx - 顶层 Context 包装

App 组件是整个交互会话的顶层包装器,提供全局 Context:

// App.tsx
type Props = {
  getFpsMetrics: () => FpsMetrics | undefined;  // FPS 性能监控
  stats?: StatsStore;                            // 统计数据存储
  initialState: AppState;                        // 应用初始状态
  children: React.ReactNode;
};

export function App({ getFpsMetrics, stats, initialState, children }: Props) {
  return (
    <FpsMetricsProvider getFpsMetrics={getFpsMetrics}>
      <StatsProvider store={stats}>
        <AppStateProvider 
          initialState={initialState}
          onChangeAppState={onChangeAppState}
        >
          {children}
        </AppStateProvider>
      </StatsProvider>
    </FpsMetricsProvider>
  );
}

Tip

App 组件使用三层 Provider 包装:

  1. FpsMetricsProvider - 提供 FPS 性能数据
  2. StatsProvider - 提供统计信息(Token、Cost 等)
  3. AppStateProvider - 提供全局应用状态 这种分层设计允许子组件按需订阅不同层级的数据。

Message.tsx - 消息渲染分发

Message 组件是单条消息的渲染入口,根据消息类型分发到对应子组件:

// Message.tsx
export type Props = {
  message: NormalizedUserMessage | AssistantMessage | AttachmentMessage 
           | SystemMessage | GroupedToolUseMessage | CollapsedReadSearchGroup;
  lookups: ReturnType<typeof buildMessageLookups>;
  containerWidth?: number;
  tools: Tools;
  commands: Command[];
  verbose: boolean;
  inProgressToolUseIDs: Set<string>;
  progressMessagesForMessage: ProgressMessage[];
  shouldAnimate: boolean;
  isTranscriptMode: boolean;
};

function MessageImpl({ message, ...props }: Props) {
  switch (message.type) {
    case "attachment":
      return <AttachmentMessage 
        attachment={message.attachment} 
        verbose={verbose} 
      />;
    
    case "assistant":
      return (
        <Box flexDirection="column" width={containerWidth ?? "100%"}>
          {message.message.content.map((block, index) => 
            <AssistantMessageBlock 
              key={index}
              param={block}
              tools={tools}
              inProgressToolUseIDs={inProgressToolUseIDs}
              shouldAnimate={shouldAnimate}
              ...
            />
          )}
        </Box>
      );
    
    case "user":
      // 用户消息渲染...
    
    case "system":
      return <SystemTextMessage ... />;
    
    default:
      return null;
  }
}

消息类型映射

graph LR
    subgraph Input["消息类型"]
        UM[UserMessage]
        AM[AssistantMessage]
        SM[SystemMessage]
        AT[AttachmentMessage]
        GT[GroupedToolUseMessage]
    end
    
    subgraph Render["渲染组件"]
        UTM[UserTextMessage]
        UTR[UserToolResultMessage]
        ATM[AssistantTextMessage]
        ATU[AssistantToolUseMessage]
        ATH[AssistantThinkingMessage]
        STM[SystemTextMessage]
        ATT[AttachmentMessage]
        GTC[GroupedToolUseContent]
    end
    
    UM --> UTM
    UM --> UTR
    AM --> ATM
    AM --> ATU
    AM --> ATH
    SM --> STM
    AT --> ATT
    GT --> GTC

AssistantMessageBlock 组件

对于 Assistant 消息,根据 content block 类型分发:

// Message.tsx 内部函数
function AssistantMessageBlock({ param, tools, ...props }) {
  switch (param.type) {
    case 'text':
      return <AssistantTextMessage 
        text={param.text} 
        shouldAnimate={shouldAnimate}
        width={width}
      />;
    
    case 'thinking':
      return <AssistantThinkingMessage 
        thinking={param.thinking}
        isTranscriptMode={isTranscriptMode}
      />;
    
    case 'tool_use':
      const tool = tools[param.name];
      return <AssistantToolUseMessage 
        toolUse={param}
        tool={tool}
        inProgress={inProgressToolUseIDs.has(param.id)}
        shouldAnimate={shouldAnimate}
      />;
    
    case 'redacted_thinking':
      return <AssistantRedactedThinkingMessage />;
    
    default:
      return null;
  }
}

10.3 输入组件

TextInput.tsx - 文本输入框

TextInput 是用户输入的主要入口,支持多种模式:

// TextInput.tsx
export type Props = BaseTextInputProps & {
  highlights?: TextHighlight[];  // 文本高亮区域
};

export default function TextInput(props: Props): React.ReactNode {
  const [theme] = useTheme();
  const isTerminalFocused = useTerminalFocus();
  const settings = useSettings();
  const reducedMotion = settings.prefersReducedMotion ?? false;
  
  // Voice 模式状态
  const voiceState = feature('VOICE_MODE') 
    ? useVoiceState(s => s.voiceState) 
    : 'idle' as const;
  const isVoiceRecording = voiceState === 'recording';
  const audioLevels = feature('VOICE_MODE') 
    ? useVoiceState(s => s.voiceAudioLevels) 
    : [];
  
  // 动画帧引用(语音波形)
  const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0));
  const needsAnimation = isVoiceRecording && !reducedMotion;
  const [animRef, animTime] = feature('VOICE_MODE') 
    ? useAnimationFrame(needsAnimation ? 50 : null) 
    : [() => {}, 0];
  
  // 剪贴板图片提示
  useClipboardImageHint(isTerminalFocused, !!props.onImagePaste);
  
  // 光标渲染:语音波形 vs 标准 inverse
  const canShowCursor = isTerminalFocused && !accessibilityEnabled;
  let invert: (text: string) => string;
  
  if (!canShowCursor) {
    invert = (text) => text;
  } else if (isVoiceRecording && !reducedMotion) {
    // 语音波形光标
    const smoothed = smoothedRef.current;
    const raw = audioLevels.length > 0 ? audioLevels[audioLevels.length - 1] : 0;
    const target = Math.min(raw * LEVEL_BOOST, 1);
    smoothed[0] = (smoothed[0] ?? 0) * SMOOTH + target * (1 - SMOOTH);
    const barIndex = Math.max(1, Math.min(
      Math.round(displayLevel * (BARS.length - 1)), 
      BARS.length - 1
    ));
    const hue = (animTime / 1000) * 90 % 360;
    const { r, g, b } = isSilent ? { r: 128, g: 128, b: 128 } : hueToRgb(hue);
    invert = () => chalk.rgb(r, g, b)(BARS[barIndex]);
  } else {
    invert = chalk.inverse;
  }
  
  // 文本输入状态管理
  const textInputState = useTextInput({
    value: props.value,
    onChange: props.onChange,
    onSubmit: props.onSubmit,
    onExit: props.onExit,
    onHistoryUp: props.onHistoryUp,
    onHistoryDown: props.onHistoryDown,
    multiline: props.multiline,
    cursorChar: props.showCursor ? ' ' : '',
    invert,
    themeText: color('text', theme),
    columns: props.columns,
    maxVisibleLines: props.maxVisibleLines,
    onImagePaste: props.onImagePaste,
    inlineGhostText: props.inlineGhostText,
  });
  
  return (
    <Box ref={animRef}>
      <BaseTextInput 
        inputState={textInputState}
        terminalFocus={isTerminalFocused}
        highlights={props.highlights}
        invert={invert}
        hidePlaceholderText={isVoiceRecording}
        {...props}
      />
    </Box>
  );
}

Tip

TextInput 支持语音模式下的动态波形光标:使用 useAnimationFrame 50ms 周期刷新,根据 audioLevels 计算波形高度,通过 EMA(指数移动平均)平滑过渡,避免抖动。

BaseTextInput.tsx - 输入基础组件

BaseTextInput 处理文本输入的核心逻辑:

// BaseTextInput.tsx
export function BaseTextInput({ inputState, terminalFocus, invert, ...props }) {
  const { onInput, renderedValue, cursorLine, cursorColumn } = inputState;
  
  // 光标位置声明
  const cursorRef = useDeclaredCursor({
    line: cursorLine,
    column: cursorColumn,
    active: Boolean(props.focus && props.showCursor && terminalFocus)
  });
  
  // 粘贴处理
  const { wrappedOnInput, isPasting } = usePasteHandler({
    onPaste: props.onPaste,
    onInput: (input, key) => {
      if (isPasting && key.return) return;  // 粘贴时忽略 Enter
      onInput(input, key);
    },
    onImagePaste: props.onImagePaste
  });
  
  // Placeholder 渲染
  const { showPlaceholder, renderedPlaceholder } = renderPlaceholder({
    placeholder: props.placeholder,
    value: props.value,
    showCursor: props.showCursor,
    focus: props.focus,
    terminalFocus,
    invert
  });
  
  // 注册输入监听
  useInput(wrappedOnInput, { isActive: props.focus });
  
  // 命令参数提示
  const commandWithoutArg = props.value?.trim().indexOf(" ") === -1;
  const showArgumentHint = Boolean(
    props.argumentHint && 
    commandWithoutArg && 
    props.value?.startsWith("/")
  );
  
  // 高亮过滤(排除光标位置)
  const cursorFiltered = props.highlights?.filter(h => 
    h.dimColor || 
    props.cursorOffset < h.start || 
    props.cursorOffset >= h.end
  );
  
  return (
    <Box flexDirection="column">
      {showPlaceholder && renderedPlaceholder}
      <HighlightedInput 
        value={renderedValue}
        highlights={filteredHighlights}
        invert={invert}
        ...
      />
      {showArgumentHint && <ArgumentHint />}
      {children}
    </Box>
  );
}

10.4 输出组件

StatusLine.tsx - 状态栏

StatusLine 显示会话状态信息(模型、Token、Cost 等):

// StatusLine.tsx
type Props = {
  messagesRef: React.RefObject<Message[]>;
  lastAssistantMessageId: string | null;
  vimMode?: VimMode;
};

function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props) {
  const settings = useSettings();
  const permissionMode = useAppState(s => s.toolPermissionContext.mode);
  const statusLineText = useAppState(s => s.statusLineText);
  
  // 构建状态栏命令输入
  const input = buildStatusLineCommandInput(
    permissionMode,
    exceeds200kTokens,
    settings,
    messages,
    addedDirs,
    mainLoopModel,
    vimMode
  );
  
  return (
    <Box flexDirection="row">
      <Text>{statusLineText || renderDefaultStatusLine(input)}</Text>
    </Box>
  );
}

// 状态栏数据结构
type StatusLineCommandInput = {
  session_name?: string;
  model: { id: string; display_name: string };
  workspace: {
    current_dir: string;
    project_dir: string;
    added_dirs: string[];
  };
  cost: {
    total_cost_usd: number;
    total_duration_ms: number;
    total_lines_added: number;
    total_lines_removed: number;
  };
  context_window: {
    total_input_tokens: number;
    total_output_tokens: number;
    context_window_size: number;
    used_percentage: number;
    remaining_percentage: number;
  };
  rate_limits?: {
    five_hour?: { used_percentage: number; resets_at: string };
    seven_day?: { used_percentage: number; resets_at: string };
  };
  worktree?: {
    name: string;
    path: string;
    branch: string;
  };
};

VirtualMessageList.tsx - 虚拟滚动列表

VirtualMessageList 实现高效的虚拟滚动,处理大量消息:

// VirtualMessageList.tsx
const HEADROOM = 3;  // scrollTo 时保留的顶部缓冲行数

export type StickyPrompt = {
  text: string;
  scrollTo: () => void;
} | 'clicked';

export type JumpHandle = {
  jumpToIndex: (i: number) => void;
  setSearchQuery: (q: string) => void;
  nextMatch: () => void;
  prevMatch: () => void;
  setAnchor: () => void;  // 搜索锚点
  warmSearchIndex: () => Promise<number>;  // 预热搜索缓存
  disarmSearch: () => void;  // 手动滚动退出搜索
};

type Props = {
  messages: RenderableMessage[];
  scrollRef: RefObject<ScrollBoxHandle>;
  columns: number;  // 宽度变化时失效缓存
  itemKey: (msg: RenderableMessage) => string;
  renderItem: (msg: RenderableMessage, index: number) => React.ReactNode;
  onItemClick?: (msg: RenderableMessage) => void;
  isItemClickable?: (msg: RenderableMessage) => boolean;
  isItemExpanded?: (msg: RenderableMessage) => boolean;
  extractSearchText?: (msg: RenderableMessage) => string;
  trackStickyPrompt?: boolean;
  jumpRef?: RefObject<JumpHandle>;
  onSearchMatchesChange?: (count: number, current: number) => void;
  scanElement?: (el: DOMElement) => MatchPosition[];
  setPositions?: (state: PositionsState | null) => void;
};

function stickyPromptText(msg: RenderableMessage): string | null {
  // WeakMap 缓存,避免重复计算
  const cached = promptTextCache.get(msg);
  if (cached !== undefined) return cached;
  
  const result = computeStickyPromptText(msg);
  promptTextCache.set(msg, result);
  return result;
}

function computeStickyPromptText(msg: RenderableMessage): string | null {
  if (msg.type === 'user') {
    if (msg.isMeta || msg.isVisibleInTranscriptOnly) return null;
    const block = msg.message.content[0];
    if (block?.type !== 'text') return null;
    // 去除系统提醒前缀
    const text = stripSystemReminders(block.text);
    if (text.startsWith('<')) return null;  // XML 包装内容不显示
    return text.slice(0, STICKY_TEXT_CAP);  // 截断超长文本
  }
  return null;
}

虚拟滚动注意事项

  • 高度缓存依赖 columns 参数,窗口宽度变化时需要失效
  • itemKey 函数用于 React 列表 diff,必须稳定唯一
  • 搜索文本使用 WeakMap 缓存,消息对象作为 key,自动 GC
  • 粘性提示文本有 500 字符上限,避免超大粘贴内容撑爆内存

Markdown.tsx - Markdown 渲染

Markdown 组件处理富文本渲染:

// Markdown.tsx(简化)
export function Markdown({ children, width, theme }: Props) {
  const parsed = parseMarkdown(children);
  
  return (
    <Box flexDirection="column" width={width}>
      {parsed.blocks.map((block, i) => {
        switch (block.type) {
          case 'paragraph':
            return <Text key={i}>{renderInline(block.content)}</Text>;
          
          case 'code':
            return <HighlightedCode 
              key={i}
              code={block.code}
              language={block.lang}
              theme={theme}
            />;
          
          case 'heading':
            return <Text key={i} bold>{block.content}</Text>;
          
          case 'list':
            return <ListItems items={block.items} />;
          
          case 'blockquote':
            return <Box borderStyle="round">
              <Text dimColor>{block.content}</Text>
            </Box>;
          
          case 'table':
            return <MarkdownTable data={block.data} />;
          
          default:
            return null;
        }
      })}
    </Box>
  );
}

10.5 交互组件

Spinner.tsx - 加载动画

Spinner 组件提供多种加载动画风格:

// Spinner.tsx
const FRAMES = {
  dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
  line: ['─', '━', '┃', '┏', '┓', '┫', '┣', '┳', '┻', '╋'],
  pipe: ['┏', '┓', '┛', '┗', '┏', '┓', '┛', '┗'],
  moon: ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'],
  circle: ['◜', '◠', '◝', '◞', '◟', '◡', '◟'],
  arrow: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
};

export function Spinner({ type = 'dots', color }: Props) {
  const [frameIndex, setFrameIndex] = useState(0);
  const frames = FRAMES[type];
  const interval = type === 'moon' ? 80 : 80;  // 月相稍慢
  
  useEffect(() => {
    if (reducedMotion) return;
    const timer = setInterval(() => {
      setFrameIndex(i => (i + 1) % frames.length);
    }, interval);
    return () => clearInterval(timer);
  }, [frames, interval, reducedMotion]);
  
  return (
    <Text color={color}>
      {reducedMotion ? '...' : frames[frameIndex]}
    </Text>
  );
}

交互流程图

sequenceDiagram
    participant User as 用户
    participant TI as TextInput
    participant BTI as BaseTextInput
    participant App as App Context
    participant API as API Layer
    participant VML as VirtualMessageList
    participant MSG as Message
    
    User->>TI: 输入文本
    TI->>BTI: useTextInput()
    BTI->>BTI: useInput() 监听
    BTI->>App: onSubmit()
    App->>API: 发送请求
    API-->>App: 流式响应
    
    loop 每个响应块
        App->>VML: 添加消息
        VML->>MSG: 渲染消息
        MSG->>MSG: 类型分发
    end
    
    VML-->>User: 更新显示
    User->>VML: 滚动/点击
    VML->>VML: 虚拟滚动优化

10.6 设计系统

design-system/ 组件

设计系统提供统一风格的 UI 基础组件:

// design-system/ThemedText.tsx
export function ThemedText({ color: colorKey, children, ...props }: Props) {
  const [theme] = useTheme();
  const textColor = color(colorKey, theme);
  
  return <Text color={textColor} {...props}>{children}</Text>;
}

// design-system/Dialog.tsx
export function Dialog({ title, children, onClose }: Props) {
  const [theme] = useTheme();
  
  return (
    <Box 
      flexDirection="column"
      borderStyle="round"
      borderColor={color('border', theme)}
    >
      <Box borderBottom>
        <ThemedText color="accent">{title}</ThemedText>
      </Box>
      <Box flexDirection="column">
        {children}
      </Box>
      <Box borderTop>
        <KeyboardShortcutHint keys={['Esc']} action="关闭" />
      </Box>
    </Box>
  );
}

// design-system/ListItem.tsx
export function ListItem({ selected, children, onSelect }: Props) {
  const [theme] = useTheme();
  const isHovered = useHover();
  
  return (
    <Box 
      backgroundColor={selected ? color('selection', theme) : undefined}
      onClick={onSelect}
    >
      <Text>
        {selected ? '❯' : ' '}
        {children}
      </Text>
    </Box>
  );
}

组件层级图

graph TD
    subgraph Providers["Context Providers"]
        TP[ThemeProvider]
        FP[FpsMetricsProvider]
        SP[StatsProvider]
        AP[AppStateProvider]
    end
    
    subgraph Layouts["布局组件"]
        FL[FullscreenLayout]
        SB[ScrollBox]
        PAN[Pane]
    end
    
    subgraph Primitives["基础组件"]
        TT[ThemedText]
        TB[ThemedBox]
        DLG[Dialog]
        LI[ListItem]
        TB[Tabs]
        PB[ProgressBar]
    end
    
    subgraph Composite["复合组件"]
        TI[TextInput]
        VML[VirtualMessageList]
        MSG[Message]
        SL[StatusLine]
    end
    
    TP --> TT
    TP --> TB
    TP --> DLG
    
    FL --> SB
    SB --> VML
    VML --> MSG
    
    TI --> TT
    MSG --> TT
    SL --> TT
    
    AP --> VML
    SP --> SL

10.7 状态管理与交互设计

AppState 状态结构

// state/AppState.ts
export type AppState = {
  // 工具权限上下文
  toolPermissionContext: {
    mode: PermissionMode;
    additionalWorkingDirectories: string[];
  };
  
  // 状态栏文本
  statusLineText: string | null;
  
  // 光标导航状态
  cursorNavState: MessageActionsState | null;
  
  // 会话状态
  sessionId: string;
  isPaused: boolean;
  
  // UI 状态
  vimMode: VimMode | null;
  pendingExit: boolean;
};

// 状态变更回调
export const onChangeAppState = (state: AppState) => {
  // 同步到持久化存储
  // 触发副作用
};

交互模式

graph TD
    subgraph InputModes["输入模式"]
        NM[Normal 模式<br/>默认输入]
        VM[Vim 模式<br/>Vi 键绑定]
        MM[Multiline 模式<br/>多行编辑]
        VMODE[Voice 模式<br/>语音输入]
    end
    
    subgraph Navigation["导航操作"]
        JK[j/k 滚动]
        GD[G/D 翻页]
        NK[n/N 搜索跳转]
        MMODE[m/M 标记跳转]
    end
    
    subgraph Commands["命令操作"]
        SC[/斜杠命令]
        ENTER[Enter 提交]
        ESC[Esc 退出]
        CTRL[Ctrl+C 中断]
    end
    
    NM --> VM
    VM --> JK
    VM --> GD
    VM --> NK
    NM --> MM
    NM --> VMODE
    
    NM --> SC
    NM --> ENTER
    NM --> ESC
    NM --> CTRL

Tip

交互设计遵循渐进增强原则:

  1. 基础模式:Normal 输入 + 标准键绑定
  2. 高级模式:Vim 键绑定 + 搜索导航
  3. 辅助模式:Multiline 编辑 + Voice 语音 每种模式独立开关,用户可按偏好组合。

10.8 性能优化策略

虚拟滚动优化

// VirtualMessageList.tsx 内部
const heightCache = new Map<string, number>();

function getItemHeight(msg: RenderableMessage, index: number): number {
  const key = itemKey(msg);
  const cached = heightCache.get(key);
  if (cached !== undefined && columnsValid) return cached;
  
  // 计算实际高度
  const height = computeMessageHeight(msg, width);
  heightCache.set(key, height);
  return height;
}

// 粘性提示追踪
function StickyTracker({ scrollTop, messages }: Props) {
  // 找到最后一个可见的用户提示
  const lastVisiblePrompt = findLastVisiblePrompt(scrollTop, messages);
  
  // 更新粘性状态
  updateStickyPrompt({
    text: stickyPromptText(lastVisiblePrompt),
    scrollTo: () => scrollToMessage(lastVisiblePrompt)
  });
}

渲染缓存

// 消息搜索文本缓存
const fallbackLowerCache = new WeakMap<RenderableMessage, string>();

function defaultExtractSearchText(msg: RenderableMessage): string {
  const cached = fallbackLowerCache.get(msg);
  if (cached !== undefined) return cached;
  
  const lowered = renderableSearchText(msg).toLowerCase();
  fallbackLowerCache.set(msg, lowered);
  return lowered;
}

// 光标位置缓存
const promptTextCache = new WeakMap<RenderableMessage, string | null>();

function stickyPromptText(msg: RenderableMessage): string | null {
  const cached = promptTextCache.get(msg);
  if (cached !== undefined) return cached;
  
  const result = computeStickyPromptText(msg);
  promptTextCache.set(msg, result);
  return result;
}

Note

缓存策略使用 WeakMap,以消息对象作为 key:

  • 消息对象生命周期与组件树绑定
  • 消息被移除时自动 GC,无需手动清理
  • 避免内存泄漏,适合动态列表场景

下一章将探讨 服务层架构,分析 MCP、API、OAuth 等服务的实现原理。