AI 教程系列-TUI 应用开发教程05-大模型对话界面设计

2 阅读19分钟

TUI应用开发教程完全由ai生成,所使用的提示词存放在:github.com/hhk-png/cyc… code。

可以直接查看 github.com/hhk-png/cyc… 来阅读全部的章节

文章封面来自:bing.ioliu.cn/。

第五章 大模型对话界面设计

5.1 大模型对话 UI 的核心需求

一个面向大模型的 TUI 对话界面需要满足丰富的交互场景。下表列出了从用户视角出发的完整需求:

场景用户期望UI 表现
用户发送消息输入便捷,快捷键流畅文本输入框 + 一键发送
AI 思考中反馈正在处理,等待有预期动态 Spinner 动画 + 状态文本
AI 生成答案实时看到进展,无需等待完整输出流式输出的打字机效果
AI 调用工具透明展示工具使用过程工具调用卡片(调用→执行→结果)
工具返回结果展示中间数据,不打断主流程结果卡片可折叠,保持上下文连续
发生错误明确提示,有恢复路径醒目的错误卡片 + 重试建议
多轮对话无缝查看历史上下文消息历史可滚动视图
取消生成用户可以控制中断取消快捷键 + 即时响应
长文本阅读不用手动持续滚动自动滚动到底部
输入历史可以回溯之前输入的内容输入框 ↑↓ 切换历史
对话导出保存当前对话内容文件导出功能
Token 计数了解 API 用量状态栏显示计数

5.2 布局设计

5.2.1 经典四分区布局

大模型对话 TUI 通常采用以下四分区布局:

┌─────────────────────────────────────────────────────┐
│ 状态栏: 🤖 AI Chat  |  模型: MockLLM-1.0  |  就绪   │  ← 固定 1 行
├─────────────────────────────────────────────────────┤
│                                                     │
│  ┌─ 消息历史 ─────────────────────────────────────┐  │
│  │ 👤 用户: 你好                                  │  │
│  │ 🤖 AI: 你好!有什么可以帮助你的?                │  │  ← 可滚动
│  │ ┌─ 🔧 工具调用 ──────────────────────────┐     │  │
│  │ │ get_weather(city: "Beijing")           │     │  │
│  │ │ ⏳ 执行中...                            │     │  │
│  │ └─────────────────────────────────────────┘     │  │
│  │ ❌ 错误: 服务不可用                             │  │
│  │ 🤖 AI: 正在生成回答... ▊                       │  │
│  └─────────────────────────────────────────────────┘  │
│                                                     │
├─────────────────────────────────────────────────────┤
│ > 输入消息...                              [Ctrl+S] │  ← 固定 3 行
├─────────────────────────────────────────────────────┤
│ Ctrl+S 发送  Ctrl+Q 退出  Ctrl+C 取消  ↑↓ 滚动      │  ← 固定 1 行
└─────────────────────────────────────────────────────┘

5.2.2 布局层级(组件树)

// 布局结构
screen (blessed.Screen)
├── statusBar      (top: 0,     height: 1)     ← 状态栏
├── chatBox        (top: 1,     bottom: 5)      ← 消息区(占据所有剩余空间)
│   ├── userMessage     (blessed.Box)
│   ├── assistantMessage(blessed.Box)
│   ├── toolCallCard    (blessed.Box)
│   ├── toolResultCard  (blessed.Box)
│   ├── errorCard       (blessed.Box)
│   ├── thinkingSpinner (blessed.Box)
│   └── spacers         (blessed.Box)           ← 消息间隔
├── inputBox       (bottom: 2,  height: 3)      ← 输入区
└── helpBar        (bottom: 0,  height: 1)      ← 帮助栏

5.2.3 布局代码实现

private buildLayout(): void {
  // 1. 状态栏(固定顶部1行)
  this.statusBar = blessed.box({
    parent: this.screen,
    top: 0, left: 0, width: "100%", height: 1,
    content: " 🤖 AI Chat TUI  |  模型: MockLLM-1.0  |  {green-fg}● 就绪{/green-fg}",
    style: { fg: "white", bg: "#2255aa" },
    tags: true,
  });

  // 2. 聊天区域(可滚动,占据中间全部空间)
  this.chatBox = blessed.box({
    parent: this.screen,
    top: 1, left: 0, width: "100%", bottom: 5,
    scrollable: true,
    alwaysScroll: true,
    scrollbar: {
      ch: "░",
      track: { bg: "#222222" },
      style: { bg: "#888888" },
    },
    style: { fg: "white", bg: "#1a1a1a" },
    tags: true,
    padding: { left: 1, right: 1 },
  });

  // 3. 输入框(固定底部3行)
  this.inputBox = blessed.textarea({
    parent: this.screen,
    bottom: 2, left: 0, width: "100%", height: 3,
    inputOnFocus: true,
    padding: { left: 1, right: 1 },
    style: {
      fg: "white", bg: "#0d0d0d",
      border: { fg: "#44aa44" },
      focus: { border: { fg: "#66dd66" } },
    },
    border: { type: "line", fg: "#44aa44" },
  });

  // 4. 帮助栏(固定底部1行)
  this.helpBar = blessed.box({
    parent: this.screen,
    bottom: 0, left: 0, width: "100%", height: 1,
    content: " {green-fg}Ctrl+S{/green-fg} 发送  ...",
    style: { fg: "#888888", bg: "#0a0a0a" },
    tags: true,
  });
}

5.2.4 布局适配策略

不同终端尺寸需要不同的布局策略:

终端宽度布局策略适用场景
≥120 列宽屏:可考虑侧边栏 + 主区域大屏开发
80-119 列标准:上下分栏大多数场景
60-79 列紧凑:减少内边距分屏工作
<60 列警告:无法显示完整 UI需提示用户
private adaptLayout(): void {
  const width = this.screen.width as number;
  if (width < 60) {
    this.showSizeWarning();
  } else {
    // 正常布局
    this.chatBox.setContent(this.chatBox.getContent()); // 触发重绘
  }
}

5.3 色彩设计

合理的色彩方案能显著提升可用性和美观度。以下是一个经过验证的大模型对话 TUI 主题:

const Theme = {
  // ── 消息角色颜色 ──
  user:      { fg: '#88ccff', bg: '#1a1a1a', prefix: '👤 用户' },
  assistant: { fg: '#88ff88', bg: '#1a1a1a', prefix: '🤖 AI' },
  system:    { fg: '#ffaa44', bg: '#1a1a1a', prefix: '💬 系统' },

  // ── 工具调用 ──
  toolCall:  { fg: '#cc88ff', bg: '#1a1a1a', prefix: '🔧 工具调用' },
  toolResult:{ fg: '#66bbaa', bg: '#1a1a1a', prefix: '✅ 工具结果' },

  // ── 状态 ──
  error:     { fg: '#ff4444', bg: '#1a1a1a', prefix: '❌ 错误' },
  thinking:  { fg: '#ffaa00', bg: '#1a1a1a', prefix: '💭 思考' },

  // ── UI 元素 ──
  input:     { border: '#44aa44', focus: '#66dd66' },
  status:    { bg: '#2255aa', fg: 'white' },
};

色彩设计原则

  1. 语义化 — 颜色传达含义(绿=正常、红=错误、黄=警告、紫=工具)
  2. 高对比度 — 前景色与背景色对比度 ≥ 4.5:1
  3. 有限调色板 — 核心颜色不超过 8 种,避免眼花缭乱
  4. 暗色优先 — 终端应用以深色背景为主,降低视觉疲劳
  5. 一致性 — 相同语义使用相同的颜色,贯穿整个应用

