AI 教程系列-TUI 应用开发教程06-事件系统与状态管理

3 阅读9分钟

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

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

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

第六章 事件系统与状态管理

6.1 TUI 中的事件系统

TUI 应用本质上是事件驱动的——它在等待用户操作(按键、鼠标点击)或系统事件(定时器、异步数据到达)并做出响应。

6.1.1 事件类型总览

type TUIEvent =
  | KeyboardEvent      // 按键、组合键 —— 最核心的交互方式
  | MouseEvent         // 点击、滚轮、悬停、拖拽
  | FocusEvent         // 焦点获得/失去
  | ResizeEvent        // 终端窗口大小变化
  | TimerEvent         // setTimeout / setInterval 定时器
  | AsyncEvent         // 异步数据到达(如 LLM 流式响应)
  | CustomEvent;       // 自定义业务事件

6.1.2 blessed 事件处理模式

// ── 1. 屏幕级键盘事件(全局快捷键) ──
screen.key(['C-q', 'C-c'], () => process.exit(0));
screen.key('tab', () => switchFocus());
screen.key(['up', 'down'], (ch, key) => handleNav(key.name));

// ── 2. 组件级事件 ──
button.on('press', () => handleClick());
list.on('select', (item) => handleSelect(item));
textarea.on('submit', (value) => handleSubmit(value));

// ── 3. 鼠标事件 ──
widget.on('click', (mouseData) => handleClick(mouseData));
widget.on('wheelup', () => scrollUp());
widget.on('wheeldown', () => scrollDown());
screen.on('mouse', (data) => globalMouseHandler(data));

// ── 4. 生命周期事件 ──
screen.on('resize', () => recalcLayout());
screen.on('destroy', () => cleanup());
widget.on('show', () => { /* 组件显示时 */ });
widget.on('hide', () => { /* 组件隐藏时 */ });

6.1.3 事件传播机制

blessed 的事件传播遵循特定的优先级链:

键盘按键按下
  ↓
screen.key(...) 全局快捷键处理(优先级最高)
  ↓ 若未被消费(return false)
screen.keypress 事件
  ↓ 若有焦点组件
已聚焦的 widget.key(...) 组件级处理
  ↓ 若有 textarea/textbox
组件的输入处理(输入文本)

事件消费规则:

// 在全局处理中消费事件并阻止传播
screen.key('tab', () => {
  handleTabSwitch();
  return false;  // ← 阻止事件继续传播到焦点组件
});

// 在组件级处理中消费
textarea.on('keypress', (ch, key) => {
  if (key.name === 'enter') {
    handleSubmit();
    return false;  // ← 阻止 Enter 进入文本
  }
});

6.1.4 键盘事件详解

blessed 的键盘事件使用简写命名规范:

// ── 组合键 ──
'C-a'        // Ctrl+A
'M-a'        // Meta+Alt+A (在 macOS 终端中可能映射到 Esc+A)
'S-a'        // Shift+A
'C-S-a'      // Ctrl+Shift+A

// ── 功能键 ──
'f1' - 'f12' // 功能键 F1-F12

// ── 方向键 ──
'up', 'down', 'left', 'right'

// ── 编辑键 ──
'backspace', 'delete', 'insert'
'home', 'end'
'pageup', 'pagedown'

// ── 特殊键 ──
'tab', 'enter', 'escape', 'space'

// ── 监听所有按键 ──
screen.on('keypress', (ch, key) => {
  // ch: 输入的字符(如果有)
  // key: { name, ctrl, meta, shift, seq }
});

6.2 状态管理

对于大模型对话 TUI 这样的复杂应用,良好的状态管理至关重要。

6.2.1 全局状态对象

interface AppState {
  // ── 应用运行时状态 ──
  status: AppStateValue;
  messages: ChatMessage[];

  // ── 当前响应状态 ──
  currentAssistantText: string;
  currentTool: ToolCallDisplay | null;
  currentToolResult: string | null;

  // ── UI 控制 ──
  isInputEnabled: boolean;
  streamingMessageId: string | null;

  // ── 统计信息 ──
  totalTokens: number;
  messageCount: number;
}

6.2.2 基于类的状态管理

class ChatTUI {
  private state: AppState = {
    status: 'idle',
    messages: [],
    currentAssistantText: '',
    currentTool: null,
    currentToolResult: null,
    isInputEnabled: true,
    streamingMessageId: null,
    totalTokens: 0,
    messageCount: 0,
  };

  /**
   * 安全地更新状态,并触发 UI 刷新
   */
  private setState(partial: Partial<AppState>): void {
    Object.assign(this.state, partial);
    this.onStateChanged();
  }

  /**
   * 状态变更后的 UI 同步
   */
  private onStateChanged(): void {
    // 1. 更新状态栏
    this.updateStatusBar();

    // 2. 根据状态控制输入可用性
    if (this.state.isInputEnabled) {
      this.inputBox.show();
    } else {
      this.inputBox.hide();
    }

    // 3. 重新渲染
    this.screen.render();
  }
}

