AGENTS.md@internal/tui · git:20261104.dfea0b5 · 2026-11-04 · sha256 8a2de412f0faf5e3

AGENTS.md@internal/tui git:20261104.dfea0b5A

Immutable. This exact content never changes and is served at /api/v1/blob/8a2de412f0faf5e3.

# 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 索引逐个不许跳段、判据跟着面板走、面板与答题区逐字节不变、真实渲染产物上行数与列宽一格不多、卡片底色两处同源);
`/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)`。