颜色对比度速查表

前景背景对比度可读性
#ffffff (白)#1a1a1a (深灰)13.7:1⭐⭐⭐ 极佳
#88ff88 (亮绿)#1a1a1a (深灰)8.5:1⭐⭐⭐ 极佳
#ff4444 (红)#1a1a1a (深灰)5.2:1⭐⭐ 良好
#888888 (灰)#1a1a1a (深灰)3.5:1⚠️ 勉强
#666666 (深灰)#1a1a1a (深灰)2.3:1❌ 较差

5.4 消息模型

5.4.1 数据模型定义

type MessageRole = 'user' | 'assistant' | 'system' | 'tool';

/** 工具调用信息 */
interface ToolCall {
  name: string;                          // 工具名称,如 get_weather
  arguments: Record<string, unknown>;    // 调用参数
  result?: string;                       // 工具返回结果
  status: 'pending' | 'running' | 'completed' | 'error';
  error?: string;                        // 工具错误信息
}

/** 聊天消息 */
interface ChatMessage {
  id: string;                            // 消息唯一标识
  role: MessageRole;
  content: string;                       // 消息文本内容
  timestamp: Date;
  toolCall?: ToolCall;                   // 关联的工具调用
  error?: string;                        // 错误信息
  isStreaming?: boolean;                 // 是否正在流式输出
  tokens?: number;                       // 该消息的 token 数
}

5.4.2 消息在 UI 中的渲染

private renderMessage(msg: ChatMessage): void {
  let content: string;

  switch (msg.role) {
    case 'user':
      content = `{cyan-fg}{bold}👤 用户{/bold}{/cyan-fg}\n${this.escapeTags(msg.content)}`;
      break;
    case 'assistant':
      content = `{green-fg}{bold}🤖 AI{/bold}{/green-fg}\n${this.escapeTags(msg.content)}`;
      break;
    case 'system':
      content = `{yellow-fg}{bold}💬 系统{/bold}{/yellow-fg}\n${msg.content}`;
      break;
    case 'tool':
      content = this.buildToolCard(msg);
      break;
  }

  const el = blessed.box({
    parent: this.chatBox,
    top: 0, left: 0, width: '100%-2', height: 'shrink',
    content,
    style: { fg: 'white', bg: '#1a1a1a' },
    tags: true, wrap: true, shrink: true,
  });
}

5.5 状态机设计

对话 TUI 的核心是状态管理。使用状态机可以清晰地表达各种状态及合法转换。

5.5.1 状态定义

type AppState =
  | 'idle'         // 等待用户输入
  | 'thinking'     // AI 正在思考
  | 'streaming'    // AI 正在输出文本
  | 'tool_call'    // AI 正在调用工具
  | 'tool_result'  // 工具返回结果
  | 'error';       // 发生错误

5.5.2 状态转换图

                  ┌──────────┐
                  │   idle   │ ◄────── 等待用户输入
                  └────┬─────┘
                       │ 用户发送消息
                       ▼
                 ┌───────────┐
                 │ thinking  │ ◄────── AI 正在思考
                 └─────┬─────┘
                       │ 开始生成文本
                       ▼
                 ┌───────────┐
          ┌──────│ streaming │ ◄────── AI 正在输出
          │      └─────┬─────┘
          │            │ 需要调用工具
          │            ▼
          │      ┌───────────┐
          │      │ tool_call │ ◄────── 正在调用工具
          │      └─────┬─────┘
          │            │ 工具返回
          │            ▼
          │      ┌───────────┐
          │      │tool_result│ ◄────── 展示工具结果
          │      └─────┬─────┘
          │            │ 继续生成
          │            ▼
          │      ┌───────────┐
          └──────│ streaming │ (回到流式输出继续生成)
                 └─────┬─────┘
                       │ 生成完毕
                       ▼
                 ┌───────────┐
                 │   done    │ ◄────── 响应完成
                 └─────┬─────┘
                       │
                       ▼
                 ┌───────────┐
                 │   idle    │ ◄────── 回到就绪

   任何状态可能发生错误:
   ┌────────┐
   │ error  │ ◄── 从 thinking/streaming/tool_call 均可进入
   └────┬───┘
        │ 用户确认
        ▼
     ┌──────┐
     │ idle │
     └──────┘

5.5.3 状态机实现

class StateMachine {
  private state: AppState = 'idle';
  private readonly log: (msg: string) => void;

  /** 合法状态转换表 */
  private readonly transitions: Record<AppState, AppState[]> = {
    idle:        ['thinking'],
    thinking:    ['streaming', 'tool_call', 'error'],
    streaming:   ['tool_call', 'tool_result', 'error', 'done'],
    tool_call:   ['tool_result', 'error'],
    tool_result: ['streaming', 'error'],
    done:        ['idle'],
    error:       ['idle'],
  };

  transition(to: AppState): boolean {
    const allowed = this.transitions[this.state];
    if (!allowed?.includes(to)) {
      console.error(`非法状态转换: ${this.state}${to}`);
      return false;
    }
    console.log(`状态: ${this.state}${to}`);
    this.state = to;
    return true;
  }

  canTransition(to: AppState): boolean {
    return this.transitions[this.state]?.includes(to) ?? false;
  }

  get current(): AppState {
    return this.state;
  }

  reset(): void {
    this.state = 'idle';
  }
}

5.6 事件驱动架构

5.6.1 LLM 事件定义

/** LLM 流式事件 —— 从 Mock 或真实 API 发出 */
type LLMEvent =
  | { type: 'thinking' }                                   // AI 开始思考
  | { type: 'text'; content: string }                      // 文本片段(增量)
  | { type: 'tool_call'; name: string; args: Record<string, unknown> }  // 工具调用
  | { type: 'tool_result'; name: string; result: string }  // 工具结果
  | { type: 'error'; message: string }                     // 错误发生
  | { type: 'done' };                                      // 生成完成

5.6.2 事件消费循环

每进入一种 UI 状态,TUI 需要执行以下步骤:

收到事件
  ↓
更新状态 (state.transition(event.type))
  ↓
更新状态栏 (setStatus)
  ↓
确保 UI 元素正确 (创建/移除/更新)
  ↓
渲染 (screen.render())
  ↓
滚动到底部 (scrollToBottom)
private async processStream(userMsg: string): Promise<void> {
  for await (const event of mockLLMStream(userMsg)) {
    switch (event.type) {
      case 'thinking':
        this.stateMachine.transition('thinking');
        this.setStatus('🤔 思考中...', '#aa8800');
        this.startSpinner();
        break;

      case 'text':
        if (this.stateMachine.current === 'thinking' ||
            this.stateMachine.current === 'tool_result') {
          this.stopSpinner();
          this.clearToolDisplay();
        }
        this.stateMachine.transition('streaming');
        fullResponse += event.content;
        this.updateAssistantMessage(fullResponse);
        break;

      case 'tool_call':
        this.stateMachine.transition('tool_call');
        this.setStatus(`🔧 调用工具: ${event.name}`, '#8844aa');
        this.stopSpinner();
        this.showToolCall(event.name, event.args);
        break;

      // ... 其他事件
    }
  }
}

5.7 流式响应的交互设计

