# internal/tui — Bubble Tea v2 界面

> 范围：本目录内不可派生的约束。全局见仓库根 `AGENTS.md` §6.8。
> **这里没有专属守卫**（`check_*.sh` 共十一把，管的是依赖方向、裸 go、原子写、文件行数、测试卫生），所以 TUI 的全部不变量只由代码注释 + 渲染/尺寸矩阵测试钉住。

## 边界

唯一面向用户的 model/Update/View。只消费事件总线并按其渲染：**不生产事件、不做压缩判定、
不写配置生效逻辑**（写回内核在 `internal/config/persist.go`）。不 import `internal/agents`
（落点由 `app` 侧计算）。

## 硬约束

**1. `listenForEvents` 是一次性 cmd，没有重放队列。** 只有"事件被消费"才补回监听
（`tui_update.go:9-17` 的 `keepListening`），给按键或 resize 补会让监听 goroutine 按按键次数堆积。
任何新增叠加模式/守卫都必须消费 `taskEventMsg` 并重新武装监听器，否则界面永久断流
（根 AGENTS.md §11 坑 6）。

**2. 弹窗守卫是白名单，`taskCompleteMsg` 必须透传。** `tui_update.go:46-56`：弹窗打开时只放行
少数消息类型。`taskCompleteMsg` 是 `taskRunning` **唯一的清零点**，吞掉的表现是界面永久卡在
"运行中"且最终答案不落地。**求助与授权已不是弹窗**（`ask_state.go` / `ask_panel.go`）：它们是 footer 的一块，不进这道守卫。

**3. 布局尺寸只有一个口径。** `computeFooterHeight`（`tui_render.go:16-18`）与 `View()` 的 footer
拼接逐行对应（`tui_view.go:212-214`）；视口行高与正文四列宽度也只算一次 —— `computeBodyLayout`
（`tui_view.go:40`）= 高 `termHeight - footer - tabBarHeight`、宽 `termWidth - 滚动条列 - dashboard 列`，
resize（`tui_render.go:137`）、切标签页（`tui_tabs.go:283`）、后台标签页尺寸（`tui_update_system.go:135`）
全部走它。差一行整屏抖动；差一列（旧 resize 用 `termWidth - dashboardWidth` 且没扣 tab 栏）的代价是
View 每轮纠正宽度 + 整段正文白排一遍。`View()` 因此**只拼接**正文列（`joinColumns`，`tui_view.go:239`）：
viewport 自己已把每一行补齐到列宽（`TestViewportViewPadsEveryLine` 钉住这条第三方前提），再套一次
`Width().Render()` 就是把同一件事做第二遍（40 行 × 109 列实测 243µs → 5.5µs），而且满宽的行会被
再折一次、行数翻倍。`lipgloss.Style.Height(h)` 是**含上下边框**的总高，
按 `height` 而不是 `height-2` 分配行预算会多要 2 行，被末尾的"行数收敛 + 补边框"悄悄截掉
——那处收敛在 `renderDashboard`（`tui_dashboard.go:142`）：`:157-160` 明写**四段**（◆ Tokens / ⚙ Activity / ⧗ LATENCY / ⧉ COMPRESS）抢的是同一份 `height-2`，让位序 = token > Activity > ⧗≈⧉（`:169-176` 的扣除、`:211-231` 的两段预算），两个后段在 `maxRows < 2` 时必须**整段让位**——传 0 进渲染器等于"不限高"，它会反过来把 Activity 挤掉。
`:261-285` 先按 `innerWidth` 裁行、补圆角边框、最后再把超出的行砍掉，所以代价是**侧栏最后一段少两行
而不报错**。footer 每加一块都要同时进 `computeFooterHeight`，行数只从渲染结果数（`askPanelHeight` = `lipgloss.Height(askPanelBlock)`），固定段只有一个算法 `footerFixedRows`（含 `timelineFooterRows`，**有待答求助时它整块让位**）。同理日志的重着色：不走 `viewport.SetHighlights`（它按 `ansi.Strip` 之后的字节位算偏移、
却用原文判行，在我们的上色日志上必然错位），走 `StyleLineFunc`（`tui_search.go:139-140`，挂点在 `:160`）。
（根 AGENTS.md §11 坑 7。）