6.2.3 使用状态机防止非法转换

class StateMachine {
  private currentState: AppState = 'idle';
  private onTransition?: (from: AppState, to: AppState) => void;

  /** 合法状态转换表 */
  private static 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 from = this.currentState;
    const allowed = StateMachine.TRANSITIONS[from];

    if (!allowed?.includes(to)) {
      console.error(`[状态机] 非法转换: ${from}${to}`);
      return false;
    }

    console.log(`[状态机] ${from}${to}`);
    this.currentState = to;
    this.onTransition?.(from, to);
    return true;
  }

  canTransition(to: AppState): boolean {
    return StateMachine.TRANSITIONS[this.currentState]?.includes(to) ?? false;
  }

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

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

6.2.4 状态变更与 UI 同步流程

用户操作(如发送消息)
  ↓
stateMachine.transition('thinking')
  ↓
UI 更新:
├── statusBar: "思考中..."
├── startSpinner()
├── inputBox.hide()  (禁用输入)
└── screen.render()
  ↓
LLM 事件到达
  ↓
stateMachine.transition('streaming')
  ↓
UI 更新:
├── statusBar: "生成中..."
├── stopSpinner()
├── createAssistantMessage()
└── screen.render()
  ↓
... 继续处理 ...

6.3 异步处理模式

6.3.1 AsyncGenerator 模式

LLM 流式响应天然适合用 AsyncGenerator 消费:

// ── 生产者:Mock LLM ──
async function* mockLLMStream(prompt: string): AsyncGenerator<LLMEvent> {
  yield { type: 'thinking' };
  await delay(500);

  yield { type: 'text', content: '你好!' };
  await delay(100);

  yield { type: 'tool_call', name: 'get_weather', args: { city: 'Beijing' } };
  await delay(1000);

  yield { type: 'tool_result', name: 'get_weather', result: '{"temp": 25}' };
  yield { type: 'text', content: '北京今天 25°C。' };
  yield { type: 'done' };
}

// ── 消费者:TUI 应用 ──
async function consumeStream(stream: AsyncGenerator<LLMEvent>) {
  for await (const event of stream) {
    switch (event.type) {
      case 'thinking':   showSpinner(); break;
      case 'text':       appendText(event.content); break;
      case 'tool_call':  showToolCall(event); break;
      case 'tool_result':showToolResult(event); break;
      case 'error':      showError(event.message); break;
      case 'done':       finishResponse(); break;
    }
  }
}

6.3.2 取消令牌(AbortController)模式

支持用户取消正在进行的生成操作:

class ChatTUI {
  private abortController: AbortController | null = null;

  /**
   * 发送消息并处理流式响应
   */
  async sendMessage(text: string): Promise<void> {
    // 创建新的取消令牌
    this.abortController = new AbortController();
    const signal = this.abortController.signal;

    try {
      for await (const event of mockLLMStream(text)) {
        // 每次迭代检查是否被取消
        if (signal.aborted) {
          console.log('生成被用户取消');
          break;
        }

        await this.handleLLMEvent(event);
      }
    } catch (err) {
      if ((err as Error).name === 'AbortError') {
        this.showSystemMessage('⏹️ 生成已取消');
      } else {
        this.showError(err as Error);
      }
    } finally {
      this.abortController = null;
      this.state = 'idle';
      this.setStatus('● 就绪', '#22aa44');
      this.inputBox.focus();
    }
  }

  /**
   * 取消正在进行的生成
   */
  cancelGeneration(): void {
    if (this.abortController && this.state !== 'idle') {
      this.abortController.abort();
    }
  }
}

6.3.3 渲染节流(Throttle)

流式更新时,避免不必要的过度渲染:

class RenderThrottle {
  private lastRenderTime = 0;
  private readonly MIN_INTERVAL = 33; // ~30fps 上限
  private pending = false;

  constructor(private renderFn: () => void) {}

  request(): void {
    const now = Date.now();
    if (now - this.lastRenderTime >= this.MIN_INTERVAL) {
      // 距离上次渲染已超过最小间隔,立即执行
      this.lastRenderTime = now;
      this.renderFn();
    } else if (!this.pending) {
      // 还没有等待中的渲染请求,调度一个
      this.pending = true;
      const delay = this.MIN_INTERVAL - (now - this.lastRenderTime);
      setTimeout(() => {
        this.lastRenderTime = Date.now();
        this.pending = false;
        this.renderFn();
      }, delay);
    }
    // 如果已经有等待中的请求,跳过(合并更新)
  }
}

6.3.4 事件批处理模式

对于高频事件(如流式文本),使用批处理合并更新:

class BatchProcessor {
  private buffer: string[] = [];
  private timer: ReturnType<typeof setTimeout> | null = null;
  private readonly BATCH_INTERVAL = 50; // 50ms 批次