流式响应(Streaming)是大模型对话中的核心体验,设计上需要精细把控:

用户发送消息
  ↓ 即时反馈
显示 Spinner + "AI 思考中..."
  ↓
第一个文本 token 到达
  ↓ 平滑过渡
Spinner 消失,文本开始逐字出现
  ↓ 实时更新
文本不断追加,光标跟随
  ↓
工具调用触发 → 文本暂停,显示工具卡片
  ↓
工具返回 → 展示结果,继续文本输出
  ↓
输出完毕 → 状态回到 idle,输入框可用

关键设计决策

决策选择理由
流式期间输入框保持可见但发送禁用用户可以回顾已发送消息
工具卡片收缩式展示,不占太多空间保持对话连续性
错误处理保留已输出内容,末尾追加错误不丢失已有结果
取消机制Ctrl+C 发送 abort 信号用户控制权
自动滚动新内容到达时自动滚到底部始终看到最新内容
历史回顾用户可以手动向上滚动不丢失上下文
输入框状态生成期间禁用,完成后自动聚焦防止误操作

5.8 输入框交互增强

5.8.1 输入历史

使用 ↑↓ 键切换历史输入:

class InputWithHistory {
  private history: string[] = [];
  private historyIndex = -1;
  private readonly MAX_HISTORY = 50;

  constructor(private inputBox: Widgets.TextareaElement) {
    this.bindKeys();
  }

  private bindKeys(): void {
    this.inputBox.key('up', () => {
      if (this.historyIndex < this.history.length - 1) {
        this.historyIndex++;
        this.inputBox.setValue(this.history[this.historyIndex]);
        this.inputBox.focus();
      }
    });

    this.inputBox.key('down', () => {
      if (this.historyIndex > 0) {
        this.historyIndex--;
        this.inputBox.setValue(this.history[this.historyIndex]);
      } else if (this.historyIndex === 0) {
        this.historyIndex = -1;
        this.inputBox.clearValue();
      }
      this.inputBox.focus();
    });
  }

  addToHistory(text: string): void {
    this.history.unshift(text);
    if (this.history.length > this.MAX_HISTORY) {
      this.history.pop();
    }
    this.historyIndex = -1;
  }
}

5.8.2 多行输入

默认 textarea 支持多行输入,Enter 换行,Ctrl+S 发送:

this.screen.key('C-s', () => {
  // Ctrl+S: 发送(而非默认的 Enter)
  if (this.state === 'idle') {
    const text = this.inputBox.getValue().trim();
    if (text) {
      this.sendMessage(text);
    }
  }
});

// Enter 仅换行,不发送
// 这需要阻止 blessed 的默认 Enter 行为

5.9 响应式布局与终端适配

不同终端尺寸需要不同的布局策略。TUI 应用必须主动适配,而非假设固定尺寸。

5.9.1 自适应布局策略

type LayoutMode = 'wide' | 'standard' | 'compact' | 'minimal';

function detectLayoutMode(screen: Widgets.Screen): LayoutMode {
  const width = screen.width as number;
  if (width >= 120) return 'wide';
  if (width >= 80) return 'standard';
  if (width >= 60) return 'compact';
  return 'minimal';
}

class AdaptiveLayout {
  private mode: LayoutMode = 'standard';

  constructor(private screen: Widgets.Screen) {
    this.screen.on('resize', () => this.adapt());
  }

  private adapt(): void {
    this.mode = detectLayoutMode(this.screen);
    switch (this.mode) {
      case 'wide':
        // 宽屏:显示侧边栏、更多信息
        this.showSidebar(true);
        this.setPadding(2);
        break;
      case 'standard':
        // 标准:正常布局
        this.showSidebar(false);
        this.setPadding(1);
        break;
      case 'compact':
        // 紧凑:减少边距
        this.showSidebar(false);
        this.setPadding(0);
        break;
      case 'minimal':
        // 最小:只保留核心
        this.showSidebar(false);
        this.setPadding(0);
        this.showWarning(true);
        break;
    }
  }
}

5.9.2 布局模式对比

模式最小宽度侧边栏内边距状态栏信息
wide≥120 列显示会话列表2完整
standard≥80 列隐藏1标准
compact≥60 列隐藏0精简
minimal<60 列隐藏0仅警告

5.10 输入法(IME)兼容性

CJK(中日韩)用户在 TUI 中需要特别注意输入法兼容性。IME 涉及三个核心问题:组合输入状态检测、双宽字符渲染、候选窗口遮挡。

IME 输入的问题

问题 1: 输入法候选窗口可能遮挡 TUI 界面
影响: 用户无法看到候选词,输入体验差
解决: 将输入框固定在底部,IME 候选窗口自然显示在终端之上

问题 2: IME 组合状态与快捷键冲突
影响: 用户使用 Ctrl+S 发送时触发输入法功能而非发送
解决: 检测 composition 状态,组合中禁用快捷键

问题 3: CJK 双宽字符导致光标偏移
影响: 删除、导航时字符截断或错位
解决: 使用 width 而非 length 计算字符宽度

CJK 字符宽度计算

CJK 字符在终端中占用两个英文字符的宽度。如果使用 JavaScript 的 length 属性计算,会导致光标错位、换行错乱:

/**
 * 计算字符串在终端中的实际显示宽度
 * 中文字符占 2 列,英文字符占 1 列
 */