**3.1 流式重排按时间合并，不按 chunk。** bubbletea **每条消息都调 `View()`**（`tea.go` 的 Update 后
直接 render），而一次 flush 就标一次脏 —— 不合并的话"重排次数 = chunk 数 / 5"，实测 4000 chunk
触发 800 次重排（每消息 1.4ms@300 条、2.7ms@1200 条）。所以合并必须发生在 `View()` 里，
发生在 chunk 处理里没用。规则：只有"没有结构性变化 **且** 脏条目全部是正在流式的 `ai_stream`"才允许推迟到
`streamRepaintInterval`（80ms，必须 < tick 的 100ms，否则尾巴要迟一拍）—— 判据在 `streamDirtyHeld`
（`tui_render.go:207`），别退化成布尔开关：定稿（`streaming=false`）、`ai_response`、新条目、尺寸变化
都必须立刻落地。被推迟时 `viewportDirty` 保持为真，且 tick 的 idle 早退条件里必须带
`hasDirtyEntries()`（`tui_update_system.go:50`），否则没人再来触发。配套的两处 memo：流式条目的
已闭合 thought 面板（`renderStreamEntry`，`tui_render.go:391`：宽度或块序列变了才重画，定稿后走
普通条目的 `renderedCache`）、transcript 顶部固定头部（`renderWelcomePanel`，`tui_view.go:559`，
key 只含宽度/行高/cwd；**头部分支不许算 footer 行高**，那条路会走 token 面板与 langManager）。

**3.2 正文是虚拟化的：viewport 里只有可见区间。** 虚拟化之后一次重排的成本是 O(变过的条目 + 窗口)，不再是 O(历史)（实测 4000 chunk：viewport 内容 924KB → 41KB，
每消息 548µs → 385µs，且 300 条与 1200 条历史已经分不出差别）。滚动游标是绝对行号
`body.top`，`bodyIndex`（`tui_body.go:43`）是唯一行索引：条目 → 排版块（agent 分隔行 + 正文，
含 thinking 续行）→ 行数前缀和。四条不可违反：
① **行数统计与取词同源** —— `bodyBlockFor`（`:158`）既算 `lines` 也给文本，`bodyText`（`:295`）
必须等于全文的连续切片；旧的 `contentPartLinePrefix` 按条目原始行数累加（不认跳过项、分隔行、
续行），圈动画范围够用、拿来切片整屏错位，已删。
② **`scrolledUp` 是反着写的** —— 跟随尾部必须是零值（新建 model、清空会话、恢复 tab 都从零值起步），
写反的症状是"流式输出停在开头"。
③ **`growBodySlice`（`:149`）必须保留内容** —— 换数组时 `make` 会把已排好的块清零，而 `validFrom`
不知道数组被换过。
④ **条目数的唯一权威是 `m.logEntries`** —— `m.contentParts` 只是它的派生渲染缓存，**不许**当循环上界或条目数（`bodyEnsureIndex` 取的就是 `len(m.logEntries)`，`tui_body.go:100-106`）。
换一批条目（清空会话、从历史恢复、切标签页）必须把排版缓存与 `bodyIndex` **一起**作废（`applyRestoredMemory`，`tui_resume.go:571-581`），只清 `logEntries` 会留下越界现场 —— 本次 panic 正是旧 `bodyBlockFor` 按 `len(contentParts)` 索引 `logEntries`、崩在 `View()` 里。
排版/索引路径对两切片**短暂不等长**（新条目已追加而正文还没渲染、或恢复后残留更长的缓存）必须夹边界、**不许裸索引**：排版在 `View()` 调用链上，越界一次就是整屏 panic。`rebuildContentCache` 的增量分支同理是硬顺序 —— **先对齐 `contentParts` 长度、再处理脏下标**（`tui_render.go:248-303`，脏下标写的就是这份派生缓存，长度必须先行可信）；缓存没覆盖到的脏下标要**保留**给下一拍，`delete` 掉等于留下一段永不重排的陈旧正文。行数统计与取词同源见 ①。
守卫：`TestBody_EntriesShorterThanRenderCache`、`TestBody_RenderCacheShorterThanEntries`（`tui_body_test.go:265,295`）、`TestRebuildContentCache_TrimsStaleContentParts`、`TestRebuildContentCache_AppendedDirtyEntryAlignsFirst`（`tui_render_test.go:76,107`）、`TestApplyRestoredMemory_ShorterHistoryAfterLongerOne`、`TestApplyRestoredMemory_ViewMatrix`（`tui_resume_restore_test.go:786,840`）。
滚动一律走 `bodyScrollTo/bodyScrollBy/bodyGoto*/bodyPageBy`，**不许**再直接对正文 viewport 调 `Scroll*/Page*/SetYOffset/GotoBottom`，`viewport.Update` 也不接正文按键（滚轮在 `handleMouseMsg`）：
两处都能滚就错位。滚动条、ruler、搜索读绝对索引 —— `searchLineAt` 存绝对行，而 `searchLineStyle`（`tui_search.go:158`）收到的是**窗口内**行号，必须加回 `body.top`。
`bodyIndex` 整体随标签页保存/恢复（`tab.body = m.body.clone()`），且必须与 `tab.viewport` 同版本，否则恢复后 `bodySync` 以为窗口没变而跳过 SetContent，屏幕停在另一个 tab 的内容上。