  constructor(private flushFn: (batch: string[]) => void) {}

  add(item: string): void {
    this.buffer.push(item);
    if (!this.timer) {
      this.timer = setTimeout(() => this.flush(), this.BATCH_INTERVAL);
    }
  }

  private flush(): void {
    if (this.buffer.length > 0) {
      const batch = [...this.buffer];
      this.buffer = [];
      this.flushFn(batch);
    }
    this.timer = null;
  }

  destroy(): void {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
    if (this.buffer.length > 0) {
      this.flush(this.buffer);
      this.buffer = [];
    }
  }
}

// 使用
const batchProcessor = new BatchProcessor((batch) => {
  const text = batch.join('');
  updateAssistantMessage(text);
});

6.3.5 EventBus — 全局事件总线模式

对于大型 TUI 应用,使用事件总线解耦组件:

type BusEvent =
  | { type: 'user:send'; text: string }
  | { type: 'llm:thinking' }
  | { type: 'llm:text'; content: string }
  | { type: 'llm:tool_call'; name: string; args: Record<string, unknown> }
  | { type: 'llm:tool_result'; name: string; result: string }
  | { type: 'llm:error'; message: string }
  | { type: 'llm:done' }
  | { type: 'ui:resize'; cols: number; rows: number }
  | { type: 'ui:focus'; component: string }
  | { type: 'app:exit' };

class EventBus {
  private listeners = new Map<string, Array<(event: any) => void>>();

  on<T extends BusEvent>(type: T['type'], handler: (event: T) => void): void {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, []);
    }
    this.listeners.get(type)!.push(handler as any);
  }

  emit<T extends BusEvent>(event: T): void {
    const handlers = this.listeners.get(event.type);
    handlers?.forEach(h => h(event));
  }

  off(type: string, handler: Function): void {
    const handlers = this.listeners.get(type);
    if (handlers) {
      const idx = handlers.indexOf(handler as any);
      if (idx >= 0) handlers.splice(idx, 1);
    }
  }

  clear(): void {
    this.listeners.clear();
  }
}

// 全局单例
const bus = new EventBus();

// ── 使用 ──
// 状态栏组件订阅事件
bus.on('llm:thinking', () => statusBar.setContent('思考中...'));
bus.on('llm:text', (e) => appendText(e.content));

// 发送组件发布事件
bus.emit({ type: 'user:send', text: inputText });

6.4 实际案例:llm-chat.ts 中的事件流转

以下是 examples/llm-chat.ts 中的完整事件流转逻辑:

private async processStream(userMsg: string): Promise<void> {
  let fullResponse = '';

  for await (const event of mockLLMStream(userMsg)) {
    switch (event.type) {
      case 'thinking':
        this.state = 'thinking';
        this.setStatus('🤔 思考中...', '#aa8800');
        this.startSpinner();
        break;

      case 'text':
        if (this.state === 'thinking' || this.state === 'tool_result') {
          this.stopSpinner();
          this.clearToolDisplay();
        }
        this.state = 'streaming';
        this.setStatus('💬 生成中...', '#44aa44');
        fullResponse += event.content;
        this.updateAssistantMessage(fullResponse);
        break;

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

      case 'tool_result':
        this.state = 'tool_result';
        this.setStatus(`✅ 工具返回: ${event.name}`, '#4488aa');
        this.showToolResult(event.name, event.result);
        break;

      case 'error':
        this.state = 'error';
        this.setStatus('❌ 错误', '#cc2222');
        this.stopSpinner();
        this.clearToolDisplay();
        this.showError(event.message);
        break;

      case 'done':
        this.state = 'idle';
        this.setStatus('● 就绪', '#22aa44');
        this.stopSpinner();
        if (fullResponse) {
          this.messages.push({ role: 'assistant', content: fullResponse });
        }
        break;
    }
  }
}

6.4.1 终端安全退出装置

llm-chat.ts 最核心的基础设施是全局退出守卫。无论程序如何退出(正常、异常、信号),都能恢复终端到原始状态。这是 TUI 程序最重要的安全措施

// examples/llm-chat.ts
function resetTerminal(): void {
  try {
    process.stdout.write('\x1b[2J\x1b[H');   // 清屏
    process.stdout.write('\x1b[?25h');        // 恢复光标
    process.stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l'); // 禁用鼠标
    process.stdout.write('\x1b[0m');          // 重置样式
    if (process.stdin.isTTY) {
      process.stdin.setRawMode(false);
    }
    process.stdin.pause();
  } catch { /* 清理出错也不抛出 */ }
}