function terminalWidth(str: string): number {
  let width = 0;
  for (const ch of str) {
    const code = ch.charCodeAt(0);
    if (code >= 0x1100 && (
      code <= 0x115f ||                    // Hangul Jamo
      code === 0x2329 || code === 0x232a ||
      (code >= 0x2e80 && code <= 0x9fff) || // CJK 统一表意文字
      (code >= 0x3000 && code <= 0x303f) || // CJK 符号标点
      (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
      (code >= 0xff00 && code <= 0xffef)    // 全角 ASCII
    )) {
      width += 2;
    } else {
      width += 1;
    }
  }
  return width;
}

/**
 * 按终端宽度截断字符串,确保不截断半个 CJK 字符
 */
function truncateToWidth(str: string, maxWidth: number): string {
  let result = '';
  let currentWidth = 0;
  for (const ch of str) {
    const chWidth = terminalWidth(ch);
    if (currentWidth + chWidth > maxWidth) break;
    result += ch;
    currentWidth += chWidth;
  }
  return result;
}

/**
 * 将字符串填充到指定终端宽度(用于对齐)
 */
function padToWidth(str: string, targetWidth: number): string {
  const strWidth = terminalWidth(str);
  const padding = Math.max(0, targetWidth - strWidth);
  return str + ' '.repeat(padding);
}

IME 组合输入处理

当输入法处于组合状态时,按键事件不应被 TUI 作为快捷键消费:

class IMEHandler {
  private isComposing = false;

  constructor(private inputBox: Widgets.TextareaElement) {
    this.setupCompositionHandling();
  }

  private setupCompositionHandling(): void {
    // blessed 不直接暴露 composition 事件
    // 通过在 keypress 中检测序列模式间接判断
    this.inputBox.on('keypress', (ch: string, key: { name: string; sequence: string }) => {
      // IME 组合状态下,key.sequence 可能包含临时拼音字符串
      // 此时不应触发任何全局快捷键
      if (this.isComposing && key.name && !['backspace', 'enter'].includes(key.name)) {
        return false; // 阻止事件传播到全局处理器
      }
    });
  }

  /** 通知系统 IME 开始组合 */
  notifyCompositionStart(): void {
    this.isComposing = true;
  }

  /** 通知系统 IME 结束组合 */
  notifyCompositionEnd(): void {
    this.isComposing = false;
  }

  /** 检查是否正在 IME 组合中 */
  get isActive(): boolean {
    return this.isComposing;
  }

  /** 安全设置值(组合中不打断 IME) */
  setValueSafe(text: string): void {
    if (!this.isComposing) {
      this.inputBox.setValue(text);
    }
  }
}

// 在快捷键处理器中检查 IME 状态
screen.key('C-s', () => {
  if (imeHandler.isActive) {
    // IME 组合中,不发送消息
    return;
  }
  // ... 正常发送逻辑
});

输入框实现

class IMECompatibleInput {
  private ime: IMEHandler;

  constructor(private inputBox: Widgets.TextareaElement) {
    // 确保输入框始终在屏幕底部
    // IME 候选窗口在终端程序之上弹出,底部位置不会被 TUI 遮挡
    this.inputBox.bottom = 2;
    this.inputBox.height = 3;

    this.ime = new IMEHandler(inputBox);

    // CJK 退格删除处理
    this.setupCJKDelete();
  }

  private setupCJKDelete(): void {
    this.inputBox.key('backspace', () => {
      const value = this.inputBox.getValue();
      // 如果光标前是 CJK 字符,blessed 的默认退格可能只删除一列
      // 需要额外处理
      const cursor = (this.inputBox as any).getCursor?.()?.x ?? value.length;
      if (cursor > 0) {
        const charBefore = value[cursor - 1];
        if (terminalWidth(charBefore) === 2) {
          // CJK 字符:blessed 默认退格可能处理不当
          // 手动设置值
          const newValue = value.slice(0, cursor - 1) + value.slice(cursor);
          this.inputBox.setValue(newValue);
          return false; // 阻止默认行为
        }
      }
      return undefined; // 继续默认行为
    });
  }

  /** 获取 IME 组合中的文本 */
  getComposingText(): string {
    return this.inputBox.getValue();
  }
}

IME 兼容设计原则

原则说明
textarea 优先始终使用 textarea 而非 textbox,原生支持 IME 组合输入
底部固定输入框固定在界面底部,IME 候选窗口在终端之上弹出,不被 TUI 内容遮挡
组合保护IME 组合期间禁用所有全局快捷键,防止误触
双宽对齐所有字符串宽度计算使用 terminalWidth() 而非 .length
不中断 IME流式响应期间不主动 setValue()clearValue() 输入框
最小重绘IME 组合输入期间尽可能减少 screen.render() 调用,避免闪烁

5.11 无障碍(Accessibility)设计

TUI 相比 GUI 具有天然的无障碍优势——屏幕阅读器可以直接读取文本内容。但 TUI 开发者仍需主动进行无障碍适配。

屏幕阅读器兼容

大多数终端屏幕阅读器(NVDA、JAWS、Orca、VoiceOver)通过截取终端输出来朗读内容:

class AccessibilityManager {
  private enabled = false;

  constructor() {
    // 检测屏幕阅读器环境
    // 环境变量由各屏幕阅读器设置(非正式标准,但被广泛使用)
    this.enabled = !!(
      process.env.NVDA || process.env.JAWS ||
      process.env.VOICE_OVER || process.env.ORCA ||
      process.env.SPEECH_SYNTHESIS || process.env.SCREEN_READER
    );
  }

  /** 向屏幕阅读器发送无障碍通知 */
  announce(message: string): void {
    if (!this.enabled) return;
    // 1. 移除 ANSI 控制序列(屏幕阅读器可能朗读控制字符)
    const cleanMsg = message.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');

    // 2. 设置终端标题(屏幕阅读器通常关注标题变化)
    process.stdout.write(`\x1b]0;AI Chat: ${cleanMsg}\x07`);

    // 3. 也设置 process.title 作为备选
    process.title = `AI Chat TUI: ${cleanMsg}`;
  }

  /** 状态变化时通知屏幕阅读器 */
  onStateChange(state: string): void {
    const stateMap: Record<string, string> = {
      idle: '就绪状态,可以输入',
      thinking: 'AI 思考中,请等待',
      streaming: 'AI 正在生成回复',
      tool_call: '正在调用工具',
      tool_result: '工具已返回结果',
      error: '发生错误',
    };
    this.announce(stateMap[state] || state);
  }

  /** 生成屏幕阅读器友好的文本(去掉装饰) */
  static accessibleLabel(content: string): string {
    return content
      .replace(/[┌─┐└┘│╱╲╭╮╯╰]/g, ' ')  // 移除边框字符
      .replace(/\{[^}]+\}/g, '')             // 移除 blessed 标签
      .replace(/\s+/g, ' ')                  // 合并空白
      .trim();
  }
}

高对比度模式

提供高对比度主题,满足低视力和对比敏感度障碍用户需求:

const HighContrastTheme = {
  // 使用白底黑字或黑底白字的高对比度组合
  user:      { fg: '#ffffff', bg: '#000000', prefix: '用户' },
  assistant: { fg: '#ffffff', bg: '#003300', prefix: 'AI' },
  system:    { fg: '#000000', bg: '#ffcc00', prefix: '系统' },
  toolCall:  { fg: '#ffffff', bg: '#220044', prefix: '工具' },
  toolResult:{ fg: '#ffffff', bg: '#003333', prefix: '结果' },
  error:     { fg: '#ffffff', bg: '#880000', prefix: '错误' },

  // UI 元素使用粗边框 + 高亮
  input:     { border: '#ffffff', focus: '#ffff00' },
  status:    { bg: '#000000', fg: '#ffffff' },
};

/** 检测是否应启用高对比度模式 */
function detectHighContrast(): boolean {
  // 1. 检查 COLORFGBG 环境变量(某些终端设置白底)
  const colorFgBg = process.env.COLORFGBG;
  if (colorFgBg) {
    const [, bg] = colorFgBg.split(';');
    if (bg === '15' || bg === '231') return true;
  }
  // 2. 自定义环境变量
  if (process.env.TUI_HIGH_CONTRAST === '1') return true;
  return false;
}

// 应用适当主题
const theme = detectHighContrast() ? HighContrastTheme : NormalTheme;

焦点可见性

确保焦点位置始终明确可见:

function ensureFocusVisibility(screen: Widgets.Screen): void {
  screen.on('element focus', (el: Widgets.BlessedElement) => {
    // 高亮焦点元素
    if (el.type === 'textarea') {
      (el as Widgets.TextareaElement).style.border = { fg: '#ffff00' as any };
    }
    screen.render();
  });

  screen.on('element blur', (el: Widgets.BlessedElement) => {
    // 恢复默认样式
    if (el.type === 'textarea') {
      (el as Widgets.TextareaElement).style.border = { fg: '#44aa44' as any };
    }
    screen.render();
  });
}

无障碍设计清单

核心要求(必须满足):
□ 所有功能可以通过键盘完成(无鼠标依赖)
□ 焦点顺序符合阅读逻辑(从上到下,从左到右)
□ 颜色使用有辅助文本标识(不依赖颜色传达信息)
□ 状态变化有文本提示(非仅颜色变化)
□ 错误信息有清晰的文本描述和恢复路径
□ 加载/忙碌状态有文本指示器(不仅是 Spinner)

