Skip to content

feat(tui): follow the viewport with the pinned prompt header and jump on click - #5

Merged
SparkofSpike merged 4 commits into
mainfrom
codex/tui-pinned-prompt-follow-jump
Oct 3, 2026
Merged

SparkofSpike merged 4 commits into
mainfrom
codex/tui-pinned-prompt-follow-jump

Conversation

@SparkofSpike

@SparkofSpike SparkofSpike commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

Follow-up to the pinned user-prompt header: it now tracks the turn the
viewport starts on instead of only the newest message, and clicking it
returns the viewport to the message it names.

  1. Per-turn hand-over. The header used to belong to the newest user
    message alone: scrolling up past that message dropped the header
    entirely, and it only came back once the viewport cleared the whole
    turn. It now belongs to whichever user message owns the content at the
    viewport top — the newest user message whose first rendered line sits
    above the viewport. The instant a newer prompt's first line reaches the
    top row, the header hands over to the previous turn's prompt in the same
    frame, with no blank window while scrolling across a turn boundary.
  2. Click to jump. The frame that paints the header records its rect and
    the message it names. A left click pins the transcript viewport to that
    message's first line (dropping pending wheel deltas and leaving the live
    tail); clicking outside the header row is left to the transcript
    selection path. The header registers as a hover target with a new
    translated "Jump to this message" label.

Changes

  • crates/tui/src/tui/widgets/mod.rs: scrolled_user_prompt_pin scans
    rendered lines newest-first for the first non-blank user-message start
    above top and returns the header line plus the owning message's
    original history index; the caller re-resolves the header against the
    final, row-reserved viewport.
  • crates/tui/src/tui/app.rs: ViewportState gains pinned_prompt_area /
    pinned_prompt_message; App::scroll_to_transcript_line mirrors the
    wheel/scrollbar jump path, and pinned_prompt_target_line resolves the
    recorded message against the click frame's layout.
  • crates/tui/src/tui/mouse_ui.rs: a left click inside the header rect
    jumps; clicks outside fall through to transcript selection.
  • crates/tui/src/tui/ui/frame.rs: hover registration so the header lights
    up under the pointer like every other clickable chrome; the agent-focus
    sampling branch drops the main session's header box.
  • crates/localization: new PinnedPromptJumpToMessage message id plus one
    string across all 15 shipped locale packs.

Review follow-up

Two independent reviews were posted on this PR, both closing as mergeable;
every finding was addressed before merge.

SpikeBot 003 (first pass) approved and flagged three items — two fixed in
4ac93a1ff, the third is design intent recorded here so it is not mistaken
for a regression later: after a click jumps to a message, that message's
first line becomes the viewport top row, so the header hands over to the
previous turn. The header always names the newest message whose first line
sits above the viewport — it deliberately yields the moment a message
reaches the screen.

  • A prompt opening on a blank line was picked as the pin target and then
    failed the first-line render check, dropping the header instead of
    handing over to an older message. Prompts without a renderable first line
    are now skipped during the scan, with a regression test.
  • The multi-line and non-identity collapsed_cell_map paths had no test
    coverage; both now have cases.

SpikeBot 005 approved with two findings, both fixed in 134835a50:

  • The agent-focus sampling path recorded the pinned header's hit box even
    though the focus pane paints no header — the focus banner row became an
    invisible click/hover target. The focus branch now drops the box.
  • Reserving the header row moves top down on the tail, so a prompt whose
    first line was exactly the full-height top row was hidden behind a header
    naming an older prompt. The header is re-resolved against the final
    viewport, with a regression test.

005 also asked for a message identity instead of a frame-bound line offset
(done: pinned_prompt_message + resolve at click time) and for an
end-to-end render→click→land test (done:
pinned_prompt_click_lands_on_the_named_message_end_to_end).

SpikeBot 003 re-checked both follow-up commits and closed mergeable,
verifying each fix point by point.

Type of Change

  • New feature (non-breaking change which adds functionality)

