diff --git a/src/app.rs b/src/app.rs index 2a4415f..0c16870 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3067,12 +3067,34 @@ impl App { } fn current_chat_area(&self) -> Rect { - self.chat_area_for_size(self.last_frame_size) + // Prefer the last-rendered chat content rect (excludes compact chrome). + self.chat_state + .last_chat_area + .unwrap_or_else(|| self.chat_area_for_size(self.last_frame_size)) + } + + /// Region where a mouse wheel scrolls the chat. In compact mode this + /// extends above the chat content to include the 3-row header (and the + /// sticky overlay which sits inside the transcript top), so scrolling + /// works even when the pointer is over that chrome. + fn chat_scroll_region(&self) -> Rect { + let chat_area = self.current_chat_area(); + if !self.chat_state.compact_mode { + return chat_area; + } + // Sticky is an overlay inside chat_area; only the header sits above it. + let top = chat_area.y.saturating_sub(3); // header rows + Rect { + x: chat_area.x, + y: top, + width: chat_area.width, + height: chat_area.bottom().saturating_sub(top), + } } pub fn handle_coalesced_mouse_scroll(&mut self, mouse: MouseEvent, notches: usize) { if self.overlay_focus == OverlayFocus::None && self.base_focus == BaseFocus::Chat { - let chat_area = self.current_chat_area(); + let chat_area = self.chat_scroll_region(); if chat_area.contains(Position::new(mouse.column, mouse.row)) && self .chat_state @@ -4949,6 +4971,24 @@ impl App { if self.base_focus == BaseFocus::Chat { let chat_area = self.current_chat_area(); + // Compact-mode sticky user message: click to scroll to that message. + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && mouse.modifiers.is_empty() + { + if let Some((sticky_rect, msg_idx)) = self.chat_state.sticky_click_target { + if sticky_rect.contains(Position::new(mouse.column, mouse.row)) { + self.chat_state.chat.scroll_to_message_index(msg_idx); + // Clear sticky state so the scrolled-to message re-enters + // the viewport cleanly without residual sticky chrome. + self.chat_state.sticky_message_index = None; + self.chat_state.chat.faded_message_index = None; + self.chat_state.sticky_click_target = None; + self.pending_chat_message_click = None; + return; + } + } + } + match mouse.kind { MouseEventKind::Moved if !self.chat_state.chat.has_selection() @@ -6201,6 +6241,19 @@ impl App { } return; } + if parsed.name == "compact-mode" && self.base_focus == BaseFocus::Chat { + self.chat_state.compact_mode = !self.chat_state.compact_mode; + push_toast(Toast::new( + if self.chat_state.compact_mode { + "Compact mode enabled" + } else { + "Compact mode disabled" + }, + ToastLevel::Info, + Some(std::time::Duration::from_secs(2)), + )); + return; + } if self.command_matches(&parsed.name, "fork") && self.base_focus == BaseFocus::Chat { self.handle_fork_command(&parsed.args); @@ -6430,6 +6483,19 @@ impl App { } return; } + if parsed.name == "compact-mode" && self.base_focus == BaseFocus::Chat { + self.chat_state.compact_mode = !self.chat_state.compact_mode; + push_toast(Toast::new( + if self.chat_state.compact_mode { + "Compact mode enabled" + } else { + "Compact mode disabled" + }, + ToastLevel::Info, + Some(std::time::Duration::from_secs(2)), + )); + return; + } if self.command_matches(&parsed.name, "fork") && self.base_focus == BaseFocus::Chat { self.handle_fork_command(&parsed.args); return; @@ -8288,28 +8354,50 @@ impl App { { Ok(()) => { let is_active = self.is_active_session(&session_id); - // Marker is appended last — pin to bottom so the - // "Context compacted" line is visible without jump. - let mut chat = self.chat_with_messages(messages.clone()); - chat.scroll_to_bottom_on_next_render(); - if let Some(marker_idx) = messages + // Marker is last in soft layout — pin to bottom so the + // "Context compacted" line is visible without mid-history jump. + // Prefer replace_messages on the live chat: rebuilding via + // chat_with_messages zeros content_height and can desync + // sticky/live scroll state until the next session load. + let marker_idx = messages .iter() - .rposition(|m| crate::session::compaction::is_compaction_marker(m)) - { - chat.set_highlighted_message(Some(marker_idx)); - } else { - chat.clear_highlighted_message(); - } - + .rposition(|m| crate::session::compaction::is_compaction_marker(m)); if is_active { - self.chat_state.chat = chat.clone(); + self.chat_state.chat.replace_messages(messages.clone()); + self.chat_state.chat.scroll_to_bottom_on_next_render(); + if let Some(marker_idx) = marker_idx { + self.chat_state + .chat + .set_highlighted_message(Some(marker_idx)); + } else { + self.chat_state.chat.clear_highlighted_message(); + } } - // Always keep view-state in sync so reopen/switch - // shows the same compacted history + marker. self.ensure_session_view_state(&session_id); + // Build parked chat before mutably borrowing session_view_states + // (chat_with_messages needs &self). + let parked_chat = if !is_active { + let mut view_chat = self.chat_with_messages(messages); + view_chat.scroll_to_bottom_on_next_render(); + if let Some(marker_idx) = marker_idx { + view_chat.set_highlighted_message(Some(marker_idx)); + } else { + view_chat.clear_highlighted_message(); + } + Some(view_chat) + } else { + None + }; if let Some(state) = self.session_view_states.get_mut(&session_id) { - state.chat = chat; + // Keep the active session's live chat out of + // session_view_states (same invariant as + // load_session_view_state / switch_to_session). + // Never park an empty new_chat() here — that would + // wipe the marker on the next session restore. + if let Some(view_chat) = parked_chat { + state.chat = view_chat; + } state.tool_calls = ToolCallViewState::default(); state.unread_completed = !is_active; } @@ -10645,6 +10733,9 @@ impl App { subagent_tabs, &queued_messages, &mut self.find_bar, + self.session_manager + .get_current_session() + .map(|s| s.title.as_str()), ); if is_suggestions_visible(&self.suggestions_popup_state) diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 98b304b..95f4e1a 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -620,6 +620,22 @@ pub fn handle_compact<'a>( }) } +pub fn handle_compact_mode<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error("Usage: /compact-mode".to_string()); + } + + // The app intercepts /compact-mode to toggle the chat_state.compact_mode flag. + CommandResult::Success(String::new()) + }) +} + pub fn handle_fork<'a>( parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, @@ -978,6 +994,14 @@ pub fn register_all_commands(registry: &mut Registry) { chat_only: true, }); + registry.register(Command { + name: "compact-mode".to_string(), + description: "Toggle compact mode (sticky header + latest user message)".to_string(), + handler: handle_compact_mode, + hidden_tokens: vec![], + chat_only: true, + }); + registry.register(Command { name: "fork".to_string(), description: "Fork the current session".to_string(), diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 0996363..baa2b63 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -312,6 +312,8 @@ pub struct Chat { pending_click_anchor: Option<(usize, usize)>, /// Index of the message highlighted by timeline navigation (None = no highlight) pub highlighted_message_index: Option, + /// Index of the message whose viewport copy should be faded (sticky message). + pub faded_message_index: Option, /// Deferred scroll-to-message index resolved during next render after positions are known. pending_scroll_to_message: Option, /// Match ranges for the active rendered-line chat find query. @@ -1625,6 +1627,8 @@ impl Chat { selection_edge_scroll: None, pending_click_anchor: None, highlighted_message_index: None, + faded_message_index: None, + pending_scroll_to_message: None, search_matches: Vec::new(), search_active_match: None, search_query: String::new(), @@ -1644,7 +1648,6 @@ impl Chat { cached_has_active_tools: std::cell::Cell::new(false), hovered_image: None, hovered_hyperlink: None, - pending_scroll_to_message: None, } } @@ -1692,6 +1695,8 @@ impl Chat { selection_edge_scroll: None, pending_click_anchor: None, highlighted_message_index: None, + faded_message_index: None, + pending_scroll_to_message: None, search_matches: Vec::new(), search_active_match: None, search_query: String::new(), @@ -1711,7 +1716,6 @@ impl Chat { cached_has_active_tools: std::cell::Cell::new(false), hovered_image: None, hovered_hyperlink: None, - pending_scroll_to_message: None, } } @@ -2678,6 +2682,37 @@ impl Chat { .saturating_add(self.scroll_bottom_padding) } + /// Re-paint the vertical scrollbar over `area` (rightmost column). + /// Used by overlays (e.g. compact sticky) that would otherwise cover the thumb. + pub fn render_scrollbar_over( + &self, + f: &mut Frame, + area: Rect, + track_color: Color, + thumb_color: Color, + ) { + if area.width == 0 || area.height == 0 { + return; + } + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y, + width: 1, + height: area.height, + }; + render_scrollbar( + f, + ScrollMetrics::new( + self.scroll_content_height(), + self.viewport_height, + self.scroll_offset, + ), + scrollbar_area, + track_color, + thumb_color, + ); + } + pub fn set_search_query( &mut self, query: &str, @@ -2766,6 +2801,10 @@ impl Chat { /// immediately after bulk-replacing the message list (e.g. compaction). pub fn scroll_to_message_on_next_render(&mut self, idx: usize) { self.pending_scroll_to_message = Some(idx); + // Prevent pin-to-bottom / autoscroll from overriding the marker jump + // on the next frame (replace_messages re-enables autoscroll). + self.autoscroll_enabled = false; + self.user_scrolled_up = true; } pub fn set_highlighted_message(&mut self, idx: Option) { @@ -3610,6 +3649,8 @@ impl Chat { // Resolve any deferred scroll-to-message request (e.g. after compaction). // Keep the pending request if positions are not ready yet (viewport=0). + // Must win over pin-to-bottom when applied. + let mut forced_message_scroll = false; if let Some(target_idx) = self.pending_scroll_to_message { if viewport > 0 { if let Some(&line) = positions.get(target_idx) { @@ -3628,7 +3669,9 @@ impl Chat { // Stick-to-bottom only runs when user_scrolled_up is false; // keep this true so the offset is not immediately overwritten. self.user_scrolled_up = true; + self.autoscroll_enabled = false; self.pending_scroll_to_message = None; + forced_message_scroll = true; } } } @@ -3636,8 +3679,9 @@ impl Chat { let max_offset = content_height .saturating_add(self.scroll_bottom_padding) .saturating_sub(viewport); - let was_pinned_to_bottom = self.scroll_offset == usize::MAX - || (self.scroll_offset >= self.max_scroll_offset() && !self.user_scrolled_up); + let was_pinned_to_bottom = !forced_message_scroll + && (self.scroll_offset == usize::MAX + || (self.scroll_offset >= self.max_scroll_offset() && !self.user_scrolled_up)); let clamped_scroll = if was_pinned_to_bottom { max_offset } else { @@ -3681,6 +3725,32 @@ impl Chat { colors, ); + // Fade the sticky message's viewport copy so it becomes invisible while + // still occupying its rows (no text, no background). + if let Some(faded_idx) = self.faded_message_index { + if let Some(msg_start) = self.message_line_positions.get(faded_idx).copied() { + let msg_end = self + .message_line_positions + .iter() + .skip(faded_idx + 1) + .next() + .copied() + .unwrap_or(content_height); + let fade_start = msg_start.max(visible_start); + let fade_end = msg_end.min(visible_end); + if fade_start < fade_end { + let invisible = + Line::from(vec![Span::styled(" ".repeat(max_width), Style::default())]); + for line_idx in fade_start..fade_end { + let local_idx = line_idx - visible_start; + if let Some(line) = content_lines.get_mut(local_idx) { + *line = invisible.clone(); + } + } + } + } + } + let render_area = Rect { x: content_area.x, y: content_area.y, @@ -4822,6 +4892,61 @@ impl Chat { (lines, locations) } + /// Format a user message's content into wrapped, styled lines, mirroring + /// `format_message`'s user branch exactly (image-placeholder colors, + /// `@agent` mention colors, wrap width, horizontal padding). Returns + /// content lines only — no border/padding rows. Used by the compact-mode + /// sticky message so it renders like a real user message. + pub fn format_user_message_content_lines( + &self, + idx: usize, + max_width: usize, + colors: &ThemeColors, + ) -> Vec> { + let Some(message) = self.messages.get(idx) else { + return Vec::new(); + }; + if message.role != MessageRole::User { + return Vec::new(); + } + + let max_width = max_width.max(1); + let bg = colors.background_element; + let text_style = Style::default().fg(colors.text).bg(bg); + let image_style = |placeholder: &str| { + let is_hovered = self.hovered_image.as_ref().is_some_and(|target| { + target.message_index == idx && target.placeholder == placeholder + }); + if is_hovered { + Style::default().fg(colors.markdown_image_text).bg(bg) + } else { + Style::default().fg(colors.markdown_image).bg(bg) + } + }; + + let horizontal_padding = 2usize; + let right_padding = 2usize; + let wrap_width = max_width + .saturating_sub(1 + horizontal_padding + right_padding) + .max(1); + + message + .content + .split('\n') + .flat_map(|content_line| { + let content_line = content_line.strip_suffix('\r').unwrap_or(content_line); + let styled_content = Line::from(style_agent_mentions_in_line( + content_line, + &self.agent_mention_names, + colors, + text_style, + &image_style, + )); + wrap_styled_line(&styled_content, WrapOptions::new(wrap_width)) + }) + .collect::>() + } + fn format_tool_row<'a>( &'a self, message: &'a Message, diff --git a/src/views/chat.rs b/src/views/chat.rs index 87f8735..d3d8670 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -4,17 +4,19 @@ use ratatui::{ style::{Color, Modifier, Style}, symbols::border, text::{Line, Span, Text}, - widgets::{Block, Borders, Paragraph, Widget}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, Frame, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use crate::session::types::MessageRole; use crate::theme::ThemeColors; use crate::ui::components::chat::Chat; use crate::ui::components::find::FindBar; use crate::ui::components::input::Input; use crate::ui::components::status_bar::StatusBar; use crate::ui::components::wave_spinner::WaveSpinner; +use crate::ui::selection::non_selectable_style; pub const SUBAGENT_FOOTER_HEIGHT: u16 = 3; const QUEUED_MESSAGES_MAX_VISIBLE: usize = 3; @@ -72,6 +74,14 @@ pub fn render_subagent_spinner_only( pub struct ChatState { pub chat: Chat, pub wave_spinner: WaveSpinner, + pub compact_mode: bool, + /// Index of the most recent user message that has scrolled past the top + /// of the viewport, shown as a sticky message in compact mode. + pub sticky_message_index: Option, + /// Last-rendered chat content rect (excludes compact chrome). Used for mouse hit-testing. + pub last_chat_area: Option, + /// Clickable sticky user-message bar from the last render: (rect, message_index). + pub sticky_click_target: Option<(Rect, usize)>, } #[derive(Debug, Clone)] @@ -97,6 +107,10 @@ impl ChatState { Self { chat, wave_spinner: WaveSpinner::with_speed(agent_color, 40), + compact_mode: true, + sticky_message_index: None, + last_chat_area: None, + sticky_click_target: None, } } } @@ -140,6 +154,7 @@ pub fn render_chat( subagent_tabs: Option, queued_messages: &[String], find_bar: &mut FindBar, + session_title: Option<&str>, ) { let size = f.area(); let is_subagent_view = subagent_tabs @@ -178,9 +193,230 @@ pub fn render_chat( ) .split(main_chunks[0]); - chat_state - .chat - .render(f, above_status_chunks[1], &agent, &model, colors); + // Compact mode: sticky header (session title) + sticky scrolled-past user message. + // + // Layout: the sticky bar is an *overlay* painted on top of the transcript, + // not a layout row. Showing/hiding sticky must not change transcript + // viewport height or scroll extent (header is always 3 rows; chat fills the + // rest of the content area). + // + // Sticky visibility is driven by transcript line offsets vs scroll_offset. + // Eligibility: a prior user once its body has left the top (`body_end <= S`). + // Hide: when the next user message enters the sticky overlay's covered top + // region (`S + sticky_height > next_user_start`). Overlay height defines that + // visual coverage only — it never shrinks chat_area / scroll extent. + // Assistant/tool blocks between users do not suppress sticky. + let (chat_area, sticky_overlay) = if chat_state.compact_mode { + // Fixed layout first so chat_area is independent of sticky overlay height. + let (header_area, chat_area) = compact_transcript_layout(above_status_chunks[1]); + + let scroll_offset = chat_state.chat.scroll_offset; + // One start line per transcript message / rendered block (groups share a start). + let rendered_message_starts = &chat_state.chat.message_line_positions; + let content_height = chat_state.chat.content_height; + + let msg_end_line = |idx: usize| -> usize { + (idx + 1..rendered_message_starts.len()) + .find_map(|i| rendered_message_starts.get(i).copied()) + .unwrap_or(content_height) + }; + + // (message_index, start_line, body_end) for every non-compaction user message. + // body_end excludes the trailing inter-message blank so sticky appears as + // soon as the real message body has fully left the viewport top. + let user_messages: Vec<(usize, usize, usize)> = chat_state + .chat + .messages + .iter() + .enumerate() + .filter(|(_, m)| { + m.role == MessageRole::User + && !crate::session::compaction::is_compaction_display_item(m) + }) + .filter_map(|(i, _)| { + rendered_message_starts.get(i).map(|&start| { + let end = msg_end_line(i); + (i, start, user_message_body_end(end)) + }) + }) + .collect(); + + let display_sticky = resolve_sticky_display( + &user_messages, + scroll_offset, + chat_state.sticky_message_index, + ); + + // Update memory: remember last displayed sticky; clear only when scrolled + // above the first user message (nothing left to be sticky about). + if let Some(idx) = display_sticky { + chat_state.sticky_message_index = Some(idx); + } else { + let first_start = user_messages.first().map(|(_, s, _)| *s).unwrap_or(0); + if scroll_offset <= first_start { + chat_state.sticky_message_index = None; + } + // else keep memory for hysteresis while in dead/transition zones + } + + // Only fade a message that is fully above the viewport. If it's still + // partially visible we never set display_sticky, so this stays None and + // sticky/viewport never intersect. + chat_state.chat.faded_message_index = display_sticky; + + let sticky_height: u16 = if let Some(idx) = display_sticky { + let msg_start = rendered_message_starts.get(idx).copied().unwrap_or(0); + let msg_end = msg_end_line(idx); + sticky_overlay_height_for_span(msg_start, user_message_body_end(msg_end)) as u16 + } else { + 0 + }; + + // Render compact header with session title. No background fill; the + // title sits on the middle row in accent + bold. Top/bottom rows are + // truly empty (no bg). + if let Some(title) = session_title { + let header_inner = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ] + .as_ref(), + ) + .split(header_area); + // Title line (accent + bold, no background) + f.render_widget( + Paragraph::new(title).style( + Style::default() + .fg(colors.accent) + .add_modifier(Modifier::BOLD), + ), + header_inner[1], + ); + } + + chat_state.last_chat_area = Some(chat_area); + // Clear previous sticky target; set only when an overlay bar is drawn. + chat_state.sticky_click_target = None; + + let sticky_overlay = display_sticky + .and_then(|idx| sticky_overlay_rect(chat_area, sticky_height).map(|rect| (rect, idx))); + + (chat_area, sticky_overlay) + } else { + // Leaving compact mode: clear sticky state so re-enabling starts clean. + chat_state.sticky_message_index = None; + chat_state.chat.faded_message_index = None; + chat_state.sticky_click_target = None; + chat_state.last_chat_area = Some(above_status_chunks[1]); + (above_status_chunks[1], None) + }; + + // Transcript first so the sticky overlay (if any) paints on top of it. + chat_state.chat.render(f, chat_area, &agent, &model, colors); + + // Paint sticky as an overlay over the top of the transcript. This keeps + // transcript viewport height / scroll extent independent of sticky state. + if let Some((sticky_rect, idx)) = sticky_overlay { + chat_state.sticky_click_target = Some((sticky_rect, idx)); + + let max_width = sticky_rect.width as usize; + let sticky_height = sticky_rect.height; + let sticky_msg = chat_state.chat.messages.get(idx); + + let border_color = crate::theme::agent_mode_color( + sticky_msg.and_then(|m| m.agent_mode.as_deref()), + colors, + ); + let bg = colors.background_element; + let border_style = non_selectable_style(Style::default().fg(border_color)); + let pad_style = non_selectable_style(Style::default().bg(bg)); + // ▴ affordance: weak text so it reads as a clickable cue, not content. + let arrow_style = non_selectable_style(Style::default().fg(colors.text_weak).bg(bg)); + + let horizontal_padding = 2usize; + + let padding_line = || { + let mut line = Line::from(vec![ + Span::styled("▌", border_style), + Span::styled(" ".repeat(max_width.saturating_sub(1)), pad_style), + ]); + line.style = Style::default().bg(bg); + line + }; + + // Bottom padding with a horizontally-centered ▴ click affordance. + let bottom_padding_line = || { + // Layout: "▌" + spaces + "▴" + spaces, total width = max_width. + let body_width = max_width.saturating_sub(1); // after border + let arrow = "▴"; + let arrow_w = 1usize; + let left = body_width.saturating_sub(arrow_w) / 2; + let right = body_width.saturating_sub(left + arrow_w); + let mut line = Line::from(vec![ + Span::styled("▌", border_style), + Span::styled(" ".repeat(left), pad_style), + Span::styled(arrow, arrow_style), + Span::styled(" ".repeat(right), pad_style), + ]); + line.style = Style::default().bg(bg); + line + }; + + // Number of content rows = sticky height minus top/bottom padding. + let content_rows = sticky_height.saturating_sub(2) as usize; + let mut sticky_lines: Vec = Vec::with_capacity(sticky_height as usize); + sticky_lines.push(padding_line()); + + // Content rows: mirror real user-message rendering (image + // placeholders styled, text wrapped), limited to content_rows. + let content_lines = chat_state + .chat + .format_user_message_content_lines(idx, max_width, colors); + let mut content_iter = content_lines.into_iter(); + for _ in 0..content_rows { + if let Some(content_line) = content_iter.next() { + let line_width = content_line.width(); + let trailing_padding = + " ".repeat(max_width.saturating_sub(1 + horizontal_padding + line_width)); + let mut spans = Vec::with_capacity(content_line.spans.len() + 3); + spans.push(Span::styled("▌", border_style)); + spans.push(Span::styled(" ".repeat(horizontal_padding), pad_style)); + spans.extend(content_line.spans); + spans.push(Span::styled(trailing_padding, pad_style)); + let mut panel_line = Line::from(spans); + panel_line.style = Style::default().bg(bg); + sticky_lines.push(panel_line); + } else { + // Message has fewer lines than the sticky can show. + sticky_lines.push(padding_line()); + } + } + + sticky_lines.push(bottom_padding_line()); + + // Paragraph patches styles onto existing cells and only rewrites + // grapheme-covered cells. Clear first so bold/fg/bg from the + // underlying transcript cannot leak into the sticky rectangle. + paint_sticky_overlay( + f.buffer_mut(), + sticky_rect, + sticky_lines, + colors.background_element, + ); + // Chat paints its scrollbar before this overlay. Re-paint so the thumb + // stays above the sticky bar. Overlay geometry / click target are + // unchanged — only paint order is adjusted. + chat_state.chat.render_scrollbar_over( + f, + chat_area, + colors.background_element, + colors.text_weak, + ); + } if is_subagent_view { if let Some(tabs) = subagent_tabs.as_ref() { @@ -316,6 +552,169 @@ pub fn render_chat( } } +/// Fixed compact-mode layout: 3-row header + full remaining height for the +/// transcript. Sticky is an overlay and does not participate in this split. +fn compact_transcript_layout(area: Rect) -> (Rect, Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + (chunks[0], chunks[1]) +} + +/// Sticky overlay rect at the top of the transcript area, clamped so it never +/// exceeds the transcript height. +fn sticky_overlay_rect(chat_area: Rect, sticky_height: u16) -> Option { + if sticky_height == 0 || chat_area.height == 0 || chat_area.width == 0 { + return None; + } + let height = sticky_height.min(chat_area.height); + Some(Rect { + x: chat_area.x, + y: chat_area.y, + width: chat_area.width, + height, + }) +} + +/// Extra clearance (in rows) required when scrolling up before the previous +/// sticky is allowed to replace the remembered one. +const STICKY_UP_HYSTERESIS: usize = 5; + +/// User messages are laid out as: top pad + content + bottom pad + trailing blank. +/// The trailing blank is inter-message spacing, not part of the message body. +/// Sticky must appear as soon as the body has fully left the viewport top — +/// one scroll row earlier than treating `msg_end` (which includes the blank). +fn user_message_body_end(msg_end_including_trailing_blank: usize) -> usize { + msg_end_including_trailing_blank.saturating_sub(1) +} + +/// Sticky overlay row count for a user message whose body occupies +/// `[msg_start, body_end)` in the transcript. +/// +/// User messages render as top pad + content + bottom pad (+ trailing blank +/// excluded from body_end). Overlay height clamps to 3..=5 rows and is used +/// only for the visual covered region / hide boundary — never for scroll extent. +fn sticky_overlay_height_for_span(msg_start: usize, body_end: usize) -> usize { + let msg_body_lines = body_end.saturating_sub(msg_start); + msg_body_lines.min(5).max(3) +} + +/// Start line of the next user message after `message_index`, if any. +/// +/// The sticky overlay must not cover the next *user* message in the viewport. +/// Intermediate assistant/tool blocks do not suppress sticky — otherwise a +/// normal user→assistant transcript would hide sticky as soon as the prior +/// user's body leaves the top. +fn next_user_start_after( + user_messages: &[(usize, usize, usize)], + message_index: usize, +) -> Option { + user_messages + .iter() + .find(|(idx, _, _)| *idx > message_index) + .map(|(_, start, _)| *start) +} + +/// Natural sticky candidate while scrolling down. +/// +/// `user_messages` entries are `(message_index, start_line, body_end)` sorted in +/// transcript order. +/// +/// Show: last user message whose body is fully above the viewport (`body_end <= S`). +/// Hide: when the next user message's first row enters the sticky overlay's +/// half-open top coverage `[S, S + sticky_height)` — i.e. +/// `S + sticky_height > next_user_start` (still visible when equal). +/// Sticky height defines that covered region only; it does not change scroll +/// extent. Intermediate assistant/tool rows do not hide sticky. +fn natural_sticky_index( + user_messages: &[(usize, usize, usize)], + scroll_offset: usize, +) -> Option { + let prev = user_messages + .iter() + .rev() + .find(|(_, _, body_end)| *body_end <= scroll_offset) + .copied(); + match prev { + Some((idx, start, body_end)) => { + let sticky_height = sticky_overlay_height_for_span(start, body_end); + let next_start = next_user_start_after(user_messages, idx); + match next_start { + // Next user message's first row is inside the sticky-covered top + // region; hide immediately. Equal bottom edge keeps sticky visible. + Some(ns) if scroll_offset.saturating_add(sticky_height) > ns => None, + _ => Some(idx), + } + } + None => None, + } +} + +/// Resolve which sticky (if any) to display, applying scroll-up hysteresis via +/// the remembered sticky index. +/// +/// `user_messages` entries are `(message_index, start_line, body_end)`. +/// When natural selection is `None`, memory is never resurrected. +fn resolve_sticky_display( + user_messages: &[(usize, usize, usize)], + scroll_offset: usize, + memory: Option, +) -> Option { + let natural = natural_sticky_index(user_messages, scroll_offset); + + match (memory, natural) { + // No memory yet — follow natural. + (None, nat) => nat, + + // Natural is None — dead zone, next user under sticky, or body still visible. + // Never re-show / resurrect memory once natural has cleared. + (Some(_memory), None) => None, + + // Natural caught up to or passed memory (scroll down / same) — follow natural. + (Some(mem), Some(nat)) if nat >= mem => Some(nat), + + // Natural wants an older message (scroll up) — require clearance above `memory`. + (Some(mem), Some(nat)) => { + let memory_entry = user_messages.iter().find(|(i, _, _)| *i == mem); + let (memory_start, memory_body_end) = match memory_entry { + Some((_, start, body_end)) => (*start, *body_end), + None => return Some(nat), + }; + // Clearance uses the same one-row hide offset as the natural hide + // boundary (+1) so directional hysteresis stays consistent. + if scroll_offset + .saturating_add(1) + .saturating_add(STICKY_UP_HYSTERESIS) + <= memory_start + { + // Enough space above the remembered message → show older sticky. + Some(nat) + } else if memory_body_end <= scroll_offset { + // Memory is still fully above viewport → keep it sticky. + Some(mem) + } else { + // Memory has re-entered the viewport — no sticky. + None + } + } + } +} + +/// Clear the sticky rectangle, then paint the sticky Paragraph so styles from +/// the underlying transcript cannot leak into unwritten sticky cells. +fn paint_sticky_overlay( + buf: &mut Buffer, + sticky_area: Rect, + sticky_lines: Vec>, + bg: Color, +) { + Clear.render(sticky_area, buf); + Paragraph::new(sticky_lines) + .style(Style::default().bg(bg)) + .render(sticky_area, buf); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ChatStatusLayoutWidths { streaming: u16, @@ -949,13 +1348,23 @@ fn centered_subagent_footer_content(area: Rect) -> Rect { #[cfg(test)] mod tests { use super::{ - chat_status_layout_widths, display_agent_name, render_subagent_spinner_only, + chat_status_layout_widths, compact_transcript_layout, display_agent_name, + natural_sticky_index, paint_sticky_overlay, render_chat, render_subagent_spinner_only, + resolve_sticky_display, sticky_overlay_height_for_span, sticky_overlay_rect, streaming_status_spans, subagent_nav_width, subagent_streaming_status_spans, - ChatStatusLayoutWidths, STREAMING_STATUS_COMPACT_BREAKPOINT_WIDTH, + user_message_body_end, ChatState, ChatStatusLayoutWidths, STICKY_UP_HYSTERESIS, + STREAMING_STATUS_COMPACT_BREAKPOINT_WIDTH, }; use crate::theme::ThemeColors; - use crate::ui::components::{chat::Chat, wave_spinner::WaveSpinner}; - use ratatui::{buffer::Buffer, layout::Rect, style::Color}; + use crate::ui::components::{ + chat::Chat, find::FindBar, input::Input, wave_spinner::WaveSpinner, + }; + use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + }; fn test_colors() -> ThemeColors { ThemeColors { @@ -1211,4 +1620,583 @@ mod tests { assert_eq!(subagent_nav_width(20, true, 24), 12); assert_eq!(subagent_nav_width(20, false, 24), 20); } + + #[test] + fn compact_transcript_layout_keeps_header_and_full_chat_height() { + let area = Rect::new(0, 0, 80, 30); + let (header, chat) = compact_transcript_layout(area); + assert_eq!(header, Rect::new(0, 0, 80, 3)); + assert_eq!(chat, Rect::new(0, 3, 80, 27)); + // Sticky is not a layout row: chat fills everything below the header. + assert_eq!(header.height + chat.height, area.height); + } + + #[test] + fn sticky_overlay_rect_sits_on_top_of_transcript_without_shrinking_it() { + let chat_area = Rect::new(0, 3, 80, 27); + let sticky = sticky_overlay_rect(chat_area, 5).expect("sticky overlay"); + assert_eq!(sticky, Rect::new(0, 3, 80, 5)); + // Overlay occupies the top of the transcript; chat area itself is unchanged. + assert_eq!(sticky.x, chat_area.x); + assert_eq!(sticky.y, chat_area.y); + assert_eq!(sticky.width, chat_area.width); + assert!(sticky.height < chat_area.height); + } + + #[test] + fn sticky_overlay_rect_is_none_when_height_or_area_is_zero() { + let chat_area = Rect::new(0, 3, 80, 27); + assert!(sticky_overlay_rect(chat_area, 0).is_none()); + assert!(sticky_overlay_rect(Rect::new(0, 0, 0, 10), 3).is_none()); + assert!(sticky_overlay_rect(Rect::new(0, 0, 10, 0), 3).is_none()); + } + + #[test] + fn sticky_overlay_rect_clamps_to_transcript_height() { + let chat_area = Rect::new(0, 3, 80, 2); + let sticky = sticky_overlay_rect(chat_area, 5).expect("clamped sticky"); + assert_eq!(sticky.height, 2); + assert_eq!(sticky.y, chat_area.y); + } + + #[test] + fn sticky_overlay_does_not_leak_underlying_cell_styles() { + // Paragraph patches styles and only rewrites grapheme-covered cells. + // Pre-fill the sticky rect with conspicuous formatting, then ensure + // paint_sticky_overlay clears before drawing so bold/fg/bg cannot leak + // into sticky cells (including trailing/unwritten ones). + let sticky = Rect::new(0, 0, 20, 3); + let sticky_bg = Color::Rgb(30, 30, 40); + let leak_style = Style::default() + .fg(Color::Rgb(255, 0, 0)) + .bg(Color::Rgb(0, 255, 0)) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + + let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5)); + for y in sticky.y..sticky.bottom() { + for x in sticky.x..sticky.right() { + let cell = buf.cell_mut((x, y)).expect("pre-fill cell"); + cell.set_symbol("X"); + cell.set_style(leak_style); + } + } + + // Short sticky content leaves many trailing cells on each row. + let sticky_lines = vec![ + Line::from(Span::styled( + "▌ ", + Style::default().fg(Color::Gray).bg(sticky_bg), + )), + Line::from(vec![ + Span::styled("▌ ", Style::default().fg(Color::Gray).bg(sticky_bg)), + Span::styled("hi", Style::default().fg(Color::White).bg(sticky_bg)), + ]), + Line::from(Span::styled( + "▌ ▴ ", + Style::default().fg(Color::Gray).bg(sticky_bg), + )), + ]; + paint_sticky_overlay(&mut buf, sticky, sticky_lines, sticky_bg); + + for y in sticky.y..sticky.bottom() { + for x in sticky.x..sticky.right() { + let cell = buf.cell((x, y)).expect("sticky cell"); + assert_ne!( + cell.symbol(), + "X", + "sticky cell ({x},{y}) retained pre-fill symbol" + ); + assert_eq!( + cell.bg, sticky_bg, + "sticky cell ({x},{y}) missing sticky background" + ); + assert_ne!( + cell.fg, + Color::Rgb(255, 0, 0), + "sticky cell ({x},{y}) leaked underlying foreground" + ); + assert!( + !cell.modifier.contains(Modifier::BOLD), + "sticky cell ({x},{y}) leaked bold" + ); + assert!( + !cell.modifier.contains(Modifier::UNDERLINED), + "sticky cell ({x},{y}) leaked underline" + ); + } + } + } + + #[test] + fn sticky_visibility_does_not_change_transcript_viewport_height() { + // Simulates the compact layout: chat area is always full-height below + // the header, whether or not a sticky overlay would be painted. + let content_area = Rect::new(0, 0, 100, 40); + let (header, chat_without_sticky) = compact_transcript_layout(content_area); + let (_, chat_with_sticky) = compact_transcript_layout(content_area); + let sticky = sticky_overlay_rect(chat_with_sticky, 5).expect("sticky"); + + assert_eq!(header.height, 3); + assert_eq!(chat_without_sticky.height, chat_with_sticky.height); + assert_eq!(chat_with_sticky.height, content_area.height - header.height); + // Overlay lives inside the chat rect; it does not reduce chat height. + assert!(sticky.y >= chat_with_sticky.y); + assert!(sticky.bottom() <= chat_with_sticky.bottom()); + assert_eq!(chat_with_sticky.height, 37); + } + + /// Build synthetic `(index, start, body_end)` user-message rows. + /// + /// `body_lines` is the full layout height of the user message including the + /// trailing inter-message blank (top pad + content + bottom pad + blank). + /// Body end used for sticky is therefore `start + body_lines - 1`. + fn synthetic_user_messages(specs: &[(usize, usize, usize)]) -> Vec<(usize, usize, usize)> { + specs + .iter() + .map(|&(idx, start, body_lines)| { + let end_including_blank = start + body_lines; + (idx, start, user_message_body_end(end_including_blank)) + }) + .collect() + } + + #[test] + fn sticky_appears_for_normal_user_assistant_transcript() { + // Normal conversation: user → assistant → (later) user. + // Sticky must appear once the prior user's body has left the top, even + // though the assistant occupies the next rendered block and is fully + // inside the viewport. Intermediate assistant/tool rows do not hide + // sticky — only the next *user* message does, via sticky coverage. + // + // U0: start=0, body_lines=4 → body_end=3, sticky_height=3. + // Assistant at 4 (ignored for hide). Next user U1 at 40. + let msgs = synthetic_user_messages(&[ + (0, 0, 4), // U0 body_end = 3 + (2, 40, 4), // U1 body_end = 43 + ]); + + assert_eq!(msgs[0].2, 3, "1-line body_end excludes trailing blank"); + assert_eq!( + sticky_overlay_height_for_span(msgs[0].1, msgs[0].2), + 3, + "1-line content → 3-row sticky overlay" + ); + + assert_eq!(natural_sticky_index(&msgs, 2), None, "body still in view"); + assert_eq!( + natural_sticky_index(&msgs, 3), + Some(0), + "sticky appears for normal user→assistant once body fully leaves" + ); + assert_eq!( + natural_sticky_index(&msgs, 4), + Some(0), + "assistant immediately below user does not suppress sticky" + ); + assert_eq!( + natural_sticky_index(&msgs, 20), + Some(0), + "sticky stays while scrolling through assistant content" + ); + // Still well above U1's sticky-coverage boundary (40 - 3 = 37). + assert_eq!(natural_sticky_index(&msgs, 30), Some(0)); + } + + #[test] + fn sticky_appears_immediately_when_message_body_fully_leaves_viewport() { + // 1-line user content → layout is 4 rows: pad + content + pad + blank. + // body_end = start + 3. Sticky must appear at S == body_end, not one row + // later (which would wait for the trailing blank). + let msgs = synthetic_user_messages(&[ + (0, 0, 4), // U0: lines 0..4, body_end = 3 + (2, 40, 4), // U1 later + ]); + + assert_eq!(msgs[0].2, 3, "1-line body_end excludes trailing blank"); + assert_eq!(natural_sticky_index(&msgs, 2), None, "body still in view"); + assert_eq!( + natural_sticky_index(&msgs, 3), + Some(0), + "sticky appears the row the body fully leaves" + ); + assert_eq!(natural_sticky_index(&msgs, 4), Some(0)); + + // 3-line user content → layout is 6 rows: pad + 3 content + pad + blank. + // body_end = start + 5. Same "appear immediately" rule. + let tall = synthetic_user_messages(&[ + (0, 0, 6), // body_end = 5 + (2, 50, 6), + ]); + assert_eq!(tall[0].2, 5, "3-line body_end excludes trailing blank"); + assert_eq!(natural_sticky_index(&tall, 4), None); + assert_eq!( + natural_sticky_index(&tall, 5), + Some(0), + "tall sticky appears as soon as body leaves, not after blank" + ); + } + + #[test] + fn sticky_hides_when_next_user_enters_sticky_covered_region() { + // Adjacent examples from the product requirement: + // sticky rendered rows 1..3 and next viewport message rows 4..6 → hide on + // the first scroll increment where the next message enters the sticky- + // covered top region. Same for sticky rows 1..5. + // + // Hide formula: S + sticky_height > next_user_start (half-open coverage). + // Equal bottom edge keeps sticky visible. + + // 1-line / 3-row sticky. Place next user so body_end + sticky_height + // lands exactly on next_user_start: body_end=3, sticky_height=3 → + // next_user_start=6. Visible at S=3 (3+3==6), hidden at S=4 (4+3>6). + let short = synthetic_user_messages(&[ + (0, 0, 4), // body_end = 3, sticky_height = 3 + (2, 6, 4), // next user starts at row 6 + ]); + assert_eq!(sticky_overlay_height_for_span(0, 3), 3); + assert_eq!( + natural_sticky_index(&short, 3), + Some(0), + "adjacent 3-row sticky: equal edge (S+H == next_start) stays visible" + ); + assert_eq!( + natural_sticky_index(&short, 4), + None, + "adjacent 3-row sticky: first increment past equal edge hides" + ); + + // 3-line / 5-row sticky. body_end=5, sticky_height=5 → next_user_start=10. + // Visible at S=5 (5+5==10), hidden at S=6 (6+5>10) — one increment later. + let tall = synthetic_user_messages(&[ + (0, 0, 6), // body_end = 5, sticky_height = 5 + (2, 10, 6), // next user starts at row 10 + ]); + assert_eq!(sticky_overlay_height_for_span(0, 5), 5); + assert_eq!( + natural_sticky_index(&tall, 5), + Some(0), + "adjacent 5-row sticky: equal edge keeps sticky visible" + ); + assert_eq!( + natural_sticky_index(&tall, 6), + None, + "adjacent 5-row sticky: first increment past equal edge hides" + ); + + // Non-adjacent: next user far below. Sticky remains while scrolling + // through intermediate content until the covered region reaches it. + let gap = synthetic_user_messages(&[ + (0, 0, 4), // body_end = 3, sticky_height = 3 + (2, 40, 4), // next user at 40 + ]); + // Hide when S + 3 > 40 → S >= 38. + assert_eq!(natural_sticky_index(&gap, 37), Some(0)); + assert_eq!( + natural_sticky_index(&gap, 38), + None, + "next user first row enters sticky-covered top region" + ); + } + + #[test] + fn sticky_hide_uses_sticky_coverage_not_viewport_height() { + // Hide is driven by sticky overlay coverage vs next *user* start, not + // full viewport height and not intermediate assistant/tool blocks. + // Short (3-row) and tall (5-row) stickies therefore hide at different + // offsets for the same next_user_start — sticky_height matters for the + // visual covered region, but never for scroll extent / layout. + let short = synthetic_user_messages(&[ + (0, 0, 4), // body_end = 3; sticky_height = 3 + (2, 20, 4), + ]); + let tall = synthetic_user_messages(&[ + (0, 0, 6), // body_end = 5; sticky_height = 5 + (2, 20, 6), + ]); + + // Short: hide when S + 3 > 20 → S >= 18. + assert_eq!(natural_sticky_index(&short, 3), Some(0)); + assert_eq!(natural_sticky_index(&short, 17), Some(0)); + assert_eq!( + natural_sticky_index(&short, 18), + None, + "short sticky hides at S + sticky_height > next_user" + ); + + // Tall: hide when S + 5 > 20 → S >= 16 — earlier than short because the + // taller overlay covers more of the top region. + assert_eq!(natural_sticky_index(&tall, 5), Some(0)); + assert_eq!(natural_sticky_index(&tall, 15), Some(0)); + assert_eq!( + natural_sticky_index(&tall, 16), + None, + "tall sticky hides earlier by sticky_height, not by viewport height" + ); + } + + #[test] + fn sticky_ignores_assistant_and_tool_blocks_between_users() { + // Transcript: U0 (idx 0) → assistant (1) → tool (2) → U1 (3). + // Sticky for U0 must remain while scrolling through assistant/tool and + // only hide when U1 enters the sticky-covered top region. + let msgs = synthetic_user_messages(&[ + (0, 0, 4), // U0 body_end = 3, sticky_height = 3 + (3, 50, 4), // U1 body_end = 53 + ]); + + assert_eq!( + natural_sticky_index(&msgs, 3), + Some(0), + "U0 sticky while assistant immediately follows" + ); + assert_eq!( + natural_sticky_index(&msgs, 25), + Some(0), + "assistant/tool content does not suppress sticky" + ); + // Hide when S + 3 > 50 → S >= 48. + assert_eq!(natural_sticky_index(&msgs, 47), Some(0)); + assert_eq!( + natural_sticky_index(&msgs, 48), + None, + "hide only when next *user* enters sticky coverage" + ); + // U1 sticky once its body is fully above. + assert_eq!( + natural_sticky_index(&msgs, 53), + Some(3), + "selected sticky remains the previous user message (U1)" + ); + } + + #[test] + fn sticky_display_uses_up_hysteresis_without_overlay_geometry() { + // U0 body_end=3 sticky_height=3, U1 start=40 body_end=43 sticky_height=3. + // Hide U0 sticky when S + 3 > 40, i.e. S >= 38. + let msgs = synthetic_user_messages(&[(0, 0, 4), (2, 40, 4)]); + + // Scroll down: memory tracks natural. + assert_eq!(resolve_sticky_display(&msgs, 3, None), Some(0)); + assert_eq!(resolve_sticky_display(&msgs, 20, Some(0)), Some(0)); + + // Past hide boundary for U0, before U1 body is fully above → no sticky. + // natural == None must not resurrect remembered state. + assert_eq!( + resolve_sticky_display(&msgs, 38, Some(0)), + None, + "natural None cannot resurrect memory" + ); + assert_eq!(resolve_sticky_display(&msgs, 40, Some(0)), None); + assert_eq!(resolve_sticky_display(&msgs, 43, Some(0)), Some(2)); + + // Scroll up from U1 sticky: hand-off to U0 requires natural to want U0 + // (S + sticky_height <= U1_start) and clearance + // S + 1 + UP_HYSTERESIS <= memory_start. + assert_eq!(STICKY_UP_HYSTERESIS, 5); + assert_eq!( + resolve_sticky_display(&msgs, 36, Some(2)), + None, + "within sticky-coverage of U1 → natural None" + ); + assert_eq!( + resolve_sticky_display(&msgs, 37, Some(2)), + None, + "U1 body re-entered viewport → no sticky" + ); + // S=30: natural wants U0 (30+3 <= 40) and clearance 30+1+5=36 <= 40. + assert_eq!( + resolve_sticky_display(&msgs, 30, Some(2)), + Some(0), + "clearance met and next user not under sticky coverage → hand off" + ); + // Body of the remembered sticky re-entered → clear. + assert_eq!(resolve_sticky_display(&msgs, 2, Some(0)), None); + } + + #[test] + fn compact_render_keeps_chat_area_stable_when_sticky_appears() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut chat_state = ChatState { + chat: Chat::new(), + wave_spinner: WaveSpinner::new(Color::Blue), + compact_mode: true, + sticky_message_index: None, + sticky_click_target: None, + last_chat_area: None, + }; + // Sticky appears for a prior user once its body leaves the top, even with + // a following assistant. Tall assistant content gives room to scroll the + // first user fully above the viewport while remaining well clear of the + // next user sticky-coverage boundary. + chat_state.chat.add_user_message("sticky candidate"); + chat_state + .chat + .add_assistant_message("assistant reply\n".repeat(40)); + chat_state.chat.add_user_message("later user"); + + let mut input = Input::new(); + let mut find_bar = FindBar::new(); + let colors = test_colors(); + let backend = TestBackend::new(80, 40); + let mut terminal = Terminal::new(backend).expect("terminal"); + + // First paint: near top, no sticky expected. Record chat area height. + terminal + .draw(|f| { + render_chat( + f, + &mut chat_state, + &mut input, + "0.0.0".into(), + "/tmp".into(), + None, + "build".into(), + "model".into(), + "provider".into(), + None, + &colors, + false, + false, + false, + None, + "", + None, + &[], + &mut find_bar, + Some("Session"), + ); + }) + .expect("draw without sticky"); + + let area_without = chat_state.last_chat_area.expect("chat area without sticky"); + let viewport_without = chat_state.chat.viewport_height; + assert!( + chat_state.sticky_click_target.is_none(), + "sticky should be hidden near the top of the transcript" + ); + + // Scroll so the first user message is fully above the viewport, but the + // later user has not entered the sticky-covered top region yet. + // Use the first user's body_end as the scroll target. + let first_user_body_end = { + let starts = &chat_state.chat.message_line_positions; + let end = starts + .get(1) + .copied() + .unwrap_or(chat_state.chat.content_height); + user_message_body_end(end) + }; + chat_state.chat.scroll_offset = first_user_body_end; + + terminal + .draw(|f| { + render_chat( + f, + &mut chat_state, + &mut input, + "0.0.0".into(), + "/tmp".into(), + None, + "build".into(), + "model".into(), + "provider".into(), + None, + &colors, + false, + false, + false, + None, + "", + None, + &[], + &mut find_bar, + Some("Session"), + ); + }) + .expect("draw with sticky"); + + let area_with = chat_state.last_chat_area.expect("chat area with sticky"); + let viewport_with = chat_state.chat.viewport_height; + + assert_eq!( + area_without, area_with, + "sticky overlay must not change the transcript layout rect" + ); + assert_eq!( + viewport_without, viewport_with, + "sticky overlay must not change Chat::viewport_height / scroll extent" + ); + let (sticky_rect, sticky_idx) = chat_state + .sticky_click_target + .expect("sticky click target for normal user→assistant after body leaves"); + // First user message is index 0 (user, assistant, later user). + assert_eq!(sticky_idx, 0); + assert_eq!(sticky_rect.x, area_with.x); + assert_eq!(sticky_rect.y, area_with.y); + assert_eq!(sticky_rect.width, area_with.width); + assert!(sticky_rect.height >= 3 && sticky_rect.height <= 5); + assert_eq!(chat_state.chat.faded_message_index, Some(sticky_idx)); + } + + #[test] + fn compact_render_without_sticky_leaves_full_transcript_area() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut chat_state = ChatState { + chat: Chat::new(), + wave_spinner: WaveSpinner::new(Color::Blue), + compact_mode: true, + sticky_message_index: None, + sticky_click_target: None, + last_chat_area: None, + }; + chat_state + .chat + .add_user_message("only message still in view"); + + let mut input = Input::new(); + let mut find_bar = FindBar::new(); + let colors = test_colors(); + let backend = TestBackend::new(80, 30); + let mut terminal = Terminal::new(backend).expect("terminal"); + + terminal + .draw(|f| { + render_chat( + f, + &mut chat_state, + &mut input, + "0.0.0".into(), + "/tmp".into(), + None, + "build".into(), + "model".into(), + "provider".into(), + None, + &colors, + false, + false, + false, + None, + "", + None, + &[], + &mut find_bar, + Some("Session"), + ); + }) + .expect("draw"); + + let chat_area = chat_state.last_chat_area.expect("chat area"); + // Transcript is everything below the fixed 3-row compact header. + // Input/help/status rows reduce available height, but sticky is not a + // layout row so the chat area is still "full" relative to that chrome. + assert_eq!(chat_area.y, 3, "chat starts immediately under the header"); + assert!(chat_area.height > 0); + assert!(chat_state.sticky_click_target.is_none()); + assert!(chat_state.chat.faded_message_index.is_none()); + // Overlay helpers agree: no sticky height → no overlay rect. + assert!(sticky_overlay_rect(chat_area, 0).is_none()); + } }