From 58bbbcec406d9e9b4d5531e66fa5594382b87cca Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Sat, 3 Oct 2026 11:46:39 +0800 Subject: [PATCH 1/4] feat(tui): re-pin the prompt header to the turn above the viewport 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> --- crates/tui/src/tui/widgets/mod.rs | 269 ++++++++++++++++++++++-------- 1 file changed, 203 insertions(+), 66 deletions(-) diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 664a4cd9b7..93de3c275f 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -647,7 +647,7 @@ impl ChatWidget { // cell scrolled (#6704). The footer's `Alt+V:details` hint names it. apply_selection(&mut lines, top, app); - if let Some(pin) = pinned_prompt { + if let Some((pin, _)) = pinned_prompt { lines.insert(0, pin); line_links.insert(0, Vec::new()); } @@ -830,50 +830,65 @@ pub(crate) fn active_entry_revision(active_rev: u64, salt: u64) -> u64 { revision_in_domain(mixed, true) } -/// Build the last-user-prompt header when that message is above the resolved -/// transcript viewport. The caller owns the one-row layout reservation so -/// the header never masquerades as `top` or displaces the newest tail line. +/// Build the pinned user-prompt header for the content at the top of the +/// resolved transcript viewport. +/// +/// The header belongs to whichever user message owns the content the +/// viewport starts on: the newest user message whose first rendered line +/// sits above `top`. The instant a newer prompt's first line reaches the top +/// viewport row — scrolling up, or the tail sitting short — the header hands +/// over to the previous turn's prompt, so it never blinks out while the user +/// scrolls across a turn boundary. The returned line index lets a click on +/// the header jump the viewport back to that message, and the caller owns +/// the one-row layout reservation so the header never masquerades as `top` +/// or displaces the newest tail line. fn scrolled_user_prompt_pin( history: &[HistoryCell], line_meta: &[TranscriptLineMeta], collapsed_cell_map: &[usize], top: usize, width: u16, -) -> Option> { - if width == 0 { +) -> Option<(Line<'static>, usize)> { + if width == 0 || top == 0 { return None; } - let (orig_idx, content) = - history - .iter() - .enumerate() - .rev() - .find_map(|(idx, cell)| match cell { - HistoryCell::User { content } if !content.trim().is_empty() => { - Some((idx, content.as_str())) - } - _ => None, - })?; - // The newest prompt sits near the tail, so search backward; a forward - // scan cost O(transcript) on every frame of a long session (#6652). - let first_line = line_meta.iter().rposition(|meta| match meta { - TranscriptLineMeta::CellLine { + // First rendered line of a non-blank user cell, as an original history + // index. Only `line_in_cell == 0` matches: later lines of a long prompt + // are its body, not the message start. + let user_first_line = |meta: &TranscriptLineMeta| -> Option { + let TranscriptLineMeta::CellLine { cell_index, - line_in_cell, + line_in_cell: 0, .. - } => { - let original = collapsed_cell_map - .get(*cell_index) - .copied() - .unwrap_or(*cell_index); - original == orig_idx && *line_in_cell == 0 - } - _ => false, - }); - let first_line = first_line?; - if first_line >= top { - return None; - } + } = meta + else { + return None; + }; + let original = collapsed_cell_map + .get(*cell_index) + .copied() + .unwrap_or(*cell_index); + matches!( + history.get(original), + Some(HistoryCell::User { content }) if !content.trim().is_empty() + ) + .then_some(original) + }; + // Newest user message whose start sits above the viewport's top row, + // scanned newest-first so a long prompt that began several screens up + // still resolves to its own first line. A prompt whose first line is + // exactly the top row belongs to the screen, not the header, so the + // hand-over happens the instant it enters. + let (first_line, orig_idx) = line_meta + .iter() + .enumerate() + .take(top) + .rev() + .find_map(|(line_index, meta)| user_first_line(meta).map(|orig| (line_index, orig)))?; + let content = match history.get(orig_idx) { + Some(HistoryCell::User { content }) => content, + _ => return None, + }; let first = content.lines().next().unwrap_or("").trim(); if first.is_empty() { @@ -894,15 +909,18 @@ fn scrolled_user_prompt_pin( shown.push('…'); } - Some(Line::from(vec![ - Span::styled( - format!("{} ", crate::tui::glyphs::USER), - Style::default() - .fg(palette::WHALE_HUMAN) - .add_modifier(Modifier::BOLD), - ), - Span::styled(shown, Style::default().fg(palette::TEXT_PRIMARY)), - ])) + Some(( + Line::from(vec![ + Span::styled( + format!("{} ", crate::tui::glyphs::USER), + Style::default() + .fg(palette::WHALE_HUMAN) + .add_modifier(Modifier::BOLD), + ), + Span::styled(shown, Style::default().fg(palette::TEXT_PRIMARY)), + ]), + first_line, + )) } impl Renderable for ChatWidget { @@ -8064,6 +8082,26 @@ mod tests { assert_eq!(first[(11, 14)].symbol(), second[(11, 14)].symbol()); } + /// Rendered line metadata for pin tests: `(cell_index, line_in_cell)`. + fn pin_meta(entries: &[(usize, usize)]) -> Vec { + entries + .iter() + .map(|(cell_index, line_in_cell)| TranscriptLineMeta::CellLine { + cell_index: *cell_index, + line_in_cell: *line_in_cell, + copy_prefix_width: 0, + copy_separator_after: crate::tui::ui_text::CopyLineSeparator::None, + }) + .collect() + } + + fn pin_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + #[test] fn pin_helper_returns_header_when_user_line_is_above_viewport() { let history = vec![ @@ -8075,28 +8113,19 @@ mod tests { streaming: false, }, ]; - let meta = vec![ - TranscriptLineMeta::CellLine { - cell_index: 0, - line_in_cell: 0, - copy_prefix_width: 0, - copy_separator_after: crate::tui::ui_text::CopyLineSeparator::None, - }, - TranscriptLineMeta::CellLine { - cell_index: 1, - line_in_cell: 0, - copy_prefix_width: 0, - copy_separator_after: crate::tui::ui_text::CopyLineSeparator::None, - }, - ]; + let meta = pin_meta(&[(0, 0), (1, 0)]); let map = vec![0, 1]; - let pin = super::scrolled_user_prompt_pin(&history, &meta, &map, 1, 40) + let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 1, 40) .expect("scrolled user prompt should yield a pinned header"); - let text: String = pin.spans.iter().map(|span| span.content.as_ref()).collect(); + let text = pin_text(&pin); assert!( text.contains("remember this prompt"), "expected pinned user text, got {text:?}" ); + assert_eq!( + line, 0, + "the pin must name the prompt's first rendered line" + ); } #[test] @@ -8104,16 +8133,124 @@ mod tests { let history = vec![HistoryCell::User { content: "still on screen".into(), }]; - let meta = vec![TranscriptLineMeta::CellLine { - cell_index: 0, - line_in_cell: 0, - copy_prefix_width: 0, - copy_separator_after: crate::tui::ui_text::CopyLineSeparator::None, - }]; + let meta = pin_meta(&[(0, 0)]); let map = vec![0]; assert!(super::scrolled_user_prompt_pin(&history, &meta, &map, 0, 40).is_none()); } + /// Scrolling up a turn keeps the header alive: it re-pins to the previous + /// turn's prompt instead of dropping out once the newest prompt leaves the + /// viewport. + #[test] + fn pin_helper_follows_the_viewport_up_to_the_previous_turn() { + let history = vec![ + HistoryCell::User { + content: "first prompt".into(), + }, + HistoryCell::Assistant { + content: "a1".into(), + streaming: false, + }, + HistoryCell::Assistant { + content: "a2".into(), + streaming: false, + }, + HistoryCell::Assistant { + content: "a3".into(), + streaming: false, + }, + HistoryCell::User { + content: "second prompt".into(), + }, + HistoryCell::Assistant { + content: "b1".into(), + streaming: false, + }, + HistoryCell::Assistant { + content: "b2".into(), + streaming: false, + }, + HistoryCell::Assistant { + content: "b3".into(), + streaming: false, + }, + ]; + let meta = pin_meta(&[ + (0, 0), + (1, 0), + (2, 0), + (3, 0), + (4, 0), + (5, 0), + (6, 0), + (7, 0), + ]); + let map: Vec = (0..8).collect(); + + // Viewport over the newest replies: the newest prompt owns the top. + let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 5, 40) + .expect("newest prompt pins while its line is above the viewport"); + assert!(pin_text(&pin).contains("second prompt")); + assert_eq!(line, 4); + + // Viewport scrolled up past the newest prompt: the header re-pins to + // the previous turn instead of disappearing. + let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 1, 40) + .expect("a turn above the viewport must keep a pinned header"); + assert!(pin_text(&pin).contains("first prompt")); + assert_eq!(line, 0); + } + + /// The header hands over the instant a newer prompt's first line reaches + /// the viewport's top row: no window where it blinks out while the user + /// scrolls across a turn boundary. + #[test] + fn pin_helper_hands_over_the_instant_the_newer_prompt_enters() { + let history = vec![ + HistoryCell::User { + content: "first prompt".into(), + }, + HistoryCell::Assistant { + content: "a1".into(), + streaming: false, + }, + HistoryCell::Assistant { + content: "a2".into(), + streaming: false, + }, + HistoryCell::User { + content: "second prompt".into(), + }, + HistoryCell::Assistant { + content: "b1".into(), + streaming: false, + }, + ]; + let meta = pin_meta(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]); + let map: Vec = (0..5).collect(); + + // One row before the newest prompt reaches the screen: still pinned + // to the newest prompt (line 3 is above a viewport starting at 4). + let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 4, 40) + .expect("the newest prompt is still pinned one row above the viewport"); + assert!(pin_text(&pin).contains("second prompt")); + assert_eq!(line, 3); + + // The newest prompt's first line is now the top row itself: hand over + // to the previous turn immediately, with no gap in between. + let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 3, 40) + .expect("the header must hand over instead of blinking out"); + assert!(pin_text(&pin).contains("first prompt")); + assert_eq!(line, 0); + + // Scrolling further keeps the previous turn pinned while its content + // fills the top of the screen. + let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 2, 40) + .expect("the previous turn stays pinned"); + assert!(pin_text(&pin).contains("first prompt")); + assert_eq!(line, 0); + } + #[test] fn pinned_prompt_reserves_header_without_hiding_tail_or_shifting_mouse_mapping() { let mut app = create_test_app(); From 9097635ece7c40340bca8cbfa0cfffb2aec716d4 Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Sat, 3 Oct 2026 11:46:50 +0800 Subject: [PATCH 2/4] feat(tui): click the pinned header to jump to its message 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> --- crates/localization/locales/ca.json | 3 +- crates/localization/locales/de.json | 3 +- crates/localization/locales/en.json | 3 +- crates/localization/locales/es-419.json | 3 +- crates/localization/locales/fr.json | 3 +- crates/localization/locales/hi.json | 3 +- crates/localization/locales/id.json | 3 +- crates/localization/locales/ja.json | 3 +- crates/localization/locales/ko.json | 3 +- crates/localization/locales/pt-BR.json | 3 +- crates/localization/locales/ru.json | 3 +- crates/localization/locales/uk.json | 3 +- crates/localization/locales/vi.json | 3 +- crates/localization/locales/zh-Hans.json | 3 +- crates/localization/locales/zh-Hant.json | 3 +- crates/localization/src/lib.rs | 4 ++ crates/tui/src/tui/app.rs | 23 +++++++++ crates/tui/src/tui/mouse_ui.rs | 9 ++++ crates/tui/src/tui/ui/frame.rs | 28 ++++++++++- crates/tui/src/tui/ui/tests.rs | 64 ++++++++++++++++++++++++ crates/tui/src/tui/widgets/mod.rs | 63 +++++++++++++++++++++++ 21 files changed, 220 insertions(+), 16 deletions(-) diff --git a/crates/localization/locales/ca.json b/crates/localization/locales/ca.json index 1beca11880..65a3bb9929 100644 --- a/crates/localization/locales/ca.json +++ b/crates/localization/locales/ca.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "No s'ha pogut reordenar la fixació: {error}", "ModelPickerReadinessRefreshed": "S'ha actualitzat la disponibilitat dels models · files del catàleg reconstruïdes", "ModelPickerOpenToRefresh": "Obre /model per actualitzar la disponibilitat i el catàleg", - "ModelPickerPinnedChip": "fixat" + "ModelPickerPinnedChip": "fixat", + "PinnedPromptJumpToMessage": "Ves a aquest missatge" } diff --git a/crates/localization/locales/de.json b/crates/localization/locales/de.json index a7f7c1c3f3..3ffa8e4b0b 100644 --- a/crates/localization/locales/de.json +++ b/crates/localization/locales/de.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "Anheftung konnte nicht verschoben werden: {error}", "ModelPickerReadinessRefreshed": "Modellbereitschaft aktualisiert · Katalogzeilen neu aufgebaut", "ModelPickerOpenToRefresh": "Öffne /model, um Bereitschaft und Katalog zu aktualisieren", - "ModelPickerPinnedChip": "angeheftet" + "ModelPickerPinnedChip": "angeheftet", + "PinnedPromptJumpToMessage": "Zu dieser Nachricht springen" } diff --git a/crates/localization/locales/en.json b/crates/localization/locales/en.json index dc2bb6a581..e474271d86 100644 --- a/crates/localization/locales/en.json +++ b/crates/localization/locales/en.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "Could not reorder pin: {error}", "ModelPickerReadinessRefreshed": "Model readiness refreshed · catalog rows rebuilt", "ModelPickerOpenToRefresh": "Open /model to refresh readiness and catalog", - "ModelPickerPinnedChip": "pinned" + "ModelPickerPinnedChip": "pinned", + "PinnedPromptJumpToMessage": "Jump to this message" } diff --git a/crates/localization/locales/es-419.json b/crates/localization/locales/es-419.json index 95eb268a87..1cca803875 100644 --- a/crates/localization/locales/es-419.json +++ b/crates/localization/locales/es-419.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "No se pudo reordenar la fijación: {error}", "ModelPickerReadinessRefreshed": "Disponibilidad de modelos actualizada · filas del catálogo reconstruidas", "ModelPickerOpenToRefresh": "Abre /model para actualizar la disponibilidad y el catálogo", - "ModelPickerPinnedChip": "fijado" + "ModelPickerPinnedChip": "fijado", + "PinnedPromptJumpToMessage": "Ir a este mensaje" } diff --git a/crates/localization/locales/fr.json b/crates/localization/locales/fr.json index 7ea12bc323..42e6632e7a 100644 --- a/crates/localization/locales/fr.json +++ b/crates/localization/locales/fr.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "Impossible de réordonner l’épingle : {error}", "ModelPickerReadinessRefreshed": "Disponibilité des modèles actualisée · lignes du catalogue reconstruites", "ModelPickerOpenToRefresh": "Ouvrez /model pour actualiser la disponibilité et le catalogue", - "ModelPickerPinnedChip": "épinglé" + "ModelPickerPinnedChip": "épinglé", + "PinnedPromptJumpToMessage": "Aller à ce message" } diff --git a/crates/localization/locales/hi.json b/crates/localization/locales/hi.json index 90c926828d..9cb805571c 100644 --- a/crates/localization/locales/hi.json +++ b/crates/localization/locales/hi.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "पिन का क्रम नहीं बदला जा सका: {error}", "ModelPickerReadinessRefreshed": "मॉडल की तैयारी रीफ़्रेश हुई · कैटलॉग पंक्तियाँ फिर से बनीं", "ModelPickerOpenToRefresh": "तैयारी और कैटलॉग रीफ़्रेश करने के लिए /model खोलें", - "ModelPickerPinnedChip": "पिन किया" + "ModelPickerPinnedChip": "पिन किया", + "PinnedPromptJumpToMessage": "इस संदेश पर जाएँ" } diff --git a/crates/localization/locales/id.json b/crates/localization/locales/id.json index a6e93178f8..fc5e580824 100644 --- a/crates/localization/locales/id.json +++ b/crates/localization/locales/id.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "Tidak dapat mengubah urutan sematan: {error}", "ModelPickerReadinessRefreshed": "Kesiapan model diperbarui · baris katalog dibangun ulang", "ModelPickerOpenToRefresh": "Buka /model untuk memperbarui kesiapan dan katalog", - "ModelPickerPinnedChip": "disematkan" + "ModelPickerPinnedChip": "disematkan", + "PinnedPromptJumpToMessage": "Lompat ke pesan ini" } diff --git a/crates/localization/locales/ja.json b/crates/localization/locales/ja.json index 54a99a5a6b..3cf8c79c83 100644 --- a/crates/localization/locales/ja.json +++ b/crates/localization/locales/ja.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "ピン留めの順序を変更できませんでした: {error}", "ModelPickerReadinessRefreshed": "モデルの準備状況を更新しました · カタログ行を再構築しました", "ModelPickerOpenToRefresh": "準備状況とカタログを更新するには /model を開いてください", - "ModelPickerPinnedChip": "ピン留め" + "ModelPickerPinnedChip": "ピン留め", + "PinnedPromptJumpToMessage": "このメッセージへジャンプ" } diff --git a/crates/localization/locales/ko.json b/crates/localization/locales/ko.json index eb2d2058ae..73f6913017 100644 --- a/crates/localization/locales/ko.json +++ b/crates/localization/locales/ko.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "고정 순서를 바꾸지 못했습니다: {error}", "ModelPickerReadinessRefreshed": "모델 준비 상태를 새로 고쳤습니다 · 카탈로그 행을 다시 만들었습니다", "ModelPickerOpenToRefresh": "준비 상태와 카탈로그를 새로 고치려면 /model을 여세요", - "ModelPickerPinnedChip": "고정됨" + "ModelPickerPinnedChip": "고정됨", + "PinnedPromptJumpToMessage": "이 메시지로 이동" } diff --git a/crates/localization/locales/pt-BR.json b/crates/localization/locales/pt-BR.json index bb788ae3ba..f4a35f76a6 100644 --- a/crates/localization/locales/pt-BR.json +++ b/crates/localization/locales/pt-BR.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "Não foi possível reordenar a fixação: {error}", "ModelPickerReadinessRefreshed": "Prontidão dos modelos atualizada · linhas do catálogo reconstruídas", "ModelPickerOpenToRefresh": "Abra /model para atualizar a prontidão e o catálogo", - "ModelPickerPinnedChip": "fixado" + "ModelPickerPinnedChip": "fixado", + "PinnedPromptJumpToMessage": "Ir para esta mensagem" } diff --git a/crates/localization/locales/ru.json b/crates/localization/locales/ru.json index ab64c46381..61c9525a46 100644 --- a/crates/localization/locales/ru.json +++ b/crates/localization/locales/ru.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "Не удалось изменить порядок закрепления: {error}", "ModelPickerReadinessRefreshed": "Готовность моделей обновлена · строки каталога перестроены", "ModelPickerOpenToRefresh": "Откройте /model, чтобы обновить готовность и каталог", - "ModelPickerPinnedChip": "закреплено" + "ModelPickerPinnedChip": "закреплено", + "PinnedPromptJumpToMessage": "Перейти к этому сообщению" } diff --git a/crates/localization/locales/uk.json b/crates/localization/locales/uk.json index 7a634085c3..b11bea7859 100644 --- a/crates/localization/locales/uk.json +++ b/crates/localization/locales/uk.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "Не вдалося змінити порядок закріплення: {error}", "ModelPickerReadinessRefreshed": "Готовність моделей оновлено · рядки каталогу перебудовано", "ModelPickerOpenToRefresh": "Відкрийте /model, щоб оновити готовність і каталог", - "ModelPickerPinnedChip": "закріплено" + "ModelPickerPinnedChip": "закріплено", + "PinnedPromptJumpToMessage": "Перейти до цього повідомлення" } diff --git a/crates/localization/locales/vi.json b/crates/localization/locales/vi.json index 1f0f2fc5fc..e89a1bb5ce 100644 --- a/crates/localization/locales/vi.json +++ b/crates/localization/locales/vi.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "Không thể sắp xếp lại ghim: {error}", "ModelPickerReadinessRefreshed": "Đã làm mới trạng thái sẵn sàng của mô hình · đã dựng lại các dòng danh mục", "ModelPickerOpenToRefresh": "Mở /model để làm mới trạng thái sẵn sàng và danh mục", - "ModelPickerPinnedChip": "đã ghim" + "ModelPickerPinnedChip": "đã ghim", + "PinnedPromptJumpToMessage": "Nhảy đến tin nhắn này" } diff --git a/crates/localization/locales/zh-Hans.json b/crates/localization/locales/zh-Hans.json index 2aec343198..0baaca34d8 100644 --- a/crates/localization/locales/zh-Hans.json +++ b/crates/localization/locales/zh-Hans.json @@ -2438,5 +2438,6 @@ "ModelPickerPinReorderFailed": "无法调整固定顺序:{error}", "ModelPickerReadinessRefreshed": "已刷新模型就绪状态 · 已重建目录行", "ModelPickerOpenToRefresh": "打开 /model 以刷新就绪状态和目录", - "ModelPickerPinnedChip": "已固定" + "ModelPickerPinnedChip": "已固定", + "PinnedPromptJumpToMessage": "跳转到这条消息" } diff --git a/crates/localization/locales/zh-Hant.json b/crates/localization/locales/zh-Hant.json index a8b5b9dab5..1ec0a91e0f 100644 --- a/crates/localization/locales/zh-Hant.json +++ b/crates/localization/locales/zh-Hant.json @@ -2438,5 +2438,6 @@ "ModelPickerOpenToRefresh": "開啟 /model 以重新整理就緒狀態和目錄", "ModelPickerPinnedChip": "已釘選", "TranscriptThought": "思考完畢", - "TranscriptThoughtFor": "思考耗時 {duration}" + "TranscriptThoughtFor": "思考耗時 {duration}", + "PinnedPromptJumpToMessage": "跳轉到這則訊息" } diff --git a/crates/localization/src/lib.rs b/crates/localization/src/lib.rs index bf056f6c3f..ab1bcdfa4b 100644 --- a/crates/localization/src/lib.rs +++ b/crates/localization/src/lib.rs @@ -2673,6 +2673,9 @@ pub enum MessageId { ModelPickerReadinessRefreshed, ModelPickerOpenToRefresh, ModelPickerPinnedChip, + /// Hover label for the pinned user-prompt header above the transcript: + /// clicking the header jumps to the user message it names. + PinnedPromptJumpToMessage, } #[allow(dead_code)] @@ -5118,6 +5121,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ModelPickerReadinessRefreshed, MessageId::ModelPickerOpenToRefresh, MessageId::ModelPickerPinnedChip, + MessageId::PinnedPromptJumpToMessage, ]; pub fn tr(locale: Locale, id: MessageId) -> Cow<'static, str> { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index e7654da9d7..e141ee4fc9 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1018,6 +1018,13 @@ pub struct ViewportState { pub last_transcript_total: usize, pub last_transcript_padding_top: usize, pub jump_to_latest_button_area: Option, + /// Painted rect of the pinned user-prompt header above the transcript, + /// when one is shown and mouse capture is on. A left click there jumps + /// the viewport to `pinned_prompt_line`. + pub pinned_prompt_area: Option, + /// Transcript line index of the user message the pinned header + /// describes; the click target for `pinned_prompt_area`. + pub pinned_prompt_line: Option, /// Inner content rect of the composer (excluding border/padding), /// stored at render time for mouse coordinate mapping. pub last_composer_content: Option, @@ -1061,6 +1068,8 @@ impl Default for ViewportState { last_transcript_total: 0, last_transcript_padding_top: 0, jump_to_latest_button_area: None, + pinned_prompt_area: None, + pinned_prompt_line: None, last_composer_content: None, last_composer_scroll_offset: 0, last_composer_top_padding: 0, @@ -6030,6 +6039,8 @@ impl App { self.viewport.last_transcript_total = 0; self.viewport.last_transcript_padding_top = 0; self.viewport.jump_to_latest_button_area = None; + self.viewport.pinned_prompt_area = None; + self.viewport.pinned_prompt_line = None; self.needs_redraw = true; } @@ -6066,6 +6077,18 @@ impl App { self.needs_redraw = true; } + /// Jump the transcript viewport so rendered line `line` becomes its top + /// row. The pinned prompt header calls this to return to the user message + /// it names. Mirrors the wheel/scrollbar path: pending wheel deltas are + /// dropped so the jump lands where it was asked to, and the viewport + /// leaves the live tail. + pub fn scroll_to_transcript_line(&mut self, line: usize) { + self.viewport.transcript_scroll = TranscriptScroll::at_line(line); + self.viewport.pending_scroll_delta = 0; + self.user_scrolled_during_stream = true; + self.needs_redraw = true; + } + pub fn queue_message(&mut self, message: QueuedMessage) { self.queued_messages.push_back(message); } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index cd030c3717..5a56de2629 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -796,6 +796,15 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec, MessageId); 4] = [ + let targets: [(Option, MessageId); 5] = [ ( app.viewport.jump_to_latest_button_area, MessageId::KbJumpTopBottom, ), + ( + // The pinned prompt header jumps to the user message it names. + app.viewport.pinned_prompt_area, + MessageId::PinnedPromptJumpToMessage, + ), ( app.viewport.last_plugin_cta_review_area, MessageId::PluginCtaReview, @@ -2300,6 +2305,27 @@ mod tests { ); } + /// The pinned prompt header answers a click in `mouse_ui`; it must light + /// up under the pointer like every other clickable chrome. + #[test] + fn pinned_prompt_header_registers_a_hover_target() { + let _guard = crate::tui::hover_layer::HOVER_TEST_LOCK.lock().unwrap(); + crate::tui::hover_layer::begin_frame(); + let mut app = + crate::test_support::test_app_with_options(crate::test_support::test_tui_options(".")); + let header = ratatui::layout::Rect::new(4, 3, 40, 1); + app.viewport.pinned_prompt_area = Some(header); + + super::register_clickable_chrome_for_hover(&app); + + let registered = crate::tui::hover_layer::registered_targets(); + assert!( + registered.iter().any(|hit| hit.area == header), + "the pinned prompt header handles a click in mouse_ui and must \ + light up under the pointer; registered: {registered:?}" + ); + } + /// The composer's `[↵]` answered clicks and showed nothing under the /// pointer — the last of the clickable-but-dark controls. It lights up /// only when a click there would actually send. diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index f031a84473..0a6ce06a61 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4884,6 +4884,70 @@ fn jump_to_latest_button_click_scrolls_to_tail() { assert!(!app.viewport.transcript_selection.dragging); } +/// Clicking the pinned prompt header returns the viewport to the user +/// message the header names. +#[test] +fn pinned_prompt_click_jumps_to_the_message_it_names() { + let mut app = create_test_app(); + app.viewport.transcript_scroll = TranscriptScroll::at_line(0); + app.viewport.pinned_prompt_area = Some(Rect { + x: 4, + y: 3, + width: 40, + height: 1, + }); + app.viewport.pinned_prompt_line = Some(12); + + let events = handle_mouse_event( + &mut app, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 8, + row: 3, + modifiers: KeyModifiers::NONE, + }, + ); + + assert!(events.is_empty()); + assert_eq!( + app.viewport.transcript_scroll, + TranscriptScroll::at_line(12), + "the click must pin the viewport to the message's first line" + ); + assert_eq!(app.viewport.pending_scroll_delta, 0); + assert!(app.user_scrolled_during_stream); + assert!(!app.viewport.transcript_selection.dragging); +} + +/// A click outside the header's row must not be captured by its hit box. +#[test] +fn pinned_prompt_click_only_claims_the_header_row() { + let mut app = create_test_app(); + app.viewport.transcript_scroll = TranscriptScroll::to_bottom(); + app.viewport.pinned_prompt_area = Some(Rect { + x: 4, + y: 3, + width: 40, + height: 1, + }); + app.viewport.pinned_prompt_line = Some(12); + + handle_mouse_event( + &mut app, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 8, + row: 4, + modifiers: KeyModifiers::NONE, + }, + ); + + assert!( + app.viewport.transcript_scroll.is_at_tail(), + "a click below the header must not trigger the jump" + ); +} + /// Clicking the transcript scrollbar gutter starts a scrollbar drag (not /// text selection) so the visible thumb remains interactive for users who /// prefer mouse-based navigation. diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 93de3c275f..8e2ff123b3 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -272,6 +272,8 @@ impl ChatWidget { app.viewport.last_transcript_total = 0; app.viewport.last_transcript_padding_top = 0; app.viewport.jump_to_latest_button_area = None; + app.viewport.pinned_prompt_area = None; + app.viewport.pinned_prompt_line = None; return Self { content_area, transcript_area: content_area, @@ -645,6 +647,20 @@ impl ChatWidget { // (Underwater, Shoreline): the cell's text sat on the terminal's own // background, and CJK trailing columns left black remnants when the // cell scrolled (#6704). The footer's `Alt+V:details` hint names it. + // + // The pinned header is clickable: a click jumps the viewport to the + // user message it describes. Record the hit box and target line in + // the same frame that paints the header, so the coordinates the + // mouse handler tests are the coordinates the user saw. + app.viewport.pinned_prompt_area = (pinned_prompt.is_some() && app.use_mouse_capture) + .then_some(Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: 1, + }); + app.viewport.pinned_prompt_line = pinned_prompt.as_ref().map(|(_, line)| *line); + apply_selection(&mut lines, top, app); if let Some((pin, _)) = pinned_prompt { @@ -8330,6 +8346,53 @@ mod tests { ); } + /// The pinned header records its own hit box and jump target on the frame + /// that paints it, so a click can return to the message it names. + #[test] + fn pinned_prompt_records_its_click_target_on_the_header_row() { + let mut app = create_test_app(); + app.pin_last_prompt = true; + app.use_mouse_capture = true; + app.add_message(HistoryCell::User { + content: "keep this goal visible".into(), + }); + for index in 0..8 { + app.add_message(HistoryCell::Assistant { + content: format!("answer {index}"), + streaming: false, + }); + } + + let area = Rect::new(2, 5, 48, 5); + let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0); + + assert_eq!( + widget.transcript_area, + Rect::new(2, 6, 48, 4), + "the header takes the first content row" + ); + assert_eq!( + app.viewport.pinned_prompt_area, + Some(Rect { + x: 2, + y: 5, + width: 48, + height: 1, + }), + "the hit box must cover the painted header row" + ); + assert_eq!( + app.viewport.pinned_prompt_line, + Some(0), + "the header must jump to the user message's first rendered line" + ); + + // Without mouse capture the header stays decorative: no hit box. + app.use_mouse_capture = false; + let _ = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0); + assert!(app.viewport.pinned_prompt_area.is_none()); + } + #[test] fn fish_glyph_always_matches_screen_direction() { assert_eq!(fish_mark(true), "><>"); From 4ac93a1ff2d0be6d06ddb5b60d5dcc08f5300cad Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Sat, 3 Oct 2026 12:42:02 +0800 Subject: [PATCH 3/4] fix(tui): keep the prompt header from dropping on blank-led prompts 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> --- crates/tui/src/tui/widgets/mod.rs | 84 +++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 8e2ff123b3..b637649885 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -884,11 +884,18 @@ fn scrolled_user_prompt_pin( .get(*cell_index) .copied() .unwrap_or(*cell_index); - matches!( - history.get(original), - Some(HistoryCell::User { content }) if !content.trim().is_empty() - ) - .then_some(original) + // Only a prompt with renderable first-line text can head the pin. A + // message that opens on a blank line is skipped here, not rejected + // later, so the scan keeps walking to an older message that can head + // the header instead of dropping out (review follow-up). + let content = match history.get(original) { + Some(HistoryCell::User { content }) => content, + _ => return None, + }; + if content.lines().next().unwrap_or("").trim().is_empty() { + return None; + } + Some(original) }; // Newest user message whose start sits above the viewport's top row, // scanned newest-first so a long prompt that began several screens up @@ -8217,6 +8224,73 @@ mod tests { assert_eq!(line, 0); } + /// The scan keys off a message's *first* rendered line and the filtered→ + /// original cell mapping: a later line of a multi-line prompt must not + /// stand in for the message start, and filtered indices must resolve to + /// the original cell. + #[test] + fn pin_helper_uses_first_rendered_line_and_resolves_filtered_cells() { + let history = vec![ + HistoryCell::Assistant { + content: "collapsed away".into(), + streaming: false, + }, + HistoryCell::User { + content: "wrapped prompt line one\nline two".into(), + }, + HistoryCell::Assistant { + content: "c1".into(), + streaming: false, + }, + HistoryCell::Assistant { + content: "c2".into(), + streaming: false, + }, + ]; + // Original cell 0 is collapsed away, so the rendered (filtered) index + // 0 maps back to original 1 — the user message — across its two + // lines. + let meta = pin_meta(&[(0, 0), (0, 1), (1, 0), (2, 0)]); + let map = vec![1, 2, 3]; + let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 3, 40) + .expect("multi-line prompt pins at its first rendered line"); + assert!(pin_text(&pin).contains("wrapped prompt line one")); + assert!( + !pin_text(&pin).contains("line two"), + "the pin must show the first line, not a body line" + ); + assert_eq!(line, 0, "the jump target is the message's first line"); + } + + /// A prompt whose first line is blank cannot head the header, and it must + /// not suppress an older message that can: the header keeps handing over + /// instead of dropping out (SpikeBot 003 review follow-up). + #[test] + fn pin_helper_skips_blank_first_line_prompts_and_keeps_handing_over() { + let history = vec![ + HistoryCell::User { + content: "older prompt".into(), + }, + HistoryCell::Assistant { + content: "a1".into(), + streaming: false, + }, + HistoryCell::User { + content: "\nblank first line".into(), + }, + HistoryCell::Assistant { + content: "b1".into(), + streaming: false, + }, + ]; + let meta = pin_meta(&[(0, 0), (1, 0), (2, 0), (3, 0)]); + let map: Vec = (0..4).collect(); + let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 4, 40) + .expect("a blank-led prompt must not blank out the header"); + assert!(pin_text(&pin).contains("older prompt")); + assert_eq!(line, 0); + } + /// The header hands over the instant a newer prompt's first line reaches /// the viewport's top row: no window where it blinks out while the user /// scrolls across a turn boundary. From 134835a50f539d5000f8f9c7ce63d2a6314b359d Mon Sep 17 00:00:00 2001 From: Shizuku <2163018547@qq.com> Date: Sat, 3 Oct 2026 13:38:40 +0800 Subject: [PATCH 4/4] fix(tui): address review follow-ups on the pinned header click path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- crates/tui/src/tui/app.rs | 50 +++++++- crates/tui/src/tui/mouse_ui.rs | 6 +- crates/tui/src/tui/ui/frame.rs | 7 + crates/tui/src/tui/ui/tests.rs | 23 +++- crates/tui/src/tui/widgets/mod.rs | 205 ++++++++++++++++++++++++------ 5 files changed, 240 insertions(+), 51 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index e141ee4fc9..80190605b7 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1020,11 +1020,14 @@ pub struct ViewportState { pub jump_to_latest_button_area: Option, /// Painted rect of the pinned user-prompt header above the transcript, /// when one is shown and mouse capture is on. A left click there jumps - /// the viewport to `pinned_prompt_line`. + /// the viewport to the message named by `pinned_prompt_message`. pub pinned_prompt_area: Option, - /// Transcript line index of the user message the pinned header - /// describes; the click target for `pinned_prompt_area`. - pub pinned_prompt_line: Option, + /// Original history index of the user message the pinned header + /// describes; the click target for `pinned_prompt_area`. Stored as a + /// message identity, not a line offset, because line offsets are + /// frame-bound and a rewrite between paint and click would otherwise + /// land the jump on whatever now sits at the stale offset. + pub pinned_prompt_message: Option, /// Inner content rect of the composer (excluding border/padding), /// stored at render time for mouse coordinate mapping. pub last_composer_content: Option, @@ -1069,7 +1072,7 @@ impl Default for ViewportState { last_transcript_padding_top: 0, jump_to_latest_button_area: None, pinned_prompt_area: None, - pinned_prompt_line: None, + pinned_prompt_message: None, last_composer_content: None, last_composer_scroll_offset: 0, last_composer_top_padding: 0, @@ -6040,7 +6043,7 @@ impl App { self.viewport.last_transcript_padding_top = 0; self.viewport.jump_to_latest_button_area = None; self.viewport.pinned_prompt_area = None; - self.viewport.pinned_prompt_line = None; + self.viewport.pinned_prompt_message = None; self.needs_redraw = true; } @@ -6085,10 +6088,43 @@ impl App { pub fn scroll_to_transcript_line(&mut self, line: usize) { self.viewport.transcript_scroll = TranscriptScroll::at_line(line); self.viewport.pending_scroll_delta = 0; - self.user_scrolled_during_stream = true; + // `at_line` is never the tail sentinel, so this reads as `true` today; + // keep the same expression the scrollbar-jump path uses so the two + // stay in step if `at_line` ever clamps to tail on its own. + self.user_scrolled_during_stream = !self.viewport.transcript_scroll.is_at_tail(); self.needs_redraw = true; } + /// First rendered line of the user message named by the pinned prompt + /// header, resolved against the current transcript layout. + /// + /// The header records the message, not a line offset, and this resolves + /// that identity at click time — a rewrite between paint and click then + /// cannot land the jump on a stale offset. Returns `None` when the + /// message is no longer rendered (collapsed or filtered out), so a stale + /// click cannot teleport the viewport. + pub fn pinned_prompt_target_line(&self) -> Option { + let message = self.viewport.pinned_prompt_message?; + let map = &self.collapsed_cell_map; + self.viewport + .transcript_cache + .line_meta() + .iter() + .enumerate() + .find_map(|(line_index, meta)| { + let TranscriptLineMeta::CellLine { + cell_index, + line_in_cell: 0, + .. + } = meta + else { + return None; + }; + let original = map.get(*cell_index).copied().unwrap_or(*cell_index); + (original == message).then_some(line_index) + }) + } + pub fn queue_message(&mut self, message: QueuedMessage) { self.queued_messages.push_back(message); } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 5a56de2629..87b9d55725 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -797,9 +797,11 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec Option<( shell_ocean = chat_widget.ocean_column(); } app.viewport.pending_scroll_delta = parked_scroll_delta; + // The sampling constructor above records the pinned prompt header's + // hit box from the main session's transcript, but the focus pane + // never paints that header — its first row is the agent banner. + // Drop the stale box so the banner cannot answer a click meant for + // the (hidden) main transcript. + app.viewport.pinned_prompt_area = None; + app.viewport.pinned_prompt_message = None; crate::tui::agent_focus::refresh_focus(app); let buf = f.buffer_mut(); crate::tui::agent_focus::render_focus(app, chat_area, buf); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 0a6ce06a61..aa650575a1 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4885,18 +4885,29 @@ fn jump_to_latest_button_click_scrolls_to_tail() { } /// Clicking the pinned prompt header returns the viewport to the user -/// message the header names. +/// message the header names, resolved against the current layout. #[test] fn pinned_prompt_click_jumps_to_the_message_it_names() { let mut app = create_test_app(); - app.viewport.transcript_scroll = TranscriptScroll::at_line(0); + app.history = vec![HistoryCell::User { + content: "jump target".to_string(), + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 80, + app.transcript_render_options(), + ); + app.collapsed_cell_map = vec![0]; + app.viewport.transcript_scroll = TranscriptScroll::at_line(5); app.viewport.pinned_prompt_area = Some(Rect { x: 4, y: 3, width: 40, height: 1, }); - app.viewport.pinned_prompt_line = Some(12); + app.viewport.pinned_prompt_message = Some(0); let events = handle_mouse_event( &mut app, @@ -4911,8 +4922,8 @@ fn pinned_prompt_click_jumps_to_the_message_it_names() { assert!(events.is_empty()); assert_eq!( app.viewport.transcript_scroll, - TranscriptScroll::at_line(12), - "the click must pin the viewport to the message's first line" + TranscriptScroll::at_line(0), + "the click must pin the viewport to the resolved message's first line" ); assert_eq!(app.viewport.pending_scroll_delta, 0); assert!(app.user_scrolled_during_stream); @@ -4930,7 +4941,7 @@ fn pinned_prompt_click_only_claims_the_header_row() { width: 40, height: 1, }); - app.viewport.pinned_prompt_line = Some(12); + app.viewport.pinned_prompt_message = Some(12); handle_mouse_event( &mut app, diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index b637649885..d8d7bb1567 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -273,7 +273,7 @@ impl ChatWidget { app.viewport.last_transcript_padding_top = 0; app.viewport.jump_to_latest_button_area = None; app.viewport.pinned_prompt_area = None; - app.viewport.pinned_prompt_line = None; + app.viewport.pinned_prompt_message = None; return Self { content_area, transcript_area: content_area, @@ -536,7 +536,7 @@ impl ChatWidget { // only when the prompt has actually scrolled above it. Resolving once // more with the smaller body keeps the newest tail line visible. let mut transcript_area = content_area; - let pinned_prompt = (app.pin_last_prompt && content_area.height > 1) + let mut pinned_prompt = (app.pin_last_prompt && content_area.height > 1) .then(|| { scrolled_user_prompt_pin( &app.history, @@ -553,6 +553,19 @@ impl ChatWidget { let visible = usize::from(transcript_area.height); (total_lines, top, was_explicit_tail) = resolve_transcript_viewport_after_layout(&mut app.viewport, visible); + // Reserving the row moved `top` down by one on the tail, so + // re-resolve the header against the final viewport: a prompt + // whose first line was exactly the old top row must now head the + // header instead of being hidden behind it. The previous + // candidate's first line is still above the new `top`, so this + // always re-selects a message. + pinned_prompt = scrolled_user_prompt_pin( + &app.history, + app.viewport.transcript_cache.line_meta(), + &app.collapsed_cell_map, + top, + content_area.width, + ); visible } else { visible_lines @@ -659,7 +672,10 @@ impl ChatWidget { width: content_area.width, height: 1, }); - app.viewport.pinned_prompt_line = pinned_prompt.as_ref().map(|(_, line)| *line); + // Record the message, not a line offset: offsets are frame-bound, and + // a rewrite between paint and click would land the jump on whatever + // now sits on the stale offset. + app.viewport.pinned_prompt_message = pinned_prompt.as_ref().map(|(_, message)| *message); apply_selection(&mut lines, top, app); @@ -854,10 +870,12 @@ pub(crate) fn active_entry_revision(active_rev: u64, salt: u64) -> u64 { /// sits above `top`. The instant a newer prompt's first line reaches the top /// viewport row — scrolling up, or the tail sitting short — the header hands /// over to the previous turn's prompt, so it never blinks out while the user -/// scrolls across a turn boundary. The returned line index lets a click on -/// the header jump the viewport back to that message, and the caller owns -/// the one-row layout reservation so the header never masquerades as `top` -/// or displaces the newest tail line. +/// scrolls across a turn boundary. The returned message index lets a click +/// on the header jump the viewport back to that message (resolved against +/// the click frame's layout, so a rewrite between paint and click cannot +/// land the jump on a stale offset), and the caller owns the one-row layout +/// reservation so the header never masquerades as `top` or displaces the +/// newest tail line. fn scrolled_user_prompt_pin( history: &[HistoryCell], line_meta: &[TranscriptLineMeta], @@ -902,12 +920,7 @@ fn scrolled_user_prompt_pin( // still resolves to its own first line. A prompt whose first line is // exactly the top row belongs to the screen, not the header, so the // hand-over happens the instant it enters. - let (first_line, orig_idx) = line_meta - .iter() - .enumerate() - .take(top) - .rev() - .find_map(|(line_index, meta)| user_first_line(meta).map(|orig| (line_index, orig)))?; + let orig_idx = line_meta.iter().take(top).rev().find_map(user_first_line)?; let content = match history.get(orig_idx) { Some(HistoryCell::User { content }) => content, _ => return None, @@ -942,7 +955,7 @@ fn scrolled_user_prompt_pin( ), Span::styled(shown, Style::default().fg(palette::TEXT_PRIMARY)), ]), - first_line, + orig_idx, )) } @@ -8138,17 +8151,14 @@ mod tests { ]; let meta = pin_meta(&[(0, 0), (1, 0)]); let map = vec![0, 1]; - let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 1, 40) + let (pin, message) = super::scrolled_user_prompt_pin(&history, &meta, &map, 1, 40) .expect("scrolled user prompt should yield a pinned header"); let text = pin_text(&pin); assert!( text.contains("remember this prompt"), "expected pinned user text, got {text:?}" ); - assert_eq!( - line, 0, - "the pin must name the prompt's first rendered line" - ); + assert_eq!(message, 0, "the pin must name the user message it heads"); } #[test] @@ -8211,17 +8221,17 @@ mod tests { let map: Vec = (0..8).collect(); // Viewport over the newest replies: the newest prompt owns the top. - let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 5, 40) + let (pin, message) = super::scrolled_user_prompt_pin(&history, &meta, &map, 5, 40) .expect("newest prompt pins while its line is above the viewport"); assert!(pin_text(&pin).contains("second prompt")); - assert_eq!(line, 4); + assert_eq!(message, 4, "the second prompt is history cell 4"); // Viewport scrolled up past the newest prompt: the header re-pins to // the previous turn instead of disappearing. - let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 1, 40) + let (pin, message) = super::scrolled_user_prompt_pin(&history, &meta, &map, 1, 40) .expect("a turn above the viewport must keep a pinned header"); assert!(pin_text(&pin).contains("first prompt")); - assert_eq!(line, 0); + assert_eq!(message, 0); } /// The scan keys off a message's *first* rendered line and the filtered→ @@ -8252,14 +8262,17 @@ mod tests { // lines. let meta = pin_meta(&[(0, 0), (0, 1), (1, 0), (2, 0)]); let map = vec![1, 2, 3]; - let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 3, 40) + let (pin, message) = super::scrolled_user_prompt_pin(&history, &meta, &map, 3, 40) .expect("multi-line prompt pins at its first rendered line"); assert!(pin_text(&pin).contains("wrapped prompt line one")); assert!( !pin_text(&pin).contains("line two"), "the pin must show the first line, not a body line" ); - assert_eq!(line, 0, "the jump target is the message's first line"); + assert_eq!( + message, 1, + "the pin names the user message, resolved through the filtered map" + ); } /// A prompt whose first line is blank cannot head the header, and it must @@ -8285,10 +8298,10 @@ mod tests { ]; let meta = pin_meta(&[(0, 0), (1, 0), (2, 0), (3, 0)]); let map: Vec = (0..4).collect(); - let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 4, 40) + let (pin, message) = super::scrolled_user_prompt_pin(&history, &meta, &map, 4, 40) .expect("a blank-led prompt must not blank out the header"); assert!(pin_text(&pin).contains("older prompt")); - assert_eq!(line, 0); + assert_eq!(message, 0); } /// The header hands over the instant a newer prompt's first line reaches @@ -8320,25 +8333,26 @@ mod tests { let map: Vec = (0..5).collect(); // One row before the newest prompt reaches the screen: still pinned - // to the newest prompt (line 3 is above a viewport starting at 4). - let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 4, 40) + // to the newest prompt (its first line sits above a viewport starting + // at 4). + let (pin, message) = super::scrolled_user_prompt_pin(&history, &meta, &map, 4, 40) .expect("the newest prompt is still pinned one row above the viewport"); assert!(pin_text(&pin).contains("second prompt")); - assert_eq!(line, 3); + assert_eq!(message, 3); // The newest prompt's first line is now the top row itself: hand over // to the previous turn immediately, with no gap in between. - let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 3, 40) + let (pin, message) = super::scrolled_user_prompt_pin(&history, &meta, &map, 3, 40) .expect("the header must hand over instead of blinking out"); assert!(pin_text(&pin).contains("first prompt")); - assert_eq!(line, 0); + assert_eq!(message, 0); // Scrolling further keeps the previous turn pinned while its content // fills the top of the screen. - let (pin, line) = super::scrolled_user_prompt_pin(&history, &meta, &map, 2, 40) + let (pin, message) = super::scrolled_user_prompt_pin(&history, &meta, &map, 2, 40) .expect("the previous turn stays pinned"); assert!(pin_text(&pin).contains("first prompt")); - assert_eq!(line, 0); + assert_eq!(message, 0); } #[test] @@ -8456,9 +8470,9 @@ mod tests { "the hit box must cover the painted header row" ); assert_eq!( - app.viewport.pinned_prompt_line, + app.viewport.pinned_prompt_message, Some(0), - "the header must jump to the user message's first rendered line" + "the header must record the user message it names" ); // Without mouse capture the header stays decorative: no hit box. @@ -8467,6 +8481,125 @@ mod tests { assert!(app.viewport.pinned_prompt_area.is_none()); } + /// Reserving the header row must re-resolve against the final viewport: + /// when the newest prompt's first line is exactly the full-height top row + /// (a turn that fills the screen), the reserved viewport moves that line + /// above the body, and the header must name the newest prompt instead of + /// hiding it behind an older prompt's header. + #[test] + fn pin_helper_repins_against_the_reserved_viewport() { + let mut app = create_test_app(); + app.pin_last_prompt = true; + app.use_mouse_capture = true; + app.add_message(HistoryCell::User { + content: "older prompt".into(), + }); + app.add_message(HistoryCell::Assistant { + content: "older reply".into(), + streaming: false, + }); + app.add_message(HistoryCell::User { + content: "newest prompt".into(), + }); + app.add_message(HistoryCell::Assistant { + content: "newest reply".into(), + streaming: false, + }); + + // Learn the layout at a roomy height: total rendered lines and the + // newest prompt's first line. + let roomy = Rect::new(0, 0, 80, 40); + let _ = ChatWidget::new_with_ocean_elapsed(&mut app, roomy, 0); + let meta = app.viewport.transcript_cache.line_meta(); + let newest_message = app.history.len() - 2; + let newest_first = meta + .iter() + .position(|meta| { + matches!( + meta, + TranscriptLineMeta::CellLine { + cell_index, + line_in_cell: 0, + .. + } if *cell_index == newest_message + ) + }) + .expect("newest prompt rendered"); + let total = meta.len(); + assert!(total > newest_first, "the newest turn has body lines"); + let height = u16::try_from(total - newest_first).expect("fits"); + assert!(height > 1, "need at least one body row under the header"); + + // Re-render at exactly that height so the tail viewport starts on the + // newest prompt's first line. + let tight = Rect::new(0, 0, 80, height); + let widget = ChatWidget::new_with_ocean_elapsed(&mut app, tight, 0); + let pinned_text: String = widget.lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!( + pinned_text.contains("newest prompt"), + "the header must re-resolve for the reserved viewport, got {pinned_text:?}" + ); + } + + /// Render → click the header → the viewport lands on the named message: + /// the recorder and the mouse handler are exercised together, not each + /// half against a hand-written fixture. + #[test] + fn pinned_prompt_click_lands_on_the_named_message_end_to_end() { + let mut app = create_test_app(); + app.pin_last_prompt = true; + app.use_mouse_capture = true; + app.add_message(HistoryCell::User { + content: "target prompt".into(), + }); + for index in 0..8 { + app.add_message(HistoryCell::Assistant { + content: format!("answer {index}"), + streaming: false, + }); + } + app.add_message(HistoryCell::User { + content: "newest prompt".into(), + }); + for index in 0..8 { + app.add_message(HistoryCell::Assistant { + content: format!("latest {index}"), + streaming: false, + }); + } + + let area = Rect::new(0, 0, 60, 8); + let _ = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0); + let header = app + .viewport + .pinned_prompt_area + .expect("header painted above the scrolled viewport"); + let expected = app + .pinned_prompt_target_line() + .expect("the named message is rendered"); + + let events = crate::tui::mouse_ui::handle_mouse_event( + &mut app, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: header.x + 1, + row: header.y, + modifiers: KeyModifiers::NONE, + }, + ); + + assert!(events.is_empty()); + assert_eq!( + app.viewport.transcript_scroll, + TranscriptScroll::at_line(expected) + ); + assert_eq!(app.viewport.pending_scroll_delta, 0); + } + #[test] fn fish_glyph_always_matches_screen_direction() { assert_eq!(fish_mark(true), "><>");