Testing

  • cargo fmt --all -- --check — clean for every file this change
    touches. (One finding on crates/tui/src/child_env.rs under the local
    rustfmt 1.9.0 is pre-existing on main — untouched by this PR.)
  • cargo check -p codewhale-tui --all-targets — exit 0
  • cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or — no findings in any file this change touches; the three remaining errors (child_env.rs:718, mcp.rs:290, lib.rs:13797) are pre-existing on main and untouched here.
  • cargo test -p codewhale-tui --lib -- pin_helper → 7 passed; 0 failed (hand-over timing, blank-led skips, reserved-viewport repin, filtered-map resolution)
  • cargo test -p codewhale-tui --lib -- pinned_prompt → 6 passed; 0 failed (header layout & tail reservation, hit-box recording, click jump, click outside the row, hover registration, render→click→land)
  • cargo test -p codewhale-tui --lib -- hover_target → 2 passed; 0 failed
  • cargo test -p codewhale-localization → 52 passed; 0 failed (key-parity gates across all packs, including the new message id)
  • Verified TUI behavior manually if UI changes — the click-to-jump
    path was verified live; the hand-over timing was iterated on live
    session feedback and is pinned by the unit tests above.

Checklist

  • Updated docs or comments as needed (module docs describe the
    hand-over semantics and the click path)
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes
  • Harvested/co-authored credit uses a GitHub numeric noreply address
    (no external contribution in this change)

Related Issues

No-Issue: interaction follow-up to the pinned prompt header; no upstream
issue was opened for this.

Attribution

🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)

The pinned user-prompt header used to track only the newest user message:
once that message left the viewport the header vanished, and it only came
back after the viewport cleared the whole turn. Resolve the header against
the content the viewport starts on instead -- the newest user message whose
first rendered line sits above `top` -- so crossing a turn boundary hands
the header over to the previous turn's prompt in the same frame, with no
blank window in between.

The helper returns the owning message's first rendered line so a later
slice can jump the viewport back to it.

Signed-off-by: Shizuku <2163018547@qq.com>
The pinned prompt header now records its painted rect and the target
message's first rendered line on the frame that draws it, so a left click
on the header returns the viewport to the message it names. The header
also registers as a hover target, and the new `PinnedPromptJumpToMessage`
label is translated for all 15 shipped locale packs.

The hit box only exists while mouse capture is on, and a click outside the
header row is left to the transcript selection path.

Signed-off-by: Shizuku <2163018547@qq.com>
@SparkofSpike

Copy link
Copy Markdown
Owner Author

评审结论

可以合并(approve)。 实现与 PR 描述一致:pin 头跟随视口、逐轮交棒无空窗、点击跳转到所指消息,方向正确;scrolled_user_prompt_pin、mouse 点击分支、hover 注册、localization key 均自洽,单测覆盖了核心行为。发现的问题都属低危的边界空隙与测试盲区,不阻塞合并,但建议补上对应用例后维护成本更稳。

发现的问题(按严重程度排序)

1.(低 ~ 中)空首行的用户消息仍会让头部闪没,违背「永不绝档」的核心承诺

crates/tui/src/tui/widgets/mod.rs:909-911

scrolled_user_prompt_pin 扫描到可视化用户消息首行后,let first = content.lines().next().unwrap_or("").trim(); if first.is_empty() { return None; }——只要这条消息的首行文本为空就整体返回 None,不会继续向前找更早一条仍然拥有屏幕内容的用户消息。

可到达性:一条内容以空行开头的多行 Prompt(如 content = "\n...实际内容",trim() 非空、lines().next() 为空)完全合法。此时用户在这个消息上方继续向下滚动时,头部会整个消失而非交棒给上一轮 —— 恰是 PR 宣称要消除的"闪没"窗口,只是触发条件换成了空首行。

旧代码只对"最新一条用户消息"执行这句 return None,新代码把它套在了滚动路径上任一条被命中的消息上,覆盖范围变大,故值得提出。建议:命中消息首行文本为空时继续回退扫描更早的用户消息,或为这一情况补测试并明确接受该行为。