/** 安装全局退出守卫 —— 拦截所有退出路径 */
function installExitGuard(): void {
  process.on('exit', () => resetTerminal());
  process.on('SIGINT', () => { resetTerminal(); process.exit(0); });
  process.on('SIGTERM', () => { resetTerminal(); process.exit(0); });
  process.on('uncaughtException', (err) => {
    resetTerminal();
    console.error('\n❌ 未捕获异常:', err);
    process.exit(1);
  });
  process.on('unhandledRejection', (reason) => {
    resetTerminal();
    console.error('\n❌ 未处理的 Promise 拒绝:', reason);
    process.exit(1);
  });
}

6.4.2 消息历史上限保护

防止长时间对话导致的内存泄漏:

private static readonly MAX_HISTORY = 100;

// 消息历史上限保护:超出时移除最早的 10 条
if (this.messages.length >= ChatTUI.MAX_HISTORY) {
  const removed = this.messages.splice(0, 10);
  removed.forEach(m => {
    this.removeMessageElement(m);
  });
}

// 按内容匹配移除 UI 元素(用于历史清理)
private removeMessageElement(msg: ChatMessage): void {
  const toRemove: Widgets.BoxElement[] = [];
  this.chatBox.children.forEach((child) => {
    const el = child as Widgets.BoxElement;
    const snippet = msg.content.slice(0, 20);
    if (snippet && el.getContent().includes(snippet)) {
      toRemove.push(el);
    }
  });
  toRemove.forEach((el) => {
    try { this.chatBox.remove(el); } catch { /* 已移除 */ }
  });
}

6.4.3 输入验证与防误触

// 输入长度限制
if (text.length > 2000) {
  this.addSystemMessage(
    `{yellow-fg}⚠️ 消息过长 (${text.length} 字符),最大支持 2000 字符{/yellow-fg}`
  );
  return;
}

// 空输入提示:输入框边框闪烁
private flashInputBorder(): void {
  const originalFg = this.inputBox.style.border?.fg;
  this.inputBox.style.border = { fg: 'red' as any };
  this.screen.render();
  setTimeout(() => {
    this.inputBox.style.border = { fg: (originalFg || '#44aa44') as any };
    this.screen.render();
  }, 300);
}

6.4.4 完整的错误处理与恢复路径

// 用户取消 vs 系统错误的区别处理
try {
  await this.processStream(text, signal);
} catch (err: unknown) {
  if (err instanceof DOMException && err.name === 'AbortError') {
    this.addSystemMessage('{yellow-fg}⏹️ 生成已取消{/yellow-fg}');
  } else {
    const msg = err instanceof Error ? err.message : '未知错误';
    this.showError(msg);
  }
} finally {
  this.abortController = null;
  this.state = 'idle';
  this.setStatus('● 就绪', '#22aa44');
  this.inputBox.focus();
}

// 错误状态下的重试按钮
private addRetryButton(originalMessage: string): void {
  const retryBtn = blessed.button({
    parent: this.chatBox,
    top: 0, left: 2,
    width: 16, height: 1,
    content: ' 🔄 重试 ',
    style: { fg: 'white', bg: '#664422', focus: { bg: '#aa8844' }, hover: { bg: '#886633' } },
    mouse: true,
  });

  retryBtn.on('press', () => {
    this.removeAllByType('error');
    this.screen.render();
    this.sendMessage(originalMessage);
  });

  this.screen.key('r', () => {
    if (this.state === 'error' && this.lastMessage) {
      this.removeAllByType('error');
      this.state = 'idle';
      this.screen.render();
      this.sendMessage(this.lastMessage);
    }
  });
}

6.4.5 实际应用中的状态流转追踪

在实际运行时,llm-chat.ts 的完整状态流转如下(以天气查询为例):

用户输入: "北京天气"
    │
    ├── state = idle → thinking       ← 状态机转换
    ├── 状态栏: "🤔 思考中..."         ← 状态栏更新
    ├── Spinner 开始旋转               ← 动画启动
    │
    ├── event: thinking               ← LLM 事件
    ├── event: text("让我查询...")      ← 流式文本
    │
    ├── state = thinking → streaming  ← 状态转换
    ├── 停止 Spinner                   ← 动画停止
    ├── 创建 AI 消息元素               ← UI 创建
    │
    ├── event: tool_call              ← 工具调用事件
    ├── state = streaming → tool_call ← 状态转换
    ├── 显示工具调用卡片               ← 特殊 UI
    │
    ├── event: tool_result            ← 工具返回事件
    ├── state = tool_call → tool_result
    ├── 替换为工具结果卡片             ← UI 更新
    │
    ├── event: text("🌤️ ...")         ← 继续流式
    ├── state = tool_result → streaming
    ├── 清除工具卡片,追加文本         ← UI 切换
    │
    ├── event: done
    ├── state = streaming → idle
    ├── 保存消息到 history
    ├── 状态栏: "● 就绪"
    └── 输入框重新获得焦点

6.5 设计原则总结

