<!-- 文档分类: IMPL-RPT-006 | 阶段: 实现 | 原路径: docs/修复记录/tui-流式与上下文显示-2026-06-01.md -->
# TUI 流式显示与上下文窗口修复记录

> ⚠️ **已归档 / 被取代**：本文档描述的自研 TUI 运行时（`replStore.js`(Flux store) / `tuiShell.js` / `TranscriptViewport.js` 等）已于 2026-06-05 全面迁移至官方 Ink 运行时（`backend/src/cli/tui/ink-components/`），上述文件均已删除。本文仅作历史修复记录保留，最新 TUI 行为请参阅 [tui-inquirer闪退修复-2026-06-05.md](%5BIMPL-RPT-003%5D%20tui-inquirer闪退修复-2026-06-05.md)。

**日期**: 2026-06-01
**版本**: v0.1.79 → v0.1.80
**范围**: TUI 流式渲染、Spinner 状态机、中文断行、上下文窗口动态化

---

## 1. Spinner 结束后不变为完成态

### 现象

AI 回复完成后，状态行仍显示 `✦ 正在输出... (64s)`，未切换为 `✳ 完成 · 64.0s` 的灰色终止态。

### 根因

`StreamingChunker` 使用 `setImmediate` 异步 flush 队列。当 `ai().chat()` 的 Promise resolve 时：

```
时序:
  1. chat() resolve
  2. .finally() → turnComplete dispatch → activity.state = 'done'
  3. setImmediate 回调触发 → chunker flush 残留 chunk
  4. CHUNK_RECEIVED dispatch → activity.state 被覆盖回 'streaming'
  5. 后续无 turnComplete，state 永久停在 'streaming'
```

`replStore.js` 中 `CHUNK_RECEIVED` 和 `STREAMING_BLOCK_START` 两个 action 无条件将 `activity.state` 设为 `'streaming'`，覆盖了已到达的 `'done'` 终态。

### 修复

三处协同修复，消除竞争条件：

**a) replStore.js — 终态保护**

`CHUNK_RECEIVED` 和 `STREAMING_BLOCK_START` 不再覆盖 `done` 状态：

```javascript
// before
activity: { ...state.activity, state: 'streaming' },

// after
activity: {
  ...state.activity,
  state: state.activity.state === 'done' ? 'done' : 'streaming',
},
```

**b) tuiShell.js — 同步 drain**

`chat()` 返回后、`streamingBlockEnd` 之前，同步清空 chunker 残留队列：

```javascript
const result = await ai().chat(text, { onChunk, onStatus });
chunker.drain();   // ← 新增：同步 flush 残留 chunk
// Finalize any open streaming block ...
```

**c) StreamingChunker — 新增 `drain()` 方法**

```javascript
drain() {
  if (this._queue.length === 0) return;
  const coalesced = this._coalesce();
  for (const item of coalesced) this._dispatch(item);
  this._queue.length = 0;
  this._scheduled = false;
}
```

**d) tuiShell.js — turnComplete 后强制渲染**

```javascript
processInput(trimmed).finally(() => {
  _setBusy(false);
  store.dispatch(actions.turnComplete({}));
  _renderScheduled = false;
  setImmediate(_doRenderAndTrack);  // 绕过 debounce，立即渲染 done 态
});
```

### 涉及文件

| 文件 | 修改内容 |
|------|----------|
| `backend/src/cli/tui/store/replStore.js` | `CHUNK_RECEIVED`、`STREAMING_BLOCK_START` 终态保护 |
| `backend/src/cli/tui/tuiShell.js` | `StreamingChunker.drain()`、同步 drain 调用、强制渲染 |

---

## 2. 中文断行（"今天" 被拆到两行）

### 现象

AI 回复中 "...等等。今" 在一行末尾，"天有什么..." 在下一行开头，中间还有空行。"今天" 作为一个语义单元被拆开。

### 根因