2.(低)无测试覆盖多行用户消息与真实 collapsed_cell_map

  • pin_helper_hands_over_the_instant_the_newer_prompt_enters 等新测试全部用单行用户 cell(每个 cell 仅 pin_meta(&[(i,0)]) 一行),line_in_cell == 0 这个把"本行 == 消息首行"的关键判断只在平凡情形下被验证;
  • collapsed_cell_map 全部用恒等映射((0..5).collect()),collapsed_cell_map.get(*cell_index).copied().unwrap_or(*cell_index) 的非恒等分支没有任何用例。

这两条是实现多行、折叠场景下交棒正确性的承重逻辑(也是本 PR 相对旧代码的实质改动点),当前都只有"最顺滑"的路径被锁定。建议至少补两个用例:多行 prompt(line_in_cell > 0 的存在)验证能继续向上命中更早的 user cell;以及一个 collapsed_cell_map 非恒等(例如把渲染 cell 索引 0 → 原始 2 )的样例。

3.(提示性,非缺陷)点击跳转后头部恰会"让位"给上一轮

点击后 scroll_to_transcript_line(pinned_prompt_line) 把消息首行钉到 top;下一帧按"首行恰在同一行即属于屏幕"的规则,头部随即交棒到上一轮该显示的消息。也就是说:你点击跳过去的那条消息在跳转后不再"位于视口之上",头部会转指它之前的消息。这与 stated 模型完全一致(头部永远指"视口上方最近的消息"),但从用户视角"点头部跳过去"后看到的是头部改指别的消息,建议在 PR 说明里确认这符合预期交互,避免将来被当作回归。

复核建议

  • 问题按严重程度排序如下,均不阻塞合并:
    1. 空首行消息导致头交棒失败(widgets/mod.rs:909-911)——希望修复或补测试。
    2. 缺多行 / 非恒等 collapsed_cell_map 的单测(两处关键新增逻辑无锁)。
    3. 点击跳转后头部让位给上一轮的意图确认。
  • 已核对的关键点无碍:
    • top == 0 直接 return None(逻辑自洽,测试 pin_helper_is_idle_when_user_line_is_visible 覆盖);
    • 命中 key 映射均用同一帧的 transcript_cache,帧一致;resize / empty-state 会把 pinned_prompt_area / pinned_prompt_line 置 None(widgets/mod.rs:275 与 app.rs:6042 双点清理),不会残留过期 hitbox;
    • mouse 点击分支放置于 scrollbar ⟶ jump_to_latest ⟶ pin ⟶ tool_run_expand,pin 的 hitbox(content_area.y,高 1)与正文身体 content_area.y+1 无重叠;return Vec::new() 正确阻止后续落到正文选择路径:
    • PinnedPromptJumpToMessage 枚举 + ALL_MESSAGE_IDS + 15 个语言包都补齐,localizer 的 key-parity 测试通过(按 PR 自测 52 passed),新旧 key 集合一致;
    • 新字段在 init.rs 用 ..ViewportState::default() 补齐,无遗漏构造。
  • 复审可直接:cargo test -p codewhale-tui --lib -- pin_helper / pinned_prompt / pinned_prompt_header_registers_a_hover_target(本机未跑,依赖脚本,可顺手补跑确认绿)。

署名

🤖 由 SpikeBot 003(ClaudeCode-JP) 生成

Review follow-up (SpikeBot 003): a user message opening on a blank line
was picked as the pin target and then failed the first-line render check,
returning None instead of handing over to an older message -- a drop-out
on the very path this feature keeps covered. Prompts without a renderable
first line are now skipped during the scan.

Adds the coverage the review asked for: the scan keys off a message's
first rendered line rather than a body line, and filtered cell indices
resolve through the collapsed-cell map.

Signed-off-by: Shizuku <2163018547@qq.com>
@SparkofSpike

Copy link
Copy Markdown
Owner Author

结论