增强要求(推荐实现):
□ ANSI 控制序列不会干扰屏幕阅读器
□ 屏幕尺寸变化时布局自适应
□ 支持高对比度模式
□ 所有交互区域有明确的焦点指示
□ Spinner 动画频率不超过 5Hz(防癫痫)
□ 图标(Emoji)带有文本标签作为后备
□ 可在设置中切换无障碍模式

5.12 色盲友好的色彩方案

TUI 应用中颜色被广泛用于传达状态和角色信息,但约 8% 的男性和 0.5% 的女性有不同程度的色觉障碍。设计色盲友好的配色方案至关重要。

色盲类型与配色影响

色盲类型占比难区分的颜色对默认主题的影响
红色盲(Protanopia)~1% 男性红/绿、红/棕Error(红) 与 AI(绿) 混淆
绿色盲(Deuteranopia)~6% 男性红/绿、紫/蓝Tool(紫) 与 User(蓝) 混淆
蓝色盲(Tritanopia)<1% 人群蓝/绿、黄/白User(蓝) 与 AI(绿) 混淆
全色盲(Achromatopsia)~0.003%全部颜色所有颜色无法区分

色盲友好的配色方案

使用颜色 + 符号 + 文本的三重编码策略:

// 色盲友好的颜色方案 —— 使用色相和亮度双重编码
const COLORBLIND_SAFE_THEME = {
  // 使用颜色 + 符号 + 文本的三重编码
  // 1. 颜色: 快速视觉识别
  // 2. Emoji: 辅助颜色识别
  // 3. 前缀文字: 最终标识
  user:      { fg: '#4a9eff', bg: '#1a1a1a', prefix: '👤 用户', indicator: '▌' },
  assistant: { fg: '#44cc44', bg: '#1a1a1a', prefix: '🤖 AI',   indicator: '┃' },
  error:     { fg: '#ff6666', bg: '#1a1a1a', prefix: '❌ 错误', indicator: '█' },

  // 通过亮度而非仅色相区分
  toolCall:  { fg: '#ff88cc', bg: '#1a1a1a', prefix: '🔧 工具', indicator: '▒' },
  toolResult:{ fg: '#44ddbb', bg: '#1a1a1a', prefix: '✅ 结果', indicator: '▐' },
};

色盲模拟验证工具

开发时使用模拟工具验证配色的可分辨性:

/**
 * LMS 色盲模拟 —— 将颜色转换为色盲所见的效果
 * 用于调试和验证配色方案是否色盲友好
 */
function simulateColorBlind(
  hex: string,
  type: 'protanopia' | 'deuteranopia' | 'tritanopia'
): string {
  // 将 hex 转为 RGB
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);

  // 色盲模拟变换矩阵(Vienot, Brettel 算法简化版)
  const matrices: Record<string, [number, number, number][]> = {
    protanopia: [
      [0.567, 0.433, 0],
      [0.558, 0.442, 0],
      [0, 0.242, 0.758],
    ],
    deuteranopia: [
      [0.625, 0.375, 0],
      [0.7, 0.3, 0],
      [0, 0.3, 0.7],
    ],
    tritanopia: [
      [0.95, 0.05, 0],
      [0, 0.433, 0.567],
      [0, 0.475, 0.525],
    ],
  };

  const m = matrices[type];
  const nr = Math.round(r * m[0][0] + g * m[0][1] + b * m[0][2]);
  const ng = Math.round(r * m[1][0] + g * m[1][1] + b * m[1][2]);
  const nb = Math.round(r * m[2][0] + g * m[2][1] + b * m[2][2]);

  return `#${Math.min(255, Math.max(0, nr)).toString(16).padStart(2, '0')}${
    Math.min(255, Math.max(0, ng)).toString(16).padStart(2, '0')}${
    Math.min(255, Math.max(0, nb)).toString(16).padStart(2, '0')}`;
}

/** 验证一组颜色在指定色盲类型下的最小色差 */
function validateColorBlindPair(
  color1: string,
  color2: string,
  type: 'protanopia' | 'deuteranopia' | 'tritanopia'
): { pass: boolean; deltaE: number } {
  const sim1 = simulateColorBlind(color1, type);
  const sim2 = simulateColorBlind(color2, type);
  const deltaE = colorDifference(sim1, sim2);
  return { pass: deltaE > 20, deltaE }; // deltaE > 20 表示可区分
}

/** 简易色差计算(CIE76 简化版) */
function colorDifference(hex1: string, hex2: string): number {
  const [r1, g1, b1] = hexToRgb(hex1);
  const [r2, g2, b2] = hexToRgb(hex2);
  return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2);
}

function hexToRgb(hex: string): [number, number, number] {
  return [
    parseInt(hex.slice(1, 3), 16),
    parseInt(hex.slice(3, 5), 16),
    parseInt(hex.slice(5, 7), 16),
  ];
}

// 使用示例:验证默认主题中 user 和 assistant 颜色
console.log(validateColorBlindPair('#88ccff', '#88ff88', 'deuteranopia'));
// { pass: true, deltaE: 28.3 } <- 可区分,通过

色盲适配设计原则

  1. 图标 + 颜色双重编码 — 每种消息类型都有独特的图标(👤 🤖 🔧 ✅ ❌),颜色只是辅助,移除颜色后仍可辨识
  2. 位置编码 — 用户消息靠左,AI 消息适度缩进,通过位置区分不同角色
  3. 亮度梯度 — 不同角色的前景色亮度不同(user=65%, assistant=75%, error=55%),即使色觉失常也能依亮度分辨
  4. 纹理和边框 — 工具卡片使用圆角边框(┌─┐),错误卡片使用实心边框(█),提供颜色之外的视觉差异
  5. 避免红绿搭配 — 如需同时使用红和绿,为其中之一增加粗体或附加符号
  6. 提供主题切换 — 允许用户在正常主题和色盲友好主题间切换
/** 主题切换功能 */
class ThemeManager {
  private currentTheme: typeof COLORBLIND_SAFE_THEME | typeof NormalTheme;

  toggleColorBlindSafe(): void {
    this.currentTheme = this.isColorBlindMode
      ? NormalTheme
      : COLORBLIND_SAFE_THEME;
    this.reapplyTheme();
  }

  /** 运行时切换,无须重启应用 */
  private reapplyTheme(): void {
    // 重新渲染所有消息
    this.repaintAllMessages();
    // 更新状态栏颜色
    this.updateStatusBar();
    this.screen.render();
  }
}

5.13 视觉层级设计

终端界面受限于单色字符和有限的画布大小,清晰的信息分层比 GUI 中的视觉层级设计更为关键。良好的视觉层级帮助用户快速定位"我在看什么"和"接下来看哪里"。

层级结构定义

将信息按重要程度分为四个层级:

第一层 (最突出): 状态栏、错误提示
    特征: 高亮背景色、加粗文字、图标
    作用: 用户第一时间感知当前状态

第二层: 角色标识(用户/AI/工具)、消息标题
    特征: 角色颜色前缀、表情符号
    作用: 快速识别"谁说的"

第三层: 消息正文
    特征: 标准前景色、正常字体
    作用: 阅读核心内容

第四层 (最弱): 时间戳、Token 计数、元数据
    特征: 灰色调、小字、右对齐
    作用: 需要时查阅,不妨碍主阅读流

视觉层级实现