原则说明反例正确做法
单一数据源所有状态集中管理,避免分散在多个方法中各自维护状态一个 AppState 对象
状态可见状态栏清晰显示当前状态用户不知道 AI 是否在思考实时更新状态栏文本和颜色
防止非法转换状态机约束流转路径在 idle 时显示工具结果使用 canTransition() 检查
可中断用户可以随时取消操作用户无法打断过长的生成使用 AbortController
优雅降级任何异步操作都要能处理错误网络错误导致整个程序崩溃每层都有 try/catch
即时反馈用户操作后有即时 UI 响应点击发送后界面无反应立即显示 Spinner
一致性相似操作触发相似 UI 变化工具调用有时显示卡片有时不显示严格按照状态机流程
渲染节流高频更新时限制渲染频率每 10ms 渲染一次导致 CPU 飙升使用 throttle 或批处理

6.6 响应式状态管理(Reactive State)

对于复杂的 TUI 应用,可以使用 Observable 模式实现响应式状态更新:

type StateChangeHandler<T> = (prev: T, next: T) => void;

class ObservableState<T extends Record<string, unknown>> {
  private state: T;
  private handlers = new Map<keyof T, StateChangeHandler<any>[]>();
  private globalHandlers: Array<(prev: T, next: T) > = [];

  constructor(initial: T) {
    this.state = { ...initial };
  }

  /** 获取当前状态快照 */
  get(): Readonly<T> {
    return Object.freeze({ ...this.state });
  }

  /** 更新状态并触发通知 */
  set<K extends keyof T>(key: K, value: T[K]): void {
    const prev = { ...this.state };
    this.state[key] = value;
    // 触发特定 key 的监听器
    this.handlers.get(key)?.forEach(h => h(prev[key], value));
    // 触发全局监听器
    this.globalHandlers.forEach(h => h(prev, this.state));
  }

  /** 批量更新 */
  patch(partial: Partial<T>): void {
    const prev = { ...this.state };
    Object.assign(this.state, partial);
    for (const key of Object.keys(partial) as Array<keyof T>) {
      this.handlers.get(key)?.forEach(h => h(prev[key], this.state[key]));
    }
    this.globalHandlers.forEach(h => h(prev, this.state));
  }

  /** 订阅特定字段变化 */
  on<K extends keyof T>(key: K, handler: StateChangeHandler<T[K]>): () => void {
    if (!this.handlers.has(key)) this.handlers.set(key, []);
    this.handlers.get(key)!.push(handler);
    return () => {
      const arr = this.handlers.get(key);
      if (arr) {
        const idx = arr.indexOf(handler);
        if (idx >= 0) arr.splice(idx, 1);
      }
    };
  }

  /** 订阅所有变化 */
  watch(handler: (prev: T, next: T) => void): () => void {
    this.globalHandlers.push(handler);
    return () => {
      const idx = this.globalHandlers.indexOf(handler);
      if (idx >= 0) this.globalHandlers.splice(idx, 1);
    };
  }
}

// 使用示例
const appState = new ObservableState({
  status: 'idle' as AppState,
  messages: [] as ChatMessage[],
  isInputEnabled: true,
});

// 订阅状态变化 → UI 更新
appState.on('status', (prev, next) => {
  updateStatusBar(next);
  render();
});

appState.on('messages', (prev, next) => {
  scrollToBottom();
});

6.7 事件溯源模式(Event Sourcing)

对于消息历史管理,事件溯源模式可以提供完整的可回溯性:

/** 所有可能发生的消息事件 */
type MessageEvent =
  | { type: 'MESSAGE_SENT'; id: string; content: string; timestamp: number }
  | { type: 'MESSAGE_RECEIVED'; id: string; content: string; timestamp: number }
  | { type: 'TOOL_CALLED'; id: string; name: string; args: unknown; timestamp: number }
  | { type: 'TOOL_RESULT'; id: string; result: string; timestamp: number }
  | { type: 'MESSAGE_EDITED'; id: string; content: string; timestamp: number }
  | { type: 'MESSAGE_DELETED'; id: string; timestamp: number }
  | { type: 'ERROR_OCCURRED'; id: string; message: string; timestamp: number };

/** 事件溯源 —— 所有状态变更通过事件日志重建 */
class MessageEventStore {
  private events: MessageEvent[] = [];
  private listeners: Array<(event: MessageEvent) => void> = [];

  /** 追加事件 */
  append(event: MessageEvent): void {
    this.events.push(event);
    this.listeners.forEach(h => h(event));
  }

  /** 基于事件日志重建消息列表 */
  rebuild(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const event of this.events) {
      switch (event.type) {
        case 'MESSAGE_SENT':
          messages.push({ role: 'user', content: event.content });
          break;
        case 'MESSAGE_RECEIVED':
          messages.push({ role: 'assistant', content: event.content });
          break;
        // ... 其他事件类型
      }
    }
    return messages;
  }

  /** 订阅新事件 */
  subscribe(handler: (event: MessageEvent) => void): () => void {
    this.listeners.push(handler);
    return () => {
      const idx = this.listeners.indexOf(handler);
      if (idx >= 0) this.listeners.splice(idx, 1);
    };
  }

  /** 导出事件日志 */
  export(): MessageEvent[] {
    return [...this.events];
  }

  /** 从事件日志导入 */
  import(events: MessageEvent[]): void {
    this.events = [...events];
  }
}