可以合并(无阻断性缺陷),但建议先处理 1 条 agent-focus 模式下的点击/悬浮副作用(本次新增的隐形热区),以及 1 条"预留行前后 top 不一致"的边界正确性问题;其余为健壮性与测试覆盖建议。

已核对且未发现问题的部分:折叠映射方向正确(TranscriptLineMeta::CellLine.cell_index 是过滤后的位置索引,用 collapsed_cell_map(filtered→original)映射回原始 history 索引,方向与 crates/tui/src/tui/widgets/mod.rs:883 一致);commit 3 对"空白开头 prompt"的跳过与渲染一致(render_plain_text 在 crates/tui/src/tui/markdown_render.rs:979 保留首行空行,因此这类消息的 line_in_cell == 0 确实是空行,跳过并继续向前交棒是正确且完整的修复);本地化在 15/15 locale 包、MessageId 枚举、ALL_MESSAGE_IDS 三处同步,并被 shipped_complete_packs_have_raw_key_parity_with_english / message_id_list_english_pack_stay_in_exact_sync 门禁覆盖。

发现的问题

1.【中】agent-focus 模式下新的头部热区覆盖"焦点横幅行",会误触发主会话跳转与错误悬浮提示

文件与行号:crates/tui/src/tui/ui/frame.rs:1730-1750、crates/tui/src/tui/widgets/mod.rs:655-662、crates/tui/src/tui/mouse_ui.rs:801-806、crates/tui/src/tui/ui/frame.rs:573-577、crates/tui/src/tui/agent_focus.rs:773-774。

焦点模式分支为了取 ocean_column 仍然调用 ChatWidget::new(app, chat_area)(frame.rs:1741),而该构造函数会按主会话状态写入 app.viewport.pinned_prompt_area / pinned_prompt_line(widgets/mod.rs:655-662)。但焦点面板由 render_focus 绘制,从不画这个头部,且只重写 last_transcript_area(agent_focus.rs:774),不会清理 pinned_prompt_*。

后果:只要主会话处于"最新 prompt 已落在视口上方"的状态(pin_last_prompt 默认 true,长回合很常见),焦点面板的首行——也就是 agent 横幅行(render_focus 在 area.y 绘制)——就变成一个隐形热区:

  • 左键点击该行会命中 mouse_ui.rs:801-806,调用 scroll_to_transcript_line 去滚动隐藏的主会话视口;
  • 悬浮时 frame.rs:573-577 会把它注册为悬浮目标,横幅行亮起并弹出 "Jump to this message"。

(同源的 jump_to_latest_button_area 在焦点模式也有同类问题,但那是本 PR 之前就存在的旧模式;本 PR 新增了 pinned_prompt_area 这一处。)

建议:只在真正绘制头部的分支(frame.rs:1751 的 else 分支)记录命中盒;或在焦点分支采样结束后清空 pinned_prompt_area / pinned_prompt_line。建议把旧的 jump 按钮一并纳入清理,避免同类隐性热区。

2.【低-中】头部在"预留一行之前"用全高 top 计算,预留后 top 会 +1,尾随边界处头部命名与契约不符,并把最新 prompt 首行藏进头部后面

文件与行号:crates/tui/src/tui/widgets/mod.rs:539-556、crates/tui/src/tui/widgets/mod.rs:905-910。

scrolled_user_prompt_pin 用预留前的全高视口 top 计算(:539-549),.take(top) 取 [0, top)(:905-910)。随后只要 pin 为 Some 就预留一行并重解析,top 会变成 {old_top, old_top+1} 之一(to_bottom 时恰好 +1,:550-556)。

复现条件(尾随态 to_bottom,默认):设渲染总行数 N、content_area 高度 H、最新用户消息首个渲染行索引 L,且 N - L == H(即最新回合内容正好铺满一整屏)。

  • 全高解析:top0 = N - H = L;.take(L) 把恰在第 L 行的最新 prompt 排除,于是选中更早的用户消息 Q。
  • pin = Some(Q) → 预留一行 → top1 = N - (H-1) = L + 1。
  • 渲染:头部行显示 Q,正文 = line_meta[L+1..],最新 prompt 的首行(索引 L)被头部遮住、不绘制。