/** 消息渲染层级配置 */
interface VisualLevelConfig {
  roleIndicator: 'icon' | 'label' | 'short'; // 角色标识方式
  showTimestamp: boolean;                      // 是否显示时间戳
  showTokenCount: boolean;                     // 是否显示 Token 数
  messagePadding: number;                      // 消息内边距
  dividerStyle: 'none' | 'thin' | 'thick';     // 分隔线风格
}

/** 根据终端宽度和应用状态选择视觉层级 */
function selectVisualLevel(cols: number, compactMode: boolean): VisualLevelConfig {
  if (compactMode || cols < 80) {
    return {
      roleIndicator: 'short',     // ">>" / "<<"
      showTimestamp: false,
      showTokenCount: false,
      messagePadding: 0,
      dividerStyle: 'none',
    };
  }
  if (cols < 120) {
    return {
      roleIndicator: 'label',     // "用户" / "AI"
      showTimestamp: false,
      showTokenCount: true,
      messagePadding: 1,
      dividerStyle: 'thin',       // ───
    };
  }
  return {
    roleIndicator: 'icon',       // "👤 用户" / "🤖 AI"
    showTimestamp: true,
    showTokenCount: true,
    messagePadding: 2,
    dividerStyle: 'thick',       // ━━━
  };
}

消息分组与间隔

合理地使用空白和分隔线,让消息之间的关系一目了然:

/**
 * 消息分组策略:属于同一轮对话的消息在视觉上更靠近
 * 使用不同高度的 spacer 在消息组之间创建层次
 */
class MessageGrouping {
  private lastRole: string | null = null;

  /** 在渲染每条消息前调用,决定是否插入组间隔 */
  addGroupSpacer(chatBox: Widgets.BoxElement, currentRole: string): void {
    const isNewGroup = this.lastRole !== null && this.lastRole !== currentRole;

    if (isNewGroup) {
      // 角色切换时:插入淡色分隔线 + 1 行空白
      const divider = '─'.repeat(Math.min(40, (chatBox.screen.width as number) - 4));
      blessed.box({
        parent: chatBox,
        top: 0, left: 0, width: '100%', height: 2,
        content: `{#555555-fg}${divider}{/#555555-fg}`,
        style: { fg: '#555555', bg: '#1a1a1a' },
        tags: true,
      });
    } else if (this.lastRole !== null) {
      // 相同角色内:1 行空白
      blessed.box({
        parent: chatBox,
        top: 0, left: 0, width: '100%', height: 1,
        content: '',
        style: { bg: '#1a1a1a' },
      });
    }

    this.lastRole = currentRole;
  }

  reset(): void {
    this.lastRole = null;
  }
}

信息密度控制

根据终端可用行数动态调整每条消息展示的信息量:

class DensityController {
  /** 计算可用的信息密度级别 */
  getDensityLevel(terminalRows: number, messageCount: number): 'sparse' | 'normal' | 'dense' {
    const availableLines = terminalRows - 5; // 减去固定栏
    const linesPerMessage = availableLines / Math.max(1, messageCount);

    if (linesPerMessage >= 8) return 'sparse';   // 大屏:展示完整元数据
    if (linesPerMessage >= 4) return 'normal';   // 中屏:标准展示
    return 'dense';                                // 小屏:最简展示
  }

  /** 根据密度级别渲染消息 */
  renderMessage(msg: ChatMessage, level: 'sparse' | 'normal' | 'dense'): string {
    switch (level) {
      case 'sparse':
        return [
          `{bold}${this.roleIcon(msg.role)}{/bold}`,
          `  {#666666-fg}${msg.timestamp.toLocaleTimeString()}{/#666666-fg}`,
          ``,
          msg.content,
          `  {#555555-fg}Tokens: ${msg.tokens ?? '--'}{/#555555-fg}`,
        ].join('\n');
      case 'dense':
        return `${this.shortRole(msg.role)} ${msg.content}`;
      default:
        return `{bold}${this.roleIcon(msg.role)}{/bold}\n${msg.content}`;
    }
  }

  private roleIcon(role: string): string {
    const icons: Record<string, string> = {
      user: '👤 用户', assistant: '🤖 AI', system: '💬 系统', tool: '🔧',
    };
    return icons[role] || role;
  }

  private shortRole(role: string): string {
    const short: Record<string, string> = {
      user: '>>', assistant: '<<', system: '##', tool: '::',
    };
    return short[role] || role;
  }
}

视觉层级设计原则

原则说明实现方式
邻近性相关的信息放在一起同一轮对话连续排列,用更少的间隔
相似性相同类型的消息使用相同的样式所有用户消息使用蓝色前缀,所有 AI 消息使用绿色
对比度不同类型的信息有明显差异角色前缀使用不同的颜色和图标
一致性相同模式反复出现所有消息遵循 [角色标识] + [内容] 的格式
可扫描用户能快速找到关键信息状态栏反映当前状态,错误信息加红框

5.14 键盘导航设计

TUI 的核心交互方式是键盘。良好的键盘导航设计直接影响用户的操作效率和学习成本。

键位绑定分类

将所有快捷键按层次和频率分类,方便用户记忆:

type KeyCategory =
  | 'essential'    // 核心操作:发送、退出、取消
  | 'navigation'   // 导航:滚动、切换焦点
  | 'editing'      // 编辑:清空、历史、粘贴
  | 'utility'      // 工具:重试、导出、帮助
  | 'debug';       // 调试:状态面板、日志

interface KeyBinding {
  keys: string[];         // 触发键序列
  description: string;    // 人类可读的描述
  category: KeyCategory;  // 分类
  context?: string[];     // 适用的状态上下文(空 = 全局)
}

/** 全键盘绑定表 —— 单一数据源,用于注册和帮助面板 */
const KEY_BINDINGS: KeyBinding[] = [
  // ── essential ──
  { keys: ['C-s'],        description: '发送消息',         category: 'essential', context: ['idle'] },
  { keys: ['C-q'],        description: '退出程序',         category: 'essential' },
  { keys: ['C-c'],        description: '取消生成',         category: 'essential', context: ['thinking', 'streaming', 'tool_call'] },

  // ── navigation ──
  { keys: ['up', 'down'], description: '滚动聊天区域',     category: 'navigation' },
  { keys: ['pageup', 'pagedown'], description: '快速滚动',  category: 'navigation' },
  { keys: ['tab'],        description: '切换焦点',         category: 'navigation' },
  { keys: ['S-tab'],      description: '反向切换焦点',     category: 'navigation' },

  // ── editing ──
  { keys: ['up', 'down'], description: '切换输入历史',     category: 'editing', context: ['idle'] },
  { keys: ['escape'],     description: '清空输入框',       category: 'editing', context: ['idle'] },

  // ── utility ──
  { keys: ['r'],          description: '重试(错误后)',   category: 'utility', context: ['error'] },
  { keys: ['?'],          description: '显示快捷键帮助',   category: 'utility' },
];

焦点管理

明确的焦点管理让用户知道"当前键盘输入会作用到哪里":

class FocusManager {
  private focusable: Widgets.BlessedElement[] = [];
  private currentIndex = 0;

  constructor(private screen: Widgets.Screen) {}

  register(element: Widgets.BlessedElement): void {
    this.focusable.push(element);
  }

  next(): void {
    if (this.focusable.length === 0) return;
    this.currentIndex = (this.currentIndex + 1) % this.focusable.length;
    this.focusable[this.currentIndex].focus();
    this.updateFocusIndicator();
  }