// 使用:每次用户发送消息或 AI 回复时追加事件
const eventStore = new MessageEventStore();
eventStore.subscribe(event => {
  if (event.type === 'MESSAGE_SENT' || event.type === 'MESSAGE_RECEIVED') {
    scrollToBottom();
  }
});

6.8 并发状态处理(Race Condition 防护)

在 LLM TUI 中,用户可能快速连续操作,需要防止竞态条件:

class ConcurrencyGuard {
  private operationLock = false;
  private pendingOperations: Array<() => Promise<void>> = [];

  /** 执行操作,如果正在执行则排队 */
  async enqueue(operation: () => Promise<void>): Promise<void> {
    if (this.operationLock) {
      // 如果已有相同操作在排队,丢弃旧的
      if (operation.toString() === this.pendingOperations[0]?.toString()) {
        return;
      }
      this.pendingOperations.push(operation);
      return;
    }

    this.operationLock = true;
    try {
      await operation();
    } finally {
      this.operationLock = false;
      // 处理下一个排队操作
      const next = this.pendingOperations.shift();
      if (next) await this.enqueue(next);
    }
  }

  /** 清空排队 */
  clear(): void {
    this.pendingOperations = [];
  }
}

// 使用 —— 防止用户在 AI 回复时重复发送
const guard = new ConcurrencyGuard();

screen.key('C-s', () => {
  guard.enqueue(async () => {
    const text = inputBox.getValue().trim();
    if (!text) return;
    await sendMessage(text);
  });
});

6.9 状态持久化

保存和恢复对话状态,支持会话中断后继续:

interface PersistedState {
  messages: ChatMessage[];
  config: {
    model: string;
    maxTokens: number;
  };
  lastTimestamp: number;
  eventLog: MessageEvent[];
}

class StatePersistence {
  private static readonly SAVE_INTERVAL = 30000; // 30 秒自动保存

  constructor(
    private state: ObservableState<any>,
    private storagePath: string,
  ) {}

  /** 启动自动保存 */
  startAutoSave(): void {
    setInterval(() => this.save(), StatePersistence.SAVE_INTERVAL);
  }

  /** 手动保存 */
  async save(): Promise<void> {
    const snapshot: PersistedState = {
      messages: this.state.get().messages,
      config: { model: 'mock', maxTokens: 4096 },
      lastTimestamp: Date.now(),
      eventLog: [], // 从 eventStore 导出
    };
    await fs.promises.writeFile(
      this.storagePath,
      JSON.stringify(snapshot, null, 2),
    );
  }

  /** 加载保存的状态 */
  async load(): Promise<boolean> {
    try {
      const data = await fs.promises.readFile(this.storagePath, 'utf-8');
      const snapshot: PersistedState = JSON.parse(data);
      this.state.patch({
        messages: snapshot.messages,
      });
      return true;
    } catch {
      return false; // 首次运行或无保存文件
    }
  }
}

6.10 撤销/重做(Undo/Redo)

对话 TUI 中,用户可能误操作或想回溯到之前的对话状态。提供 Undo/Redo 支持需要管理状态快照。

状态快照模式

interface StateSnapshot {
  messages: ChatMessage[];
  timestamp: number;
  description: string;  // 人类可读的操作描述
}

class UndoRedoManager {
  private undoStack: StateSnapshot[] = [];
  private redoStack: StateSnapshot[] = [];
  private readonly MAX_HISTORY = 50;

  /** 保存当前状态快照 */
  pushSnapshot(
    messages: ChatMessage[],
    description: string,
  ): void {
    // 深拷贝当前消息列表
    const snapshot: StateSnapshot = {
      messages: JSON.parse(JSON.stringify(messages)),
      timestamp: Date.now(),
      description,
    };

    this.undoStack.push(snapshot);
    // 新操作清空重做栈
    this.redoStack = [];

    // 限制历史深度
    if (this.undoStack.length > this.MAX_HISTORY) {
      this.undoStack.shift();
    }
  }

  /** 撤销:恢复到上一个状态 */
  undo(currentMessages: ChatMessage[]): ChatMessage[] | null {
    const snapshot = this.undoStack.pop();
    if (!snapshot) return null;

    // 保存当前状态到重做栈
    this.redoStack.push({
      messages: JSON.parse(JSON.stringify(currentMessages)),
      timestamp: Date.now(),
      description: '撤销',
    });

    return snapshot.messages;
  }