而按 PR 自己写的契约("最新一条首个渲染行位于视口顶行之上"),在最终 top1 = L + 1 下头部应当是最新 prompt。对比 main:旧代码此处 first_line >= top → None(diff 中被删除的两行),即不显示头部、最新 prompt 作为正文首行可见——所以这是相对 main 的行为回退(从"无头部、prompt 可见"变成"显示上一条头部、prompt 被遮")。现有测试 pinned_prompt_reserves_header_without_hiding_tail_or_shifting_mouse_mapping(8 条 assistant,L=0,N-L ≠ H)未覆盖该等价边界。

建议:先用全高 top 试算"是否需要预留",预留并重解析后用最终 top 重新调用 scrolled_user_prompt_pin 取头部文案与跳转行(旧选中项首行 < top0 ≤ top1,重算必为 Some,不会出现预留/取消抖动),或直接让该函数以最终 top 为输入。

3.【低】跳转目标是绑定绘制帧的位置型行索引,跨帧可能失效;scroll_to_transcript_line 的状态位可再对齐滚动条路径

文件与行号:crates/tui/src/tui/widgets/mod.rs:655-662、crates/tui/src/tui/mouse_ui.rs:801-806、crates/tui/src/tui/app.rs:6085-6090、crates/tui/src/tui/mouse_ui.rs:1237-1240。

pinned_prompt_line 记录的是绘制那一刻的行偏移,而点击发生在之后的帧。若两帧之间转录发生重排(流式追加、折叠/展开、compaction 重编号——scrolling.rs 模块注释明确指出行偏移只会被 clamp 而非锚定),热区与目标就可能与当前内容错位,静默跳到错误位置。窗口很小(头部每帧重算),但一旦命中就是错误结果。可考虑存消息身份(history 索引/修订)并在点击时重解析其首行,或在点击时校验/裁剪目标。

另:scroll_to_transcript_line 恒置 user_scrolled_during_stream = true,而滚动条路径用 !is_at_tail()(mouse_ui.rs:1240)。由于头部只在 line < top ≤ max_start 时出现,当前点击必然落在非尾随态,暂无实际影响;但改用 !is_at_tail() 更一致,也能覆盖"陈旧索引被 clamp 回尾随"的情况。

4.【低】测试覆盖缺口

  • 第 2 条的"全高 top vs 预留后 top"边界无测试。
  • crates/tui/src/tui/ui/tests.rs:4884+ 的点击测试手工写死 pinned_prompt_area / pinned_prompt_line,crates/tui/src/tui/widgets/mod.rs:8426+ 只验证"记录的行号",缺少"真实渲染头部 → 点击 → 落到该消息"的端到端用例。
  • scroll_to_transcript_line 的裁剪路径(line ≥ max_start、转录在绘制后缩短)无测试。
  • 有选区时点击头部、以及 agent-focus 下不应触发跳转,均无测试。

复核建议

  1. 复现第 1 条:聚焦一个 worker,悬浮/左键点击焦点首行(横幅行),确认是否出现 "Jump to this message" 以及主会话视口是否被挪动。
  2. 复现第 2 条:构造"单轮回合渲染行数恰好等于 content_area 高度"的主会话(尾随态),核对头部命名与最新 prompt 首行是否被遮。
  3. 补第 4 条中的用例;可在 scrolled_user_prompt_pin 调用点加一条断言"最终 top 下重算的头部 == 渲染头部",防止边界回归。

署名

🤖 由 SpikeBot 005(Codex-GH) 生成

Review follow-up (SpikeBot 005, posted on this PR):

- The agent-focus sampling path builds the main ChatWidget only for its
  ocean column, but that constructor recorded the pinned header's hit box
  while the focus pane paints no such header — the focus banner row became
  an invisible hot zone that answered clicks and hover. The focus branch
  now drops the stale box.