**4. 流式槽的键 ≠ 显示名。** `streamSlotKey` 返回 `agent#delegationID`（`tui_update_tasks.go:1025-1030`），
它是三张槽表 `aiChunkBuffers` / `aiStreamActiveEntries` / `aiStreamCompletedEntries`
（`tui_model.go:335-338`，随标签页整体保存恢复 `tui_tabs.go:146-157`）唯一的键；`le.from` /
`m.currentAgent` / token 归集**必须继续用裸 Agent 名**——混用的症状是标签变成 `Coding#call_3`。
只按名字分槽的症状是两股流涌进同一条条目、文字粘成一段交错形态（`AAABBB`，
`tui_parallel_stream_test.go:24` 写明症状、`:73` 断言那个形态）。坐标为空时退回裸名，
所以 Director 与存量 rollout 行为逐字不变。

**5. 口径只算一次。** token 面板三个表面共用 `buildTokenPanel`，所有口径只在 `tokenSnapshot()` 里算；
压缩区块同理在 `compressSnapshot()`、耗时区块同理在 `latencySnapshot()`（`latency_panel.go`）。**不要在任何表面另取计数器直接格式化**（根 AGENTS.md §6.8）。
三个压缩事件名在本包只有一份字面量副本，加档位要同改三张表（`compress_panel.go:20-22`）。⧗ 段的 `total` 是唯一每 tick 会变的数字：显示粒度必须等于进 `dashboardCacheKey` 的粒度（1s），任务收尾必须 `freezeTotal` 定格 —— 不定格的话空闲态每秒白重画一次，而 `handleTickMsg` 永远不肯停 tick。

**6. 斜杠命令必须显式列进 `/` 分支**（`tui_update_commands.go:33`）：`/` 前缀在 TUI 里**默认是搜索**
（`processCommand` 的该分支末尾是 `m.runSearch`，`:20,27-56`），不列进那条 switch 链的 `/name` 就是在搜
字符串 "name"，界面上什么都没发生。**暂停态绝不能走 `handleTaskCompleteMsg` 的换 ctx 分支**
（`tui_update_commands.go:199-201`）——那会丢掉闸门所在的那棵 ctx。

**7. 列表/表单的共用原语只有一份。** `common/list_metrics.go:1-6` 就是"两份同形实现"的修法：
`components` 只留别名（`components/dialog_list.go:28-31`），引导向导共用同一份。**不许在任一边再抄一份**。
提示条同理（`common/hintbar.go`，曾有六份拷贝）：放不进就从尾部整段丢弃，所以**书写顺序就是优先级、退出键写第一段**。
输入框视觉令牌同理：`applyTextareaTokens`（`tui_model.go:573-576`）是主输入框与向导字段的唯一来源。
`dialogListChrome = 10`（`internal/tui/components/dialog_list.go:18-22`）是"除列表外占用的行数"的事实，对话框加一行必须同步改它。