**不是** `wrapText` 的 bug。AI 模型（Claude）在流式输出时，token 边界恰好落在 "今" 和 "天" 之间，输出中包含了 `\n`：

```
实际模型输出: "...等等。今\n天有什么..."
```

`wrapText` 按 `\n` 切分后，"今" 和 "天" 被分到不同的行/段落。

### 修复

在 `wrapText` 入口添加正则预处理，将 CJK 字符间的孤立 `\n` 合并为连续文本：

```javascript
// TranscriptViewport.js — wrapText 入口
let cleaned = String(text).replace(
  /(?<=[\u2e80-\u9fff\uf900-\ufaff\ufe30-\ufe6f\uff01-\uff60])\n(?=[\u2e80-\u9fff\uf900-\ufaff\ufe30-\ufe6f\uff01-\uff60])/g,
  ''
);
```

规则：
- `CJK字\nCJK字` → 合并（`今\n天` → `今天`）
- `CJK字\n\nCJK字` → 保留段落分隔（`。\n\n第` 不变）
- `English\nEnglish` → 保留（不影响英文换行）

### 涉及文件

| 文件 | 修改内容 |
|------|----------|
| `backend/src/cli/tui/components/TranscriptViewport.js` | `wrapText` 入口 CJK `\n` 合并 |

---

## 3. Claude Opus 4 显示 200k 而非 1M 上下文

### 现象

欢迎页和 footer 显示 `上下文: 200k`，但 Claude Opus 4.6 实际支持 1M。

### 根因

`ai.js` 中 `MODEL_CAPABILITIES` 静态表记录了错误值：

```javascript
'claude-opus-4': { ..., context: 200000, ... },  // 应为 1000000
'claude-sonnet-4': { ..., context: 200000, ... }, // 应为 1000000
```

且 `_resolveModelContextLimit()` 完全依赖硬编码表，不查询 gateway 实测数据。

### 修复

**a) 修正 MODEL_CAPABILITIES 中的 Claude 4 系列 context 值**

```javascript
'claude-opus-4':   { ..., context: 1000000, ... },
'claude-sonnet-4': { ..., context: 1000000, ... },
```

**b) `_resolveModelContextLimit()` 优先查 gateway 实测数据**

```javascript
function _resolveModelContextLimit(modelHint = '') {
  // 1. Gateway adapter-reported real data (single source of truth)
  try {
    const gw = require('../services/gateway/aiGateway');
    const instance = typeof gw.getInstance === 'function' ? gw.getInstance() : gw;
    if (instance?.getModelContextWindow) {
      const dynamic = instance.getModelContextWindow(lower);
      if (dynamic > 0) return dynamic;
    }
  } catch { /* gateway not ready yet */ }

  // 2. Fallback: MODEL_CAPABILITIES static table
  // 3. Unknown model → 128000
}
```

### 涉及文件

| 文件 | 修改内容 |
|------|----------|
| `backend/src/cli/ai.js` | `MODEL_CAPABILITIES` context 值修正 + `_resolveModelContextLimit` gateway 优先 |

---

## 4. 上下文窗口全面去硬编码

### 现象

所有模型的 context window 都来自硬编码表（`MODEL_CONTEXT_LIMITS` / `MODEL_CAPABILITIES`），新增模型需要手动更新代码。

### 修复

建立单一数据源架构：**适配器实测 → gateway 缓存 → UI 显示**。

**a) 删除 `hudRenderer.js` 的 `MODEL_CONTEXT_LIMITS` 硬编码表**

`getContextLimit()` 完全委托给 gateway：

```javascript
function getContextLimit(model) {
  if (!model) return 0;
  try {
    const gw = require('../services/gateway/aiGateway');
    const instance = typeof gw.getInstance === 'function' ? gw.getInstance() : gw;
    if (instance?.getModelContextWindow) {
      return instance.getModelContextWindow(model);
    }
  } catch { /* gateway not ready */ }
  return 0;
}
```

**b) `aiGateway.js` — 未知模型异步查询**

```javascript
getModelContextWindow(modelId) {
  // 1. 精确缓存命中
  // 2. 部分匹配（版本后缀）
  // 3. 环境变量 KHY_CONTEXT_WINDOW
  // 4. 未命中 → 触发后台异步查询，返回 0
  this._resolveContextWindowAsync(modelId);
  return 0;
}

_resolveContextWindowAsync(modelId) {
  // 去重 + 遍历可用适配器的 listModels()
  // 找到后写入 _contextWindowCache，下次查询命中
}
```

**c) generate 成功后自动缓存**

```javascript
// finishResult 中
if (result?.success) {
  const usedModel = result.model || response?.model || options.model;
  if (usedModel && !this._contextWindowCache.has(usedModel)) {
    this._resolveContextWindowAsync(usedModel);
  }
}
```

**d) 数据流向**

```
适配器 listModels()          → contextWindow 字段
  ↓
aiGateway._contextWindowCache  → Map<modelId, number>
  ↓
hudRenderer.getContextLimit()  → 委托 gateway
  ↓
ai.js._resolveModelContextLimit() → 优先 gateway，fallback MODEL_CAPABILITIES
  ↓
FooterBar / 欢迎页            → 显示真实值，0 时隐藏
```

### 涉及文件

| 文件 | 修改内容 |
|------|----------|
| `backend/src/cli/hudRenderer.js` | 删除 `MODEL_CONTEXT_LIMITS` 表，`getContextLimit` 委托 gateway |
| `backend/src/services/gateway/aiGateway.js` | `_resolveContextWindowAsync`、generate 后自动缓存 |
| `backend/src/cli/ai.js` | `_resolveModelContextLimit` gateway 优先 |

---

## 5. CJK 孤字避免（wrapText 优化）

### 现象

行末刚好只剩一个 CJK 字符位置时，会出现孤字（如 "今" 独占行末，后续内容换行）。

### 修复

在 `wrapText` 的 greedy wrap 中添加孤字检测：

```javascript
const widthAfter = currentWidth + tok.cw;
const remaining = maxWidth - widthAfter;
// 如果当前 CJK 字符放入后，剩余空间不足以容纳下一个 CJK 字符
// 且下一个字符不是避头标点 → 提前换行，避免孤字
if (tok.cw === 2 && remaining >= 0 && remaining < 2 && ti + 1 < tokens.length) {
  const next = tokens[ti + 1];
  if (next.cw === 2 && !_NO_LINE_START.has(next.ch)) {
    lines.push(current + '\x1b[0m');
    current = ''; currentWidth = 0;
    continue;
  }
}
```

### 涉及文件

| 文件 | 修改内容 |
|------|----------|
| `backend/src/cli/tui/components/TranscriptViewport.js` | wrapText 孤字检测 |

---

## 验证方法

1. **Spinner 终态**: 发送 "你好"，等回复完成后确认状态行变为 `✳ 完成 · Xs`（灰色）
2. **中文断行**: 确认长中文回复中不出现 CJK 词内拆行
3. **上下文显示**: footer 显示 `ctx (1m)` 而非 `ctx (200k)`（使用 Claude Opus 4 时）
4. **未知模型**: 使用 Ollama 本地模型，确认首次显示 `0% ctx` 后自动更新为实际值

## 全部修改文件清单

| # | 文件 | 修复项 |
|---|------|--------|
| 1 | `backend/src/cli/tui/store/replStore.js` | Spinner 终态保护 |
| 2 | `backend/src/cli/tui/tuiShell.js` | chunker drain + 强制渲染 |
| 3 | `backend/src/cli/tui/components/TranscriptViewport.js` | CJK `\n` 合并 + 孤字避免 |
| 4 | `backend/src/cli/ai.js` | context 值修正 + gateway 优先 |
| 5 | `backend/src/cli/hudRenderer.js` | 删除硬编码表 |
| 6 | `backend/src/services/gateway/aiGateway.js` | 异步查询 + 自动缓存 |