- Reserving the header row moves `top` down on the tail, so a prompt whose
  first line was exactly the full-height top row ended up hidden behind a
  header naming an older prompt. The header is now re-resolved against the
  final viewport, with a regression test for the reserved case.
- The header records the message (original history index) instead of a
  frame-bound line offset, and the click path re-resolves it against the
  click frame's layout; `scroll_to_transcript_line` shares the scrollbar
  path's tail expression.
- Adds the render -> click -> land end-to-end case the review asked for.

Signed-off-by: Shizuku <2163018547@qq.com>
@SparkofSpike

Copy link
Copy Markdown
Owner Author

复核结论

修复到位,可以合并(approve)。 两个新增提交分别完整回应了上一轮我(SpikeBot 003)的问题 1、2,以及 SpikeBot 005 提出的三处改进。逐条核对后,4ac93a1f 的空首行交棒修复与 mline / 非恒等 collapsed map 补测均正确;134835a5 的三处修复——agent-focus 隐形热区清理、预留行后的最终 top 重解析、以消息索引替代行号并在点击帧重解析——实现精准、无新引入问题,且 4 个新增测试都确实锁住了对应行为。未发现回退或阻塞项。

特别说明:本机(雨云东京生产机)未装 Rust 工具链(无 cargo),无法在本机实际跑 cargo test -p codewhale-tui;以下是纯代码审查结论。若推进合并,建议在带 Rust 环境的工作机再跑一遍相关测试确认绿灯。

逐条核对

上轮问题 1(空首行 Prompt 交棒失败)——已充分修复

widgets/mod.rs 的 scrolled_user_prompt_pin 内 find_map 闭包改用 if content.lines().next().unwrap_or("").trim().is_empty() { return None; }。关键见 #1:在 .rev().find_map() 里 return None 会让扫描继续向前回退更早的用户消息,而不是像旧代码那样整体 return None 让头部消失——精确修复了我上轮指出「只对最新一条用户消息生效、滚动路径上位命中的消息空首行就全盘放弃」的缺陷。且首行判断与渲染一致(SpikeBot 005 已确认 render_plain_text 保留空格首行),line_in_cell == 0 对应 content 的首文本行。测试 pin_helper_skips_blank_first_line_prompts_and_keeps_handing_over 直接锁定该场景。✅

上轮问题 2(多行 / 非恒等 collapsed_cell_map 测试缺失)——已补齐

pin_helper_uses_first_rendered_line_and_resolves_filtered_cells 用了多行 user cell((0,0),(0,1))加非恒等映射 map = vec![1,2,3](filtered 0 → 原始 1),同时锁定「首行用作 pin」与「filtered→original 映射」两条承重逻辑;message 断言值也从固定的 0 改为经映射后的 1。✅

134835a 三处修复

处一(frame.rs 焦点分支清理隐形热区)——正确完整。
采样构造函数(主会话 ChatWidget::new)会在焦点分支偷偷写入 pinned_prompt_area / pinned_prompt_message,而焦点面板不画这个 header、首行是 agent 横幅。修复在采样之后、render_focus 之前把两个字段一并置 None,同时关闭了点击(mouse_ui.rs:803)与 hover(frame.rs:575)两个入口——单处赋值同时消掉 click + hover 双路径的隐形热区,处理完整。我确认 frame.rs 正常分支(else)走 chat_widget.render,每帧都会重算这两个字段,不存在陈旧残留。SpikeBot 005 提的旧 jump_to_latest_button_area 同类问题属本 PR 之前就存在的既有模式,非本次新引入,可另行跟进,不阻塞。✅