**8. 对话框自己不知道屏幕多大。** 入栈方必须 `SetBounds`（`tui_dialogs.go:245-249`）——弹窗守卫会
吞掉 `WindowSizeMsg`，不设尺寸则 `View()` 因 width<40 返回空串，对话框永远不可见。
`components` 不许 import `internal/tui`（循环依赖），共享类型在本地重定义（`internal/tui/components/shared_types.go:4-7`）。

**8.1 信息类对话框（`/skills`、`/mcp`）与列表对话框共用同一套几何。**
`components/info_dialogs.go` 的 `infoDialogChrome`（9）是除列表外的固定行数：边框 2 + 内边距 2 +
标题 1 + 分隔线 1 + 表头 1 + 提示前空行 1 + 提示 1。每多/少一行非列表内容都必须同步改它，
列表预算走 `common.ListBudget`。它们不可选择，所以**不进** `selectProbe`；但必须透传
`taskCompleteMsg`（硬约束 2）。

**8.2 斜杠补全带类型、按类型分组。** `skillSuggestions` 是 `[]completionItem{name,kind,desc}`，不是 `[]string`；
内置命令与技能撞名时**内置命令胜**且只 warn 一次（`tui_completion.go`）。面板分两段（命令在前，空组连组头一起不渲染），**组头行数只由 `skillSuggestionHeaderCount()` 算** —— `renderSkillSuggestions` 与 `editorBlockHeight` 都走它，写死 +2 就是第 3 条那个差一行抖动（闸门 `TestSkillSuggestionHeightMatchesRenderedLines`）。手打 `/name args` 直接执行技能
（首个 token 精确命中才算），否则回落搜索 —— 改这条要保留 `TestTUI_SlashUnknownFallsBackToSearch`。

**8.3 新对话框进按键分发要么加 `case`，要么实现 `components.InfoPanel`。**
`handleDialogStackKey`（`tui_update_keys.go:51`）是**类型 switch**，末尾 `return m, nil, false`（`:435`）
就意味着这把键归编辑模式。而 `tui_update.go:86` 明确**不**把 KeyMsg 转发给 `dialog.Update`，
所以漏加的那个对话框：Esc 关不掉（面板还压在屏幕上，输入框却顺手被切到 NORMAL）、滚动键全部失效，
**并且没有任何编译错误或日志**——根 AGENTS.md §11 坑 33 的 TUI 版本（测试与生产不走同一个载体）。
信息类面板因此按接口认领（`components/info_dialogs.go:16`，model 侧 `:428`）：
只读面板实现 `HandleInfoKey` 就自动拿到 Esc/滚动，不必再改类型 switch。
闸门：`TestInfoDialogsCloseOnEsc`（`slash_skill_test.go:188`，删掉接口那条 case 即红）、
`TestInfoDialog_ScrollsToEnd`（`info_dialogs_test.go:78`，按的是 `HandleInfoKey` 而不是 `Update`）。

**9. 向导独占键盘。** 任务运行中或弹窗打开时 `/setup` 直接拒绝（`setup_embed.go:24-34`），
理由是第 1 条那个没有重放队列的监听器。

**10. TUI 键位是"替换"语义，不是追加别名。** 翻译表两个方向都要写：按下的键 → 规范键
（各消费点 switch 的字面量），被换掉的默认键 → 空串 = 解绑（`buildKeyMap`，`tui_keymap.go:29-51`）；
消费点一律先过 `translateKey`（`tui_update_keys.go:507,844`），解绑键换成 `keyUnbound` 哨兵
（`tui_keymap.go:25,60-68`）落到 default 分支。只实现"自定义键 → 规范键"一半的症状：用户以为把
quit 挪走了，ctrl+c 仍退程序，而且默认键永远释放不掉。抢同一个键时保留**先声明**的动作并归并
一条 warn（`tui_keymap.go:42-53`；表在 `tui_model.go:656-657` 构造）。`keyUnbound` 绝不能
长得像真按键（`\x00unbound`），否则"解绑"变成"触发某个动作"。