  prev(): void {
    if (this.focusable.length === 0) return;
    this.currentIndex = (this.currentIndex - 1 + this.focusable.length) % this.focusable.length;
    this.focusable[this.currentIndex].focus();
    this.updateFocusIndicator();
  }

  private updateFocusIndicator(): void {
    // 在状态栏显示当前焦点位置
    const total = this.focusable.length;
    const current = this.currentIndex + 1;
    // 更新状态栏的焦点指示部分
  }

  focusInput(): void {
    const inputIdx = this.focusable.findIndex(el => el.type === 'textarea');
    if (inputIdx >= 0) {
      this.currentIndex = inputIdx;
      this.focusable[inputIdx].focus();
    }
  }
}

快捷键帮助面板

? 键显示可搜索的快捷键列表:

class HelpOverlay {
  show(screen: Widgets.Screen, currentState: string): void {
    const content = KEY_BINDINGS
      .filter(b => !b.context || b.context.includes(currentState))
      .map(b => {
        const keys = b.keys.map(k => `{green-fg}${k}{/green-fg}`).join(', ');
        const cat = this.categoryLabel(b.category);
        return `  ${cat}  ${keys.padEnd(16)}${b.description}`;
      })
      .join('\n');

    const overlay = blessed.box({
      parent: screen,
      top: 'center', left: 'center',
      width: 56, height: 'shrink',
      content: `{bold}⌨️ 快捷键帮助(当前状态: ${currentState}){/bold}\n\n${content}\n\n{white-fg}按任意键关闭{/white-fg}`,
      style: { fg: 'white', bg: '#222233' },
      border: { type: 'line', fg: '#6666aa' },
      tags: true,
      padding: { left: 2, right: 2 },
      shadow: true,
    });

    const close = () => {
      overlay.detach();
      screen.render();
      screen.removeListener('keypress', close);
    };
    screen.on('keypress', close);
    screen.render();
  }

  private categoryLabel(cat: KeyCategory): string {
    const labels: Record<KeyCategory, string> = {
      essential: '{red-fg}[核心]{/red-fg}',
      navigation: '{cyan-fg}[导航]{/cyan-fg}',
      editing: '{yellow-fg}[编辑]{/yellow-fg}',
      utility: '{white-fg}[工具]{/white-fg}',
      debug: '{magenta-fg}[调试]{/magenta-fg}',
    };
    return labels[cat];
  }
}

上下文相关的快捷键

同样的按键在不同焦点位置执行不同操作:

/** 管理上下文相关的快捷键分发 */
class ContextualKeyHandler {
  constructor(
    private chatBox: Widgets.BoxElement,
    private inputBox: Widgets.TextareaElement,
    private focusManager: FocusManager,
  ) {
    this.setupNavigation();
  }

  private setupNavigation(): void {
    // 聊天区:↑↓ 用于滚动
    this.chatBox.key('up', () => { this.chatBox.scroll(-1); return false; });
    this.chatBox.key('down', () => { this.chatBox.scroll(1); return false; });

    // 输入框:↑↓ 用于切换历史
    this.inputBox.key('up', () => { this.historyPrev(); return false; });
    this.inputBox.key('down', () => { this.historyNext(); return false; });

    // Tab 循环切换焦点
    this.chatBox.screen.key('tab', () => this.focusManager.next());
    this.chatBox.screen.key('S-tab', () => this.focusManager.prev());
  }
}

键盘导航设计原则

原则说明反例正确做法
可发现性用户能发现所有快捷键隐藏的快捷键只有在文档中看到? 显示内置帮助面板
一致性类似功能使用类似键位↑ 在输入框是历史,在聊天区是滚动同一键位在不同上下文的用途相近
容错性误触后有恢复路径按 C-q 直接退出无确认退出前显示确认提示
可配置用户可自定义键位硬编码所有快捷键提供 KeyBindingsConfig
不冲突不与终端模拟器冲突C-s 被终端用作 XOFF 流控提供备选键位或检测提示

5.15 布局动画

TUI 虽然是文本界面,但通过帧动画和巧妙的重绘策略,可以实现平滑的视觉过渡,提升用户体验。

Spinner 帧动画

使用 Braille 点字或 ASCII 字符序列实现旋转动画:

class FrameAnimation {
  private timer: ReturnType<typeof setInterval> | null = null;
  private frameIndex = 0;

  constructor(
    private element: Widgets.BoxElement,
    private frames: string[],
    private intervalMs: number = 100,
    private onRender: () => void,
  ) {}

  start(): void {
    this.stop();
    this.timer = setInterval(() => {
      this.frameIndex = (this.frameIndex + 1) % this.frames.length;
      this.updateFrame();
    }, this.intervalMs);
  }

  private updateFrame(): void {
    const content = this.element.getContent();
    // 替换内容中的 Spinner 字符(第一列)
    const updated = content.replace(/^./, this.frames[this.frameIndex]);
    this.element.setContent(updated);
    this.onRender();
  }

  stop(): void {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }
}

// 使用示例
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const spinner = new FrameAnimation(spinnerEl, SPINNER_FRAMES, 80, () => screen.render());

面板展开/折叠过渡

通过逐行添加或移除内容实现面板的"动画"效果:

class PanelTransition {
  /**
   * 展开面板:逐行显示内容
   */
  expand(panel: Widgets.BoxElement, finalContent: string, stepMs: number = 30): Promise<void> {
    return new Promise(resolve => {
      panel.show();
      const lines = finalContent.split('\n');
      let currentLine = 0;

      const timer = setInterval(() => {
        currentLine++;
        if (currentLine >= lines.length) {
          clearInterval(timer);
          panel.setContent(finalContent);
          resolve();
        }
        panel.setContent(lines.slice(0, currentLine).join('\n'));
        // 局部渲染以提升性能
        panel.screen.render();
      }, stepMs);
    });
  }

  /**
   * 折叠面板:内容逐行消失
   */
  collapse(panel: Widgets.BoxElement, stepMs: number = 20): Promise<void> {
    return new Promise(resolve => {
      const content = panel.getContent();
      const lines = content.split('\n');
      let remainingLines = lines.length;

      const timer = setInterval(() => {
        remainingLines--;
        if (remainingLines <= 0) {
          clearInterval(timer);
          panel.hide();
          resolve();
        }
        panel.setContent(lines.slice(0, remainingLines).join('\n'));
        panel.screen.render();
      }, stepMs);
    });
  }
}

// 使用示例
async function toggleSidebar(sidebar: Widgets.BoxElement, show: boolean): Promise<void> {
  const transition = new PanelTransition();
  if (show) {
    await transition.expand(sidebar, sidebarContent, 20);
  } else {
    await transition.collapse(sidebar, 15);
  }
}

进度条动画

用于工具调用执行、文件处理等场景:

class ProgressBar {
  private timer: ReturnType<typeof setInterval> | null = null;
  private progress = 0;
  private readonly barWidth: number;

  constructor(
    private element: Widgets.BoxElement,
    private onRender: () => void,
    barWidth: number = 20,
  ) {
    this.barWidth = Math.min(barWidth, 40);
  }

  /** 启动进度动画,durationMs 为期望的总持续时间 */
  start(durationMs: number = 2000): void {
    this.progress = 0;
    const stepMs = 50;
    const totalSteps = durationMs / stepMs;
    let step = 0;

    this.timer = setInterval(() => {
      step++;
      this.progress = Math.min(1, step / totalSteps);
      this.renderBar();

      if (this.progress >= 1) this.stop();
    }, stepMs);
  }

  /** 设置精确进度(0-1) */
  setProgress(value: number): void {
    this.progress = Math.max(0, Math.min(1, value));
    this.renderBar();
  }

  private renderBar(): void {
    const filled = Math.round(this.progress * this.barWidth);
    const empty = this.barWidth - filled;
    const bar = '{green-fg}' + '█'.repeat(filled) + '{/green-fg}' +
                '{#444444-fg}' + '█'.repeat(empty) + '{/#444444-fg}';
    const pct = Math.round(this.progress * 100);
    this.element.setContent(`  ${bar}  ${pct}%`);
    this.onRender();
  }

  stop(): void {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
    this.progress = 1;
    this.renderBar();
  }

  isRunning(): boolean {
    return this.timer !== null;
  }
}

动画性能优化

class AnimationScheduler {
  private animatingElements = new Set<Widgets.BlessedElement>();
  private frameTimer: ReturnType<typeof setInterval> | null = null;
  private readonly FPS = 30; // 限制最大帧率
  private readonly MIN_INTERVAL = Math.round(1000 / this.FPS);

  register(element: Widgets.BlessedElement): void {
    this.animatingElements.add(element);
    this.startFrameLoop();
  }

  unregister(element: Widgets.BlessedElement): void {
    this.animatingElements.delete(element);
    if (this.animatingElements.size === 0) {
      this.stopFrameLoop();
    }
  }

  private startFrameLoop(): void {
    if (this.frameTimer) return;
    this.frameTimer = setInterval(() => {
      // 一次渲染更新所有动画元素
      this.animatingElements.forEach(el => {
        // 只在元素可见时渲染
        if (!el.hidden) {
          // 更新帧
        }
      });
    }, this.MIN_INTERVAL);
  }

  private stopFrameLoop(): void {
    if (this.frameTimer) {
      clearInterval(this.frameTimer);
      this.frameTimer = null;
    }
  }

  destroy(): void {
    this.stopFrameLoop();
    this.animatingElements.clear();
  }
}

动画设计准则

准则说明
最低帧率Spinner 使用 80-120ms 间隔(8-12fps),避免 CPU 过度占用
空闲停止状态不再需要的动画及时停止,stop() 方法清理 setInterval
合并渲染多个动画同时进行时合并为一次 screen.render() 调用
局部更新只更新变化的 widget 内容,不触发全屏重绘
防止闪烁使用 screen.render() 而非 screen.realloc() 减少闪烁
可禁用提供选项关闭动画(如屏幕阅读器模式或低性能环境)

5.16 虚拟滚动与消息列表窗口化

随着对话轮次增加,消息列表中的 blessed 组件数量会线性增长。经过 20 轮对话可能创建 60-100 个组件(消息+分隔线+操作元素),100+ 轮后可达 500+ 组件,导致内存占用激增和渲染性能下降。

问题分析

// 直接渲染所有消息的问题
class NaiveChatView {
  addMessage(msg: ChatMessage): void {
    // 每次添加消息都创建一个新的 blessed 组件
    // 随着消息增多,blessed 的组件树越来越庞大
    const el = blessed.box({
      parent: this.chatBox,
      // ...
    });
    // 没有组件复用或销毁机制
    // screen.render() 需要遍历所有组件进行差分比较
  }
}

虚拟滚动实现策略

虚拟滚动(Virtual Scrolling)的核心思想是只渲染视口可见的消息,不可见的消息仅保留数据而不创建 blessed 组件:

class VirtualScrollList {
  private visibleMessages: Widgets.BoxElement[] = []; // 当前可见的组件池
  private scrollTop = 0;
  private readonly ITEM_HEIGHT = 2;    // 每条消息占用的行数
  private readonly OVERSCAN = 3;       // 视口外额外渲染的条目数
  private readonly MAX_POOL = 50;      // 组件池上限

  constructor(
    private container: Widgets.BoxElement,
    private messages: ChatMessage[],
  ) {}

  /** 计算当前应该渲染的消息范围 */
  private getVisibleRange(): { start: number; end: number } {
    const viewportHeight = (this.container.height as number) || 20;
    const start = Math.max(0, Math.floor(this.scrollTop / this.ITEM_HEIGHT) - this.OVERSCAN);
    const end = Math.min(
      this.messages.length,
      Math.ceil((this.scrollTop + viewportHeight) / this.ITEM_HEIGHT) + this.OVERSCAN,
    );
    return { start, end };
  }

  /** 回收超出视口的组件 */
  private recycleComponents(range: { start: number; end: number }): void {
    // 收集所有不再可见的组件,移除并回收引用
    const toRemove: Widgets.BoxElement[] = [];
    this.container.children.forEach((child) => {
      const el = child as Widgets.BoxElement;
      const idx = parseInt(el.getAttribute('data-index') || '-1', 10);
      if (idx >= 0 && (idx < range.start || idx >= range.end)) {
        toRemove.push(el);
      }
    });
    toRemove.forEach((el) => {
      this.container.remove(el);
    });
  }

  /** 滚动事件处理 —— 滚动时只更新可见区域 */
  onScroll(delta: number): void {
    this.scrollTop = Math.max(0, this.scrollTop + delta);
    const range = this.getVisibleRange();
    this.recycleComponents(range);
    // 渲染新增的可见消息
    for (let i = range.start; i < range.end; i++) {
      if (!this.isMessageRendered(i)) {
        this.renderMessage(this.messages[i], i);
      }
    }
    this.container.screen.render();
  }

  private isMessageRendered(index: number): boolean {
    return Array.from(this.container.children).some(
      (child) => (child as Widgets.BoxElement).getAttribute('data-index') === String(index),
    );
  }

  private renderMessage(msg: ChatMessage, index: number): void {
    // 复用组件或创建新组件
    const el = blessed.box({
      parent: this.container,
      top: index * this.ITEM_HEIGHT,
      // ...
    });
    el.setAttribute('data-index', String(index));
  }
}

替代方案:分页加载

对于不需要丝滑滚动体验的场景,可以使用简单的分页策略:

class PaginatedChatView {
  private readonly PAGE_SIZE = 20;
  private currentPage = 0;

  /** 加载上一页(更早的消息) */
  loadPreviousPage(): void {
    // 清空当前所有消息组件
    this.clearMessages();
    // 加载上一页的消息
    const start = Math.max(0, this.currentPage * this.PAGE_SIZE);
    const pageMessages = this.messages.slice(start, start + this.PAGE_SIZE);
    pageMessages.forEach(msg => this.renderMessage(msg));
    this.currentPage--;
  }
}

虚拟滚动 vs 分页对比

策略内存占用用户体验实现复杂度适用场景
虚拟滚动低(仅渲染可见消息)流畅(无缝滚动)长对话、实时流式更新
分页极低(只渲染一页)可接受(点击加载更多)历史记录查看
直接渲染(无优化)高(所有消息)最佳(但卡顿后差)短对话(<20轮)

选择建议

对于 LLM 对话 TUI 应用:

  • 原型/演示阶段:直接渲染即可(<20轮对话不卡顿)
  • 正式产品:实现虚拟滚动,或使用 blessed.log 的自动滚动特性(blessed 内置)
  • 消息数 < 50:直接渲染无压力
  • 消息数 50-200:考虑组件池复用
  • 消息数 > 200:必须实现虚拟滚动

下一步: 第六章:事件系统与状态管理