  /** 重做:恢复到撤销前的状态 */
  redo(currentMessages: ChatMessage[]): ChatMessage[] | null {
    const snapshot = this.redoStack.pop();
    if (!snapshot) return null;

    // 保存当前状态到撤销栈
    this.undoStack.push({
      messages: JSON.parse(JSON.stringify(currentMessages)),
      timestamp: Date.now(),
      description: '重做',
    });

    return snapshot.messages;
  }

  /** 是否可以撤销 */
  get canUndo(): boolean {
    return this.undoStack.length > 0;
  }

  /** 是否可以重做 */
  get canRedo(): boolean {
    return this.redoStack.length > 0;
  }

  /** 获取撤销历史描述列表(用于 UI 展示) */
  getUndoHistory(): string[] {
    return [...this.undoStack].reverse().map(s => s.description);
  }

  /** 清空历史 */
  clear(): void {
    this.undoStack = [];
    this.redoStack = [];
  }
}

命令模式(更精细的 Undo/Redo)

相比于整个状态的快照,命令模式支持细粒度的撤销/重做:

interface Command {
  description: string;
  execute(): void;
  undo(): void;
}

class AddMessageCommand implements Command {
  description = '添加消息';

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

  execute(): void {
    this.messages.push(this.message);
    // 渲染消息到 UI
  }

  undo(): void {
    const idx = this.messages.indexOf(this.message);
    if (idx >= 0) {
      this.messages.splice(idx, 1);
      // 从 UI 中移除消息元素
    }
  }
}

class CommandHistory {
  private commands: Command[] = [];
  private currentIndex = -1;

  execute(command: Command): void {
    command.execute();
    // 清空当前索引之后的所有命令(新的分支)
    this.commands = this.commands.slice(0, this.currentIndex + 1);
    this.commands.push(command);
    this.currentIndex = this.commands.length - 1;
  }

  undo(): void {
    if (this.currentIndex >= 0) {
      this.commands[this.currentIndex].undo();
      this.currentIndex--;
    }
  }

  redo(): void {
    if (this.currentIndex < this.commands.length - 1) {
      this.currentIndex++;
      this.commands[this.currentIndex].execute();
    }
  }
}

集成到 TUI

class ChatTUIWithUndo extends ChatTUI {
  private undoRedo = new UndoRedoManager();

  protected sendMessage(text: string): void {
    // 发送消息前保存快照
    this.undoRedo.pushSnapshot(this.messages, `发送消息: ${text.slice(0, 20)}`);
    super.sendMessage(text);
  }

  // 绑定快捷键
  protected bindUndoRedoKeys(): void {
    this.screen.key('C-z', () => {
      const restored = this.undoRedo.undo(this.messages);
      if (restored) {
        this.messages = restored;
        this.rebuildMessageList();
        this.addSystemMessage('{yellow-fg}↩️ 已撤销{/yellow-fg}');
      }
    });

    this.screen.key('C-y', () => {
      const restored = this.undoRedo.redo(this.messages);
      if (restored) {
        this.messages = restored;
        this.rebuildMessageList();
        this.addSystemMessage('{yellow-fg}↪️ 已重做{/yellow-fg}');
      }
    });

    // 在帮助栏显示 Undo/Redo 状态
    this.updateHelpBar();
  }
}

Undo/Redo 设计考量

考量方案
快照 vs 命令快照实现简单但占用内存;命令模式更精确但实现复杂
历史深度限制最多 50 步快照,防止内存溢出
深拷贝使用 JSON.parse(JSON.stringify()) 确保快照不被后续修改影响
UI 反馈撤销/重做后显示提示信息,帮助用户感知操作结果
自动保存触发每次用户发送消息时自动保存快照,无需手动
流式期间禁用在 streaming/thinking 状态下禁用 Undo/Redo,防止状态不一致

6.11 状态检视与调试面板

开发过程中需要能够观察应用内部状态的变化。以下是在 TUI 中集成调试工具的方法。

状态变更日志

class StateDebugLogger {
  private log: Array<{
    timestamp: Date;
    action: string;
    prevState: Partial<AppState>;
    nextState: Partial<AppState>;
  }> = [];
  private readonly MAX_LOG = 1000;

  /** 记录一次状态变更 */
  snapshot(
    action: string,
    prevState: Partial<AppState>,
    nextState: Partial<AppState>,
  ): void {
    this.log.push({
      timestamp: new Date(),
      action,
      prevState: { ...prevState },
      nextState: { ...nextState },
    });

    // 日志上限保护
    if (this.log.length > this.MAX_LOG) {
      this.log.splice(0, this.log.length - this.MAX_LOG);
    }

    // 开发模式下输出到控制台
    if (process.env.DEBUG) {
      console.log(`[State] ${action}:`, prevState, '→', nextState);
    }
  }

  /** 获取最近的 N 条日志 */
  recent(n: number = 10): string[] {
    return this.log.slice(-n).map(entry =>
      `[${entry.timestamp.toISOString().slice(11, 19)}] ${entry.action}`
    );
  }

  /** 导出完整日志 */
  export(): string {
    return this.log.map(entry =>
      `[${entry.timestamp.toISOString()}] ${entry.action}`
    ).join('\n');
  }

  /** 搜索日志 */
  search(query: string): typeof this.log {
    return this.log.filter(entry =>
      entry.action.toLowerCase().includes(query.toLowerCase())
    );
  }
}

// 使用
const stateLogger = new StateDebugLogger();

// 每次状态变化时记录
class ChatTUIDebug extends ChatTUI {
  private setState(partial: Partial<AppState>): void {
    const prev = { status: this.state.status, ... };
    Object.assign(this.state, partial);
    stateLogger.snapshot('setState', prev, partial);
  }
}

内置调试面板

F12C-d 打开覆盖层,实时查看状态:

class DebugPanel {
  private panel: Widgets.BoxElement | null = null;
  private refreshTimer: ReturnType<typeof setInterval> | null = null;

  constructor(
    private screen: Widgets.Screen,
    private getState: () => AppState,
    private getMessages: () => ChatMessage[],
  ) {}

  toggle(): void {
    if (this.panel) {
      this.close();
    } else {
      this.open();
    }
  }

  private open(): void {
    this.panel = blessed.box({
      parent: this.screen,
      top: 1, right: 0,
      width: 40,
      height: '50%',
      style: { fg: 'white', bg: '#111122' },
      border: { type: 'line', fg: '#4444aa' },
      label: ' 调试面板 ',
      tags: true,
      scrollable: true,
      alwaysScroll: true,
    });

    // 每 500ms 刷新状态显示
    this.refreshTimer = setInterval(() => this.update(), 500);
    this.update();
  }

  private update(): void {
    if (!this.panel) return;
    const state = this.getState();
    const msgs = this.getMessages();

    const content = [
      '{bold}⚙️ 运行时状态{/bold}',
      `状态: {${this.stateColor(state.status)}-fg}${state.status}{/${this.stateColor(state.status)}-fg}`,
      `消息数: ${msgs.length}`,
      `Token 数: ${state.totalTokens ?? '--'}`,
      '',
      '{bold}📋 最近事件{/bold}',
      ...stateLogger.recent(8).map(line => `  ${line}`),
      '',
      '{bold}💾 内存{/bold}',
      `  Messages: ~${JSON.stringify(msgs).length} bytes`,
      `  Undo 栈: ${undoRedoManager ? undoRedoManager['undoStack']?.length ?? 0 : 0}`,
    ].join('\n');

    this.panel.setContent(content);
    this.panel.setScrollPerc(100);
    this.screen.render();
  }

  private stateColor(status: string): string {
    const colors: Record<string, string> = {
      idle: 'green', thinking: 'yellow', streaming: 'cyan',
      tool_call: 'magenta', tool_result: 'blue', error: 'red',
    };
    return colors[status] || 'white';
  }

  private close(): void {
    if (this.refreshTimer) {
      clearInterval(this.refreshTimer);
      this.refreshTimer = null;
    }
    if (this.panel) {
      this.panel.detach();
      this.panel = null;
      this.screen.render();
    }
  }
}

// 绑定调试面板快捷键
const debugPanel = new DebugPanel(screen, () => state, () => messages);
screen.key('f12', () => debugPanel.toggle());
screen.key('C-d', () => debugPanel.toggle()); // 备选

开发模式与生产模式的分离

class DebugMode {
  static isEnabled(): boolean {
    return process.env.DEBUG === 'true' ||
           process.env.NODE_ENV === 'development' ||
           process.argv.includes('--debug');
  }

  static setup(screen: Widgets.Screen): void {
    if (!this.isEnabled()) return;

    // 开发模式下:显示更多信息,添加调试快捷键
    const stateDebugger = new StateDebugLogger();

    // 捕获所有 screen.render() 调用并记录频率
    const originalRender = screen.render.bind(screen);
    let renderCount = 0;
    screen.render = () => {
      renderCount++;
      originalRender();
    };

    // 在状态栏显示渲染计数
    setInterval(() => {
      if (renderCount > 0) {
        // 更新状态栏显示渲染统计
        renderCount = 0;
      }
    }, 5000);
  }
}

// 启动时检查
DebugMode.setup(screen);

调试工具设计原则

原则说明
无侵入调试代码通过条件编译或环境变量控制,不影响生产环境
实时性状态面板每 500ms 刷新一次,接近实时观察状态变化
可检索状态变更日志支持关键词搜索,快速定位问题
可导出日志和状态快照可导出为 JSON,便于离线分析
低开销日志上限 1000 条,定期清理,不会导致内存膨胀
快捷键F12 随时开关,不干扰正常操作

实践: 查看 examples/llm-chat.ts 中的完整状态管理实现,尝试添加一个新的状态(如 confirming —— 工具调用前等待用户确认)。

下一步: 第七章:工具调用 UI 实现