处二(预留行后用最终 top 重解析 pin)——正确,精准买到 SpikeBot 005 的边界。
复现边界("单轮回合渲染行数恰等于 content_area 高度、尾随态")时:全高 top0 = L,.take(top0) 把恰在第 L 行的最新 prompt 排除;预留一行后 top1 = L+1,修复在 resolve_transcript_viewport_after_layout 之后用最终 top 重新调用 scrolled_user_prompt_pin,此时只剩 L+1 行 → .take(L+1) 纳入第 L 行,头部正确改指最新 prompt,内容不再被遮挡、不再与 main 行为回退。且重排必有结果:旧候选首行 < old_top ≤ new_top,必然仍在扫描范围内,不会出现预留→取消抖动。测试 pin_helper_repins_against_the_reserved_viewport 精确复现了「全高清 top vs 预留后 top」边界(用 roomy 渲染学出布局,再以恰好 N-L 高度重渲),断言头部指"newest prompt"。✅

处三(消息索引替代行号 + 点击帧重解析)——正确完整。

  • scrolled_user_prompt_pin 返回 orig_idx(原始 history 索引,经 collapse 映射回原始坐标),pinned_prompt_message 记录该消息身份而非位置偏移;
  • pinned_prompt_target_line() 在点击所在帧,用同一 collapsed_cell_map 把 line_in_cell == 0 的渲染 cell 映射回原始索引并与存储消息比对,在点击时重解析目标行;消息被折叠/过滤后返回 None,则点击不掉任何操作(不会 teleport);
  • mouse_ui 点击分支改用它,return Vec::new() 照旧吞掉 header 行点击、不落到正文选择路径;头热区 None 时点击不传透选择。两侧坐标(记录/解析都对 collapse 映射与应用 history index),身份一致。

scroll_to_transcript_line 尾部改 !is_at_tail():我核实 TranscriptScroll::at_line 的 offset 与 TAIL_SENTINEL 互斥,at_line 恒非 tail,故现值仍恒 true,与旧行为完全一致,只是与滚动条路径对齐以免将来 at_line 自行 clamp 时背离。这是安全的纯对齐,无行为变化。✅

3. 新增测试是否锁定行为

  • pin_helper_uses_first_rendered_line_and_resolves_filtered_cells:锁定多行 + 非恒等 map(上轮问题 2)。
  • pin_helper_skips_blank_first_line_prompts_and_keeps_handing_over:锁定空首行交棒(上轮问题 1)。
  • pin_helper_repins_against_the_reserved_viewport:锁定最终 top 重解析(004 问题 2)。
  • pinned_prompt_click_lands_on_the_named_message_end_to_end:把真实渲染 .expect(header painted...) → 真实点击 handler → 真实 pinned_prompt_target_line 解析 → viewport 落点串在一起,避免了手工写死 pinned_prompt_area / pinned_prompt_line 的夹具测试盲区,成功覆盖「记录器与鼠标处理各半边独立」的断裂点。

这 4 个用例确实锁住了三处新行为与两处旧缺陷。

遗留或新发现的问题

  1. (提示性,非缺陷) pinned_prompt_click_lands..._e2e 的 expected 是渲染后用同一 pinned_prompt_target_line() 回读的,因此它锁不住「该 line 究竟是不是那个消息的首行」(即特定消息身份的真实性),只锁住了「render→click→resolve→落一条非 tail 行的全链路自洽」。对锁回归(坐标/布局不一致导致整条路径失效)足够,但要锁「落到确切消息」需一个硬写预期的断言。属锦上添花,不阻塞。
  2. (提示性,非本条引入) 焦点模式下旧的 jump_to_latest_button_area 仍是同类隐形热区、本次未清理。系 PR 之外的存量行为,建议后续单独处理。
  3. (提示性) 结构性校验在本机未能执行(见开头说明):改名 pinned_prompt_line → pinned_prompt_message 全域彻底(cloned 该分支 grep 已无残留),但最终合并前建议在具备 Rust 的机器上跑 cargo test -p codewhale-tui --lib -- pinned_prompt 确认绿。

以上问题均非阻塞。结论:可以合并。

署名

🤖 由 SpikeBot 003(ClaudeCode-JP) 生成

@SparkofSpike
SparkofSpike merged commit b0e36f3 into main Oct 3, 2026
24 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant