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..80190605b7 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1018,6 +1018,16 @@ 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 the message named by `pinned_prompt_message`. + pub pinned_prompt_area: 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, @@ -1061,6 +1071,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_message: None, last_composer_content: None, last_composer_scroll_offset: 0, last_composer_top_padding: 0, @@ -6030,6 +6042,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_message = None; self.needs_redraw = true; } @@ -6066,6 +6080,51 @@ 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; + // `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 cd030c3717..87b9d55725 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -796,6 +796,17 @@ 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, @@ -1740,6 +1745,13 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> 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); @@ -2300,6 +2312,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..aa650575a1 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4884,6 +4884,81 @@ 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, resolved against the current layout. +#[test] +fn pinned_prompt_click_jumps_to_the_message_it_names() { + let mut app = create_test_app(); + 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_message = Some(0); + + 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(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); + 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_message = 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 664a4cd9b7..d8d7bb1567 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_message = None; return Self { content_area, transcript_area: content_area, @@ -534,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, @@ -551,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 @@ -645,9 +660,26 @@ 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, + }); + // 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); - if let Some(pin) = pinned_prompt { + if let Some((pin, _)) = pinned_prompt { lines.insert(0, pin); line_links.insert(0, Vec::new()); } @@ -830,50 +862,69 @@ 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 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], 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 + } = meta + else { + return None; + }; + let original = collapsed_cell_map + .get(*cell_index) + .copied() + .unwrap_or(*cell_index); + // 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; } - _ => false, - }); - let first_line = first_line?; - if first_line >= top { - 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 + // 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 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, + }; let first = content.lines().next().unwrap_or("").trim(); if first.is_empty() { @@ -894,15 +945,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)), + ]), + orig_idx, + )) } impl Renderable for ChatWidget { @@ -8064,6 +8118,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 +8149,16 @@ 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, message) = 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!(message, 0, "the pin must name the user message it heads"); } #[test] @@ -8104,16 +8166,195 @@ 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, 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!(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, 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!(message, 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, 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!( + 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 + /// 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, 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!(message, 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 (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!(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, 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!(message, 0); + + // Scrolling further keeps the previous turn pinned while its content + // fills the top of the screen. + 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!(message, 0); + } + #[test] fn pinned_prompt_reserves_header_without_hiding_tail_or_shifting_mouse_mapping() { let mut app = create_test_app(); @@ -8193,6 +8434,172 @@ 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_message, + Some(0), + "the header must record the user message it names" + ); + + // 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()); + } + + /// 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), "><>");