**11. 行数预算必须按渲染后的行数算。** `lipgloss` 的 `Width()` 会把超宽的行**再折一次**，
"我数过是 N 行"和屏上真正的行数不是一个数，多出来的行在 AltScreen 里看不见也滚不回去。
三道闸门都在计数之前做，缺一个就能复现：① `fitLines` 先剪断越宽的行（宽度用 `lipgloss.Width`，
样式串里的 ANSI 不占列，`setup_render.go:346`）；② `wrapPlain` 必须**硬切超长单词**——provider
错误原文里全是 URL、base64 密钥、JSON 片段，只按空白折行等于没折（`setup_render.go:404`）；
③ 状态条自己有预算 `capStatus`，否则"正文 = 高−2−状态行"算出负数并夹到最小值，总高反而超出
终端（`setup_render.go:199`）。同理卡片/表格每行的列宽要**正好加到容器内宽**：分隔符数量按
真正拼出的列数算，徽章还要算上 `BadgeStyle` 的 `Padding(0,1)`（`setup_screens.go:147`）。
这套内核向导与恢复列表共用（`tui_resume_list.go:198,461`），新表面做排版先走它们，不许再抄一份。

**12. 向导表单屏的模态键必须带修饰符。** 表单每一格都是活的 textarea，裸字母要先送进输入框
（`setup_keys.go:567-577`：`ctrl+p` 预设 / `ctrl+o` 展开可选区 / `f2` 密钥两态），用裸键的症状是
填 `https://…` 吃掉 `ps`、并莫名打开预设列表。裸键只允许在**没有 textarea 活着**的屏
（列表、摘要、预设选择中）。反向的一半：底栏键帽上写的每个单键都必须真的能改状态——键帽比
裸串更容易被相信，"键帽上写着、按下没反应"是新的失效模式。`setup_presets.go` 那份手写的端点 URL 表
**不是权威**（`:1-10` 明写理由：`internal/models` 目录里没有 base_url），`applyPreset`（`:84`）
**只填空字段**、绝不覆盖用户已经写过的值；"这个端点认不认这把 key"的唯一事实来源是探测屏。

**13. setupWizard 的 `done` ≠ 退出，退出是宿主的决定。** 同一个状态机跑在两个宿主里：独立跑时
（`RunSetup` 置 `standalone`，`setup_render.go:527-529`）`Update` 必须把 `done` 翻成 `tea.Quit`
（`setup_keys.go:437-442`），否则 bubbletea 只认 `tea.Quit` 而程序停在最后一屏、症状是"按 q 没反应"；
嵌在主 TUI 时（`standalone=false`）**必须不翻**——翻了就是把整个 yagent 退掉，交还键盘由宿主侧
`Done()` 分支负责（`setup_embed.go:72-86`）。

**14. `applyOps` 不是事务，失败信息必须带 note 与已写条数。** 逐条落盘、失败立即停止
（`setup.go:431`），之前的几条已经真的写进了 config.toml；错误必须带上失败那条 op 的 `note` 原文
（`fmt.Errorf("%s: %w", op.note, err)`，`setup.go:446`）与 `writtenCount`"已写入 N/M 条"
（`:140-141,448`），用户才知道剩下几条怎么自救。包装成"什么都没发生"才是撒谎。

**15. 配色令牌、压暗内核与界面语言各只有一个来源。** 调色集中在 `common/styles.go` 的 `Styles`/`ColorTokens`
（**只有一套深色**）；强调色用途钉死：141 lavender = 活动 tab（`tui_tabs.go:370`）/ thinklink
（`tui_thinklink_fullscreen.go:16`）/ LLM 调用（`common/styles.go:225`），62 = 滚动条
（`tui_view.go:23`）/ 面板边框（`tui_dashboard.go:24`），214 = 命令（`common/styles.go:228`），
39 = 搜索/输入（`common/styles.go:178`），76 = 运行中（`tui_model.go:112`）。终端背景判定只有
一处 `tui_helpers.go:53`，它只切两样东西：glamour markdown 渲染器的样式（`tui_model.go:610-615`），
以及压暗往哪个方向压（`dim.go` 的锚点）；不切上述令牌（明确用 `compat.AdaptiveColor` 的少数状态栏令牌除外）。
压暗背景只有一个内核 `dim.go`：判据唯一 `m.askDimActive()`（面板不在场就逐字节原样返回，否则每帧白扫一遍全屏），
`dimAnsi` 只改前景、保持原编码族、认不出的序列整条原样，并给没上色的正文补一个压暗后的默认字色；压哪几块由
`View()` 里那处按位置算的排除表决定（面板与紧贴它下面的答题区不压），卡片底色与输入框字段同源（`askCardBg` = 235）。
界面语言是 `i18n.go` 的
全局 `langManager`（69 个字段、默认英文 `i18n.go:283`、只在 TUI 内切换 `tui_model.go:757-762`，
**没有配置项**），与 `[agent] lang`（LLM 写字段用的语言）是两回事，别互相读取。

## 子目录

- **`common/`** — 令牌层（`styles.go` 配色令牌（只有一套深色）、`list_metrics.go` 几何原语、`grad.go` 渐变按
  grapheme 切分）。`PanelWidths` 的 inner = panel − 6；`ListBudget` 装不下指示行就**放弃指示行**
  （保证表面永远不高出终端）；`Ellipsize` 超长走**中间省略**——同前缀的模型名靠尾部区分。
  零测试文件，靠调用方的矩阵测试间接暴露。
- **`components/`** — 对话框 + `DialogStack`。只渲染、只消费按键。三个选择类对话框必须同时接进
  `select_dialog_contract_test.go` 的三份契约（按键 / 渲染 / 提示条 `runSelectDialogHintContract`）。同包只准一个 ANSI 剥离辅助（`check_test_hygiene.sh` 硬拦）。
- **`layout/`** — **当前实际不负责布局**：`Region`/`AllRegions`/`IsCompact` 在包外零调用点，
  唯一被调的是 `Resize`（`tui_update_system.go:130-132`，构造在 `tui_model.go:639`）。
  改 `computeRegions` 不会改变界面；真实高度契约在 `tui_render.go`。compact 断点 120
  （`layout/engine.go:40-42`）与 dashboard 可见断点 `termWidth >= 120`（`tui_dashboard.go:51`）
  是同值两份实现。零测试，且因无消费者而永远不会红——要接进真实渲染就先补宽度矩阵，或先删掉它。

## 入口

`tui_model.go`（状态 + 渲染缓存 + 输入框令牌）、`tui_update.go`（消息路由与守卫）、
`tui_view.go` + `tui_render.go`（高度契约）、`tui_chrome.go`（手绘面板骨架）。

## 闸门

`TestView_ExactHeight`（`tui_chrome_test.go:269`）与 `TestView_ExactHeightWithPanels`（`:582`）、
`TestLiveRender_ListenerStaysArmedInOverlayModes`（`tui_livestream_test.go:97`，四个覆盖层）、
`TestTaskCompleteMsgNotSwallowedBehindDialog`（`tui_model_switch_test.go:155`）、
`TestPaste_LandsInAskAnswerBox`（`tui_paste_test.go:20`，粘贴→答案框→总线上那条回答）、
`TestParallelStreamsSameAgentDoNotMerge`（`tui_parallel_stream_test.go:26`，让 `streamSlotKey`
忽略 `DelegationID` 即红）、`TestTokenPanel_FooterFitsTermWidth`（`token_panel_test.go:257`）、`TestLatencyTierLadderRows` / `TestLatency_NeverStarvesTokensAndActivity` / `TestLatencySection_EntersDashboardCacheKey`（`latency_panel_render_test.go`：⧗ 段的 `latencyRunRows` 一旦返回拼好的多行串就被按一行计数、`latencyMax` 不再给 Activity 留行、签名漏进 `dashboardCacheKey`，三件事各自立刻红；⧗ 段的其余闸门在 `latency_panel_data_test.go` / `latency_total_test.go` / `latency_panel_surfaces_test.go`）、
`TestBodyWidthIsOneSource`（`tui_view_layout_test.go:34`，把 resize/切标签页的宽度公式改回旧写法即红）、
`TestViewportViewPadsEveryLine`（同文件 `:100`，钉住"viewport 自己补齐每行"这条被 `joinColumns` 依赖的前提）、
`TestTUI_StreamRepaintIsCoalesced` / `_NonStreamDirtIsNeverHeld` / `_TickStaysArmedWhileRepaintHeld` /
`_StreamPanelsMatchOneShot`（增量渲染必须与一次性逐字相同）/ `_WelcomeHeaderSkipsFooterMath`
（`tui_stream_repaint_test.go`：把 `streamDirtyHeld` 改成恒 false、或把面板 memo 关掉，立刻红）、
`TestBody_WindowIsAPlainSliceOfTheTranscript`（`tui_body_test.go:36`，任意高度任意游标下窗口必须等于
全文切片）/ `TestBody_ViewportHoldsOnlyTheWindow`（`:60`，改回 `SetContent(全文)` 即红）/
`TestBody_AppendRebuildsOnlyTheTail`（`:91`，扩容用 `make` 换数组即红）/ `TestBody_FollowTail`
（`:117`，`scrolledUp` 写反即红）/ `TestBody_TabSwitchKeepsCursorAndWindow`（`:208`）、
`TestLLMTimeoutDialog_SizeGuard` / `_WidthMatrix`（`components/timeout_dialog_test.go:146,167`）、
`TestWizardRenderWidthMatrix`（向导侧：宽度 {40..240} × 高度 × 七屏，断言行数 ≤ 终端高度，钉第 11 条）、
`TestUnboundSentinelMatchesNothing`（`tui_keymap_remap_test.go:90`：哨兵长得像真按键、或表里空值没被翻译成解绑，即红——钉第 10 条）、
`TestWizardHintsAreNotDecoration`（`setup_test.go:765`：键帽写出的单键没有任何屏真的处理，即红——钉第 12 条的反向一半）、
`TestRunSetupProgramActuallyExits`（`setup_test.go:1081`：用 `tea.WithInput` 真跑一次程序断言 `Run`
返回，不是只查 cmd 形状；删掉 standalone 那条 `tea.Quit` 翻译即红——钉第 13 条）、
`TestLegacyStreamsWithoutDelegationKeepOldBehavior`（`tui_parallel_stream_test.go:57`：钉第 4 条的
"没有坐标时不许变新"）。
求助/授权面板的闸门在 `ask_panel_test.go`：`TestScreen_NeverExceedsTermHeight`（整屏行数全矩阵 = 终端 {20..60} × 宽 {30..240} × {无/短/45 项/长答案/长答案+45 项}），外加键帽逐个按一遍、`esc` 首位、折叠提示、队列、正文回落、软折行高度各一条；压暗的闸门在 `dim_test.go` / `dim_screen_test.go`（SGR 表与两套锚点、256 索引逐个不许跳段、判据跟着面板走、面板与答题区逐字节不变、真实渲染产物上行数与列宽一格不多、卡片底色两处同源）；提交清空的闸门在 `submit_clear_test.go`（两条 submit 路径都清、校验失败与任务收尾都不许抹掉用户正在起草的下一条）；
`/resume` 恢复链路的闸门在 `tui_resume_restore_test.go`（`TestApplyRestoredMemory_EntryShapeMatchesLivePath:85`
与 `_RenderParityWithLivePath:443`：恢复出的 timeline 条目必须与实时事件构造的条目同名、同渲染，
一次工具调用只产一条条目）。
退出收口在 `tui_helpers.go:73,79`：`p.Run()` 返回之后必须取消**每个 tab** 上还挂着的那次运行（闸门 `TestQuitCancelsTasksAcrossAllTabs`，`tui_task_cancel_test.go:86`）——TUI 里 Ctrl+C 是按键不是信号，漏掉的表现是 main 的 `FlushAll`/`Close` 跑在一个还活着的循环上 + 一个没人杀的进程组（杀组只挂在 ctx 取消上，见 `internal/tools/AGENTS.md` 硬约束 17）。
搜索高亮绕开 `viewport.SetHighlights` 的那个选择由 `TestSearch_StripANSIBeforeMatching`（`tui_chrome_test.go:393`）钉住（钉第 3 条）。
向导侧 `TestWizardRequiredGroupMatchesValidator`（`setup_test.go:438`）**双向**钉"表单必填集合 ==
config 侧真会拒的字段集合"：少一格 = 用户以为填完了却存不下（`config.WriteProvider` 的必填判据比
`validate()` 弱），多一格 = 表单在问不必要的问题。

**测试约定**：断言**精确单元格宽度**而非子串；宽度矩阵 `{30..240}`、终端尺寸矩阵 `{20..240}`；
共享 fixture `newTestModel()` / `chromeTestModel(t,w,h)`。
