From 6c3499ced2e2df6c45ff9a79b1cf9e511b4c486f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 20 Aug 2026 02:12:43 +0800 Subject: [PATCH 01/10] fix(dialog): pin custom answer row and add vertical scroll for overflowing question options When multiple wrapped options exceeded the dialog body height, the "Type your own answer" row could be clipped off-screen. This adds vertical scroll state to the question body, keeps the focused row visible, and splits the custom answer row out as a sticky footer that always stays pinned at the bottom of the panel. --- src/views/question_dialog.rs | 256 +++++++++++++++++++++++++++++++++-- 1 file changed, 247 insertions(+), 9 deletions(-) diff --git a/src/views/question_dialog.rs b/src/views/question_dialog.rs index 63994f8..164683b 100644 --- a/src/views/question_dialog.rs +++ b/src/views/question_dialog.rs @@ -252,6 +252,8 @@ pub struct QuestionDialogState { queue: VecDeque, tab_hitboxes: Vec, mouse_hitboxes: Vec, + /// Vertical scroll for the question body when options wrap past the panel. + body_scroll_y: u16, /// Last rendered panel height (for chat bottom scroll padding). last_panel_height: u16, } @@ -281,6 +283,7 @@ impl QuestionDialogState { queue: VecDeque::new(), tab_hitboxes: Vec::new(), mouse_hitboxes: Vec::new(), + body_scroll_y: 0, last_panel_height: 0, } } @@ -295,6 +298,7 @@ impl QuestionDialogState { self.current = Some(request); self.tab_hitboxes.clear(); self.mouse_hitboxes.clear(); + self.body_scroll_y = 0; } else { self.queue.push_back(request); } @@ -353,6 +357,8 @@ impl QuestionDialogState { } self.current = self.queue.pop_front(); self.tab_hitboxes.clear(); + self.mouse_hitboxes.clear(); + self.body_scroll_y = 0; } pub fn respond_current(&mut self, response: Value) { @@ -361,6 +367,8 @@ impl QuestionDialogState { } self.current = self.queue.pop_front(); self.tab_hitboxes.clear(); + self.mouse_hitboxes.clear(); + self.body_scroll_y = 0; } pub fn cancel_current(&mut self) { @@ -370,6 +378,8 @@ impl QuestionDialogState { } self.current = self.queue.pop_front(); self.tab_hitboxes.clear(); + self.mouse_hitboxes.clear(); + self.body_scroll_y = 0; } pub fn clear_with_empty(&mut self) { @@ -383,6 +393,8 @@ impl QuestionDialogState { let _ = request.response_tx.send(response); } self.tab_hitboxes.clear(); + self.mouse_hitboxes.clear(); + self.body_scroll_y = 0; } pub fn insert_text(&mut self, text: &str) { @@ -1091,6 +1103,7 @@ fn question_body_hitboxes( request: &QuestionDialogRequest, body_lines: &[Line<'_>], body_area: Rect, + body_scroll_y: u16, ) -> Vec { let Some(question) = request.current_question() else { return Vec::new(); @@ -1101,11 +1114,22 @@ fn question_body_hitboxes( let option_start = 3 + usize::from(question.multiple); let mut y = body_area.y; + let mut consumed = 0u16; let mut hitboxes = Vec::new(); for (line_index, line) in body_lines.iter().enumerate() { let height = line_wrapped_height(line, body_area.width).max(1); + let line_start = consumed; + let line_end = consumed.saturating_add(height); + consumed = line_end; + + if line_end <= body_scroll_y { + continue; + } + + let hidden = body_scroll_y.saturating_sub(line_start); + let visible_in_line = height.saturating_sub(hidden); if line_index >= option_start && line_index < option_start + option_row_count(question) { - let visible_height = height.min(body_area.bottom().saturating_sub(y)); + let visible_height = visible_in_line.min(body_area.bottom().saturating_sub(y)); if visible_height > 0 { hitboxes.push(QuestionMouseHitbox { area: Rect::new(body_area.x, y, body_area.width, visible_height), @@ -1113,7 +1137,7 @@ fn question_body_hitboxes( }); } } - y = y.saturating_add(height); + y = y.saturating_add(visible_in_line); if y >= body_area.bottom() { break; } @@ -1121,6 +1145,67 @@ fn question_body_hitboxes( hitboxes } +/// Keep the selected/custom row visible when wrapped body height exceeds the panel. +fn ensure_body_scroll_visible( + body_lines: &[Line<'_>], + body_width: u16, + body_height: u16, + focus_line_index: usize, + scroll_y: &mut u16, +) { + if body_height == 0 || body_lines.is_empty() { + *scroll_y = 0; + return; + } + + let mut offsets = Vec::with_capacity(body_lines.len() + 1); + offsets.push(0u16); + let mut total = 0u16; + for line in body_lines { + total = total.saturating_add(line_wrapped_height(line, body_width).max(1)); + offsets.push(total); + } + + let max_scroll = total.saturating_sub(body_height); + if *scroll_y > max_scroll { + *scroll_y = max_scroll; + } + + let focus = focus_line_index.min(body_lines.len().saturating_sub(1)); + let focus_start = offsets[focus]; + let focus_end = offsets[focus + 1]; + if focus_start < *scroll_y { + *scroll_y = focus_start; + } else if focus_end > (*scroll_y).saturating_add(body_height) { + *scroll_y = focus_end.saturating_sub(body_height); + } + if *scroll_y > max_scroll { + *scroll_y = max_scroll; + } +} + +fn focused_body_line_index(request: &QuestionDialogRequest, body_lines: &[Line<'_>]) -> usize { + let Some(question) = request.current_question() else { + return 0; + }; + if question.options.is_empty() { + // Free-text body: question text is the interactive content. + return 1.min(body_lines.len().saturating_sub(1)); + } + + let option_start = 3 + usize::from(question.multiple); + if request.current_is_custom_row() { + return body_lines.len().saturating_sub(1); + } + + let cursor = request + .current_answer() + .map(|answer| answer.cursor) + .unwrap_or(0) + .min(question.options.len().saturating_sub(1)); + (option_start + cursor).min(body_lines.len().saturating_sub(1)) +} + fn push_tab_hitbox( hitboxes: &mut Vec, header_area: Rect, @@ -1280,6 +1365,7 @@ pub fn render_question_dialog( let Some(request) = state.active() else { state.tab_hitboxes.clear(); state.mouse_hitboxes.clear(); + state.body_scroll_y = 0; state.last_panel_height = 0; return; }; @@ -1362,13 +1448,80 @@ pub fn render_question_dialog( .constraints([Constraint::Min(0), Constraint::Length(cancel_chunk_width)]) .split(chunks[0]); + // Keep the custom answer row pinned to the bottom of the body so wrapping + // options cannot clip "( ) Type your own answer" off-screen on resize. + let pin_custom = state + .active() + .and_then(|r| r.current_question()) + .map(|q| !q.options.is_empty() && q.custom) + .unwrap_or(false); + let focus_line = { + let Some(request) = state.active() else { + return; + }; + // When the custom row is sticky, keep scroll focused on option rows only. + if pin_custom && request.current_is_custom_row() { + usize::MAX + } else { + focused_body_line_index(request, &body_lines) + } + }; + let (scrollable_lines, sticky_custom) = split_sticky_custom_row(pin_custom, body_lines); + let sticky_height = sticky_custom + .as_ref() + .map(|line| line_wrapped_height(line, chunks[1].width).max(1)) + .unwrap_or(0); + let scroll_area = if sticky_height > 0 && chunks[1].height > sticky_height { + Rect::new( + chunks[1].x, + chunks[1].y, + chunks[1].width, + chunks[1].height.saturating_sub(sticky_height), + ) + } else if sticky_height > 0 { + // Extremely short panel: prefer showing the custom row. + Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, 0) + } else { + chunks[1] + }; + let sticky_area = if sticky_height > 0 { + Rect::new( + chunks[1].x, + chunks[1].y.saturating_add(scroll_area.height), + chunks[1].width, + sticky_height.min(chunks[1].height.saturating_sub(scroll_area.height)), + ) + } else { + Rect::default() + }; + + ensure_body_scroll_visible( + &scrollable_lines, + scroll_area.width, + scroll_area.height, + focus_line.min(scrollable_lines.len().saturating_sub(1)), + &mut state.body_scroll_y, + ); + let body_scroll_y = state.body_scroll_y; + let (tab_scroll_x, tab_hitboxes, mouse_hitboxes) = { let Some(request) = state.active() else { return; }; let tab_scroll_x = active_tab_scroll(request, header_chunks[0].width); let tab_hitboxes = question_tab_hitboxes(request, header_chunks[0], tab_scroll_x); - let mut mouse_hitboxes = question_body_hitboxes(request, &body_lines, chunks[1]); + let mut mouse_hitboxes = + question_body_hitboxes(request, &scrollable_lines, scroll_area, body_scroll_y); + if sticky_custom.is_some() { + if let Some(question) = request.current_question() { + if !question.options.is_empty() && sticky_area.height > 0 { + mouse_hitboxes.push(QuestionMouseHitbox { + area: sticky_area, + target: QuestionMouseTarget::Option(question.options.len()), + }); + } + } + } mouse_hitboxes.push(QuestionMouseHitbox { area: header_chunks[1], target: QuestionMouseTarget::Cancel, @@ -1395,18 +1548,50 @@ pub fn render_question_dialog( header_chunks[1], ); - f.render_widget( - Paragraph::new(body_lines) - .style(Style::default().bg(colors.dialog_background)) - .wrap(Wrap { trim: true }), - chunks[1], - ); + if scroll_area.height > 0 { + f.render_widget( + Paragraph::new(scrollable_lines) + .style(Style::default().bg(colors.dialog_background)) + .wrap(Wrap { trim: true }) + .scroll((body_scroll_y, 0)), + scroll_area, + ); + } + if let Some(custom_line) = sticky_custom { + if sticky_area.height > 0 { + f.render_widget( + Paragraph::new(vec![custom_line]) + .style(Style::default().bg(colors.dialog_background)) + .wrap(Wrap { trim: true }), + sticky_area, + ); + } + } f.render_widget(Paragraph::new(footer).alignment(Alignment::Left), chunks[3]); state.tab_hitboxes = tab_hitboxes; state.mouse_hitboxes = mouse_hitboxes; } +fn split_sticky_custom_row( + pin_custom: bool, + mut body_lines: Vec>, +) -> (Vec>, Option>) { + if !pin_custom || body_lines.len() < 2 { + return (body_lines, None); + } + // question_body_lines ends with the custom answer row (optionally after a blank). + let custom = body_lines.pop(); + if body_lines + .last() + .map(|line| line.spans.is_empty()) + .unwrap_or(false) + { + body_lines.pop(); + } + (body_lines, custom) +} + fn parse_questions(value: Value) -> Vec { let values = match value { Value::Array(items) => items, @@ -3047,6 +3232,59 @@ mod tests { assert!(rendered.contains("Type your own answer")); } + #[test] + fn sticky_custom_row_stays_visible_when_body_overflows_on_short_terminal() { + use ratatui::{backend::TestBackend, Terminal}; + + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "When designing a long-running Rust TUI application like crabcode that needs to coordinate streaming LLM responses, tool execution, SQLite preference persistence, and reactive Ratatui rendering on a single event loop, which architectural tradeoff do you consider most important for the next major refactor?", + "header": "Architecture", + "options": [ + { + "label": "Single-threaded actor bus", + "description": "Keep one event loop and ordered channels; prioritize determinism and simpler reasoning about UI state." + }, + { + "label": "Hybrid workers + main UI", + "description": "Move blocking/network work off the UI thread while keeping Ratatui rendering on the main thread." + }, + { + "label": "Headless core + thin TUI", + "description": "Extract a reusable session engine so CLI/headless and TUI share one state machine." + } + ] + }]), + tx, + ); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); + // Narrow + short: wrapped options exceed body height and previously clipped + // the custom answer row off the bottom. + let backend = TestBackend::new(72, 18); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| render_question_dialog(frame, &mut state, frame.area(), colors)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let rendered = (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + rendered.contains("Type your own answer"), + "custom row should stay pinned visible on overflow:\n{rendered}" + ); + } + #[test] fn current_snapshot_exposes_questions_for_remote_clients() { let (tx, _rx) = oneshot::channel(); From de790cd054631fb3ca57e63b767b7f99b37870c2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 8 Aug 2026 20:43:05 +0800 Subject: [PATCH 02/10] feat(chat): add compact-mode with sticky header and sticky user messages Add `/compact-mode` command support to toggle compact layout while preserving chat-only behavior. Implement sticky session-title header and last fully-scrolled user message in compact mode with click-to-scroll, plus faded viewport copy handling and adjusted hit-testing to keep interaction and rendering stable. --- src/app.rs | 52 ++++++- src/command/handlers.rs | 24 ++++ src/ui/components/chat.rs | 34 ++++- src/views/chat.rs | 295 +++++++++++++++++++++++++++++++++++++- 4 files changed, 399 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index 597f7b0..d232418 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3064,7 +3064,10 @@ 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)) } /// Forward chat mouse events while a permission/question dialog is open. @@ -4914,6 +4917,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() @@ -6166,6 +6187,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); @@ -6395,6 +6429,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; @@ -10616,6 +10663,9 @@ impl App { &queued_messages, &mut self.find_bar, self.overlay_focus == OverlayFocus::None, + 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 247bd0c..12dcd2a 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -319,6 +319,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. @@ -1716,6 +1718,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(), @@ -1735,7 +1739,6 @@ impl Chat { cached_has_active_tools: std::cell::Cell::new(false), hovered_image: None, hovered_hyperlink: None, - pending_scroll_to_message: None, } } @@ -1787,6 +1790,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(), @@ -1806,7 +1811,6 @@ impl Chat { cached_has_active_tools: std::cell::Cell::new(false), hovered_image: None, hovered_hyperlink: None, - pending_scroll_to_message: None, } } @@ -3987,6 +3991,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, diff --git a/src/views/chat.rs b/src/views/chat.rs index 57e6357..3ac993d 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -9,12 +9,14 @@ use ratatui::{ }; 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, } } } @@ -141,6 +155,7 @@ pub fn render_chat( queued_messages: &[String], find_bar: &mut FindBar, show_terminal_cursor: bool, + session_title: Option<&str>, ) { let size = f.area(); let is_subagent_view = subagent_tabs @@ -179,9 +194,283 @@ 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. + // + // Sticky rules (scroll_offset = S, user message start/end = si / ei): + // + // A message is only eligible to be sticky once it is FULLY above the + // viewport (ei <= S). While any part of it is still in the viewport, the + // real message is shown — never sticky + faded at the same time. + // + // Scroll DOWN: + // - Sticky Ui appears only when ei <= S (fully scrolled off). + // - Sticky disappears when the next user message is within GAP rows of + // the viewport top: S >= s{i+1} - GAP. Only the real next message is + // shown (not sticky yet). + // - U{i+1} becomes sticky only once it too is fully above the viewport. + // + // Scroll UP: + // - Sticky Ui remains while ei <= S. + // - Once S drops so Ui is no longer fully above, sticky disappears. + // - Previous message is NOT shown immediately; wait until there is + // UP_HYSTERESIS + GAP rows of space above Ui's start, then show it. + let chat_area = if chat_state.compact_mode { + const GAP: usize = 1; + const UP_HYSTERESIS: usize = 5; + + let scroll_offset = chat_state.chat.scroll_offset; + let positions = &chat_state.chat.message_line_positions; + let content_height = chat_state.chat.content_height; + + let msg_end_line = |idx: usize| -> usize { + (idx + 1..positions.len()) + .find_map(|i| positions.get(i).copied()) + .unwrap_or(content_height) + }; + + // (message_index, start_line) for every non-compaction user message. + let user_messages: Vec<(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, _)| positions.get(i).map(|&start| (i, start))) + .collect(); + + // Natural sticky (scroll-down rules): last user message FULLY above the + // viewport, unless we're within GAP of the next user message's top. + let natural_sticky = { + let prev = user_messages + .iter() + .rev() + .find(|(idx, _)| msg_end_line(*idx) <= scroll_offset) + .copied(); + match prev { + Some((idx, _)) => { + let next_start = user_messages + .iter() + .find(|(i, _)| *i > idx) + .map(|(_, start)| *start); + match next_start { + // Next message is about to / has entered the top — no sticky. + // Real viewport message must remain visible. + Some(ns) if scroll_offset >= ns.saturating_sub(GAP) => None, + _ => Some(idx), + } + } + None => None, + } + }; + + // Apply scroll-up hysteresis using the remembered sticky index. + // sticky_message_index is a memory of the last sticky even when hidden. + let display_sticky = match (chat_state.sticky_message_index, natural_sticky) { + // No memory yet — follow natural. + (None, nat) => nat, + + // Natural is None — dead zone or message still partially in viewport. + // Never re-show memory once natural has cleared. + (Some(_memory), None) => None, + + // Natural caught up to or passed memory (scroll down / same) — follow natural. + (Some(memory), Some(nat)) if nat >= memory => Some(nat), + + // Natural wants an older message (scroll up) — require clearance above `memory`. + (Some(memory), Some(nat)) => { + let memory_start = positions.get(memory).copied().unwrap_or(0); + if scroll_offset + GAP + UP_HYSTERESIS <= memory_start { + // Enough space above the remembered message → show older sticky. + Some(nat) + } else if msg_end_line(memory) <= scroll_offset { + // Memory is still fully above viewport → keep it sticky. + Some(memory) + } else { + // Memory has re-entered the viewport — no sticky. + None + } + } + }; + + // 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 = positions.get(idx).copied().unwrap_or(0); + let msg_end = msg_end_line(idx); + // User messages are rendered as: top pad + content + bottom pad + trailing blank. + // The trailing blank is inter-message spacing, not part of the sticky body. + let msg_body_lines = msg_end.saturating_sub(msg_start).saturating_sub(1); + // 1-line body → 3 rows (pad + content + pad); clamp to 5. + msg_body_lines.min(5).max(3) as u16 + } else { + 0 + }; + + let sticky_idx = display_sticky; + + let compact_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // header (no bg) + Constraint::Length(sticky_height), // sticky (0 if invisible) + Constraint::Min(0), // chat content + ]) + .split(above_status_chunks[1]); + + // 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(compact_chunks[0]); + // 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], + ); + } + + // Render sticky message (only if sticky_height > 0 AND sticky_idx is Some) + if sticky_height > 0 { + if let Some(idx) = sticky_idx { + let sticky_rect = compact_chunks[1]; + chat_state.sticky_click_target = Some((sticky_rect, idx)); + + let max_width = sticky_rect.width as usize; + 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)); + let text_style = Style::default().fg(colors.text).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 right_padding = 2usize; + let content_width = max_width + .saturating_sub(1 + horizontal_padding + right_padding) + .max(1); + + 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: split by newlines, take up to `content_rows`, truncate each line. + if let Some(message) = sticky_msg { + for content in message.content.split('\n').take(content_rows) { + let clamped = truncate_to_width(content, content_width); + let line_width = UnicodeWidthStr::width(clamped.as_str()); + let trailing_padding = " " + .repeat(max_width.saturating_sub(1 + horizontal_padding + line_width)); + let mut spans = Vec::with_capacity(4); + spans.push(Span::styled("▌", border_style)); + spans.push(Span::styled(" ".repeat(horizontal_padding), pad_style)); + spans.push(Span::styled(clamped, text_style)); + 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); + } + // Fill remaining content rows if the message has fewer lines. + while sticky_lines.len() < content_rows + 1 { + sticky_lines.push(padding_line()); + } + } else { + for _ in 0..content_rows { + sticky_lines.push(padding_line()); + } + } + + sticky_lines.push(bottom_padding_line()); + + f.render_widget( + Paragraph::new(sticky_lines) + .style(Style::default().bg(colors.background_element)), + sticky_rect, + ); + } else { + chat_state.sticky_click_target = None; + } + } else { + chat_state.sticky_click_target = None; + } + + chat_state.last_chat_area = Some(compact_chunks[2]); + compact_chunks[2] + } 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] + }; + + chat_state.chat.render(f, chat_area, &agent, &model, colors); if is_subagent_view { if let Some(tabs) = subagent_tabs.as_ref() { From 6c4ad8c3d7266459c1d98f8b49e12dd6eda610d2 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 8 Aug 2026 21:03:18 +0800 Subject: [PATCH 03/10] fix: scroll region includes headers/stickymessage header --- src/app.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index d232418..06eadcf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3070,7 +3070,7 @@ impl App { .unwrap_or_else(|| self.chat_area_for_size(self.last_frame_size)) } - /// Forward chat mouse events while a permission/question dialog is open. +/// Forward chat mouse events while a permission/question dialog is open. /// Clicks on dialog controls are handled by the dialog; everything else /// (scroll + text selection) reaches the chat behind it. fn forward_chat_mouse_through_dialog(&mut self, mouse: MouseEvent) { @@ -3114,13 +3114,35 @@ impl App { } } + /// Region where a mouse wheel scrolls the chat. In compact mode this + /// extends above the chat content to include the 3-row header and any + /// sticky bar, 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; + } + let sticky_top = self + .chat_state + .sticky_click_target + .map(|(r, _)| r.y) + .unwrap_or(chat_area.y); + let top = sticky_top.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 matches!( self.overlay_focus, OverlayFocus::None | OverlayFocus::FindBar ) && 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 From e673607f19d029a4e84ef9d48315750cee872fbd Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 8 Aug 2026 21:08:02 +0800 Subject: [PATCH 04/10] fix(chat): align sticky user message rendering with full message formatting - add helper to render user message content with shared line-wrapping and image-placeholder styling - update compact sticky preview to use the shared formatter so it visually matches real user messages (including truncation behavior and colors) --- src/ui/components/chat.rs | 53 +++++++++++++++++++++++++++++++++++++++ src/views/chat.rs | 35 +++++++++++--------------- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 12dcd2a..7e2c634 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -5169,6 +5169,59 @@ impl Chat { (lines, locations) } + /// Format a user message's content into wrapped, styled lines, mirroring + /// `format_message`'s user branch exactly (image-placeholder 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(spans_with_image_placeholders( + content_line, + 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 3ac993d..e864105 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -377,15 +377,11 @@ pub fn render_chat( 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)); - let text_style = Style::default().fg(colors.text).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 arrow_style = + non_selectable_style(Style::default().fg(colors.text_weak).bg(bg)); let horizontal_padding = 2usize; - let right_padding = 2usize; - let content_width = max_width - .saturating_sub(1 + horizontal_padding + right_padding) - .max(1); let padding_line = || { let mut line = Line::from(vec![ @@ -419,28 +415,27 @@ pub fn render_chat( let mut sticky_lines: Vec = Vec::with_capacity(sticky_height as usize); sticky_lines.push(padding_line()); - // Content rows: split by newlines, take up to `content_rows`, truncate each line. - if let Some(message) = sticky_msg { - for content in message.content.split('\n').take(content_rows) { - let clamped = truncate_to_width(content, content_width); - let line_width = UnicodeWidthStr::width(clamped.as_str()); + // 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(4); + 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.push(Span::styled(clamped, text_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); - } - // Fill remaining content rows if the message has fewer lines. - while sticky_lines.len() < content_rows + 1 { - sticky_lines.push(padding_line()); - } - } else { - for _ in 0..content_rows { + } else { + // Message has fewer lines than the sticky can show. sticky_lines.push(padding_line()); } } From aa73c7019e66b32855561e8111e33e5f71f66b40 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 8 Aug 2026 21:39:44 +0800 Subject: [PATCH 05/10] fix: @agent mention colors in sticky message --- src/ui/components/chat.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 7e2c634..48ac287 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -5171,9 +5171,9 @@ impl Chat { /// Format a user message's content into wrapped, styled lines, mirroring /// `format_message`'s user branch exactly (image-placeholder 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. + /// `@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, @@ -5212,8 +5212,10 @@ impl Chat { .split('\n') .flat_map(|content_line| { let content_line = content_line.strip_suffix('\r').unwrap_or(content_line); - let styled_content = Line::from(spans_with_image_placeholders( + let styled_content = Line::from(style_agent_mentions_in_line( content_line, + &self.agent_mention_names, + colors, text_style, &image_style, )); From b290578e51175135407eb8629bb2463c9c06fdf7 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 9 Aug 2026 01:01:12 +0800 Subject: [PATCH 06/10] fix: more fix attempts --- src/app.rs | 54 +++++++++++++++++++++++++++------------ src/ui/components/chat.rs | 13 ++++++++-- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index 06eadcf..bf3d0d8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8322,28 +8322,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; } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 48ac287..009084e 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -3076,6 +3076,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) { @@ -3920,6 +3924,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) { @@ -3938,7 +3944,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; } } } @@ -3946,8 +3954,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 { From 9d471edbf82389cd5469fd7d9f8fa85f68f84ab1 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 9 Aug 2026 02:21:02 +0800 Subject: [PATCH 07/10] feat: make icon smaller --- src/views/chat.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/views/chat.rs b/src/views/chat.rs index e864105..2a9d9fb 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -377,7 +377,7 @@ pub fn render_chat( 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. + // ▴ 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)); @@ -392,11 +392,11 @@ pub fn render_chat( line }; - // Bottom padding with a horizontally-centered ▲ click affordance. + // Bottom padding with a horizontally-centered ▴ click affordance. let bottom_padding_line = || { - // Layout: "▌" + spaces + "▲" + spaces, total width = max_width. + // Layout: "▌" + spaces + "▴" + spaces, total width = max_width. let body_width = max_width.saturating_sub(1); // after border - let arrow = "▲"; + let arrow = "▴"; let arrow_w = 1usize; let left = body_width.saturating_sub(arrow_w) / 2; let right = body_width.saturating_sub(left + arrow_w); From 0438afc083a911c1d6e645ac13757021f624523f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 9 Aug 2026 14:52:21 +0800 Subject: [PATCH 08/10] fix(chat): best shape, render compact sticky message as overlay over transcript - move compact sticky user-message bar from layout rows to an overlay so chat viewport/scroll extents remain constant - keep mouse-wheel scroll region anchored to header-only height in compact mode, independent of sticky visibility - introduce helper to draw the scrollbar over the chat area to avoid thumb occlusion by overlays - extract sticky message visibility/position calculations and body-end handling into dedicated helpers for cleaner overlay logic --- src/app.rs | 13 +- src/ui/components/chat.rs | 31 ++ src/views/chat.rs | 1098 ++++++++++++++++++++++++++++++------- 3 files changed, 937 insertions(+), 205 deletions(-) diff --git a/src/app.rs b/src/app.rs index bf3d0d8..d55e94b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3115,19 +3115,16 @@ impl App { } /// Region where a mouse wheel scrolls the chat. In compact mode this - /// extends above the chat content to include the 3-row header and any - /// sticky bar, so scrolling works even when the pointer is over that chrome. + /// 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; } - let sticky_top = self - .chat_state - .sticky_click_target - .map(|(r, _)| r.y) - .unwrap_or(chat_area.y); - let top = sticky_top.saturating_sub(3); // header rows + // 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, diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 009084e..496a400 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -2988,6 +2988,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, diff --git a/src/views/chat.rs b/src/views/chat.rs index 2a9d9fb..c87efac 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -4,7 +4,7 @@ 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}; @@ -196,40 +196,36 @@ pub fn render_chat( // Compact mode: sticky header (session title) + sticky scrolled-past user message. // - // Sticky rules (scroll_offset = S, user message start/end = si / ei): + // 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). // - // A message is only eligible to be sticky once it is FULLY above the - // viewport (ei <= S). While any part of it is still in the viewport, the - // real message is shown — never sticky + faded at the same time. - // - // Scroll DOWN: - // - Sticky Ui appears only when ei <= S (fully scrolled off). - // - Sticky disappears when the next user message is within GAP rows of - // the viewport top: S >= s{i+1} - GAP. Only the real next message is - // shown (not sticky yet). - // - U{i+1} becomes sticky only once it too is fully above the viewport. - // - // Scroll UP: - // - Sticky Ui remains while ei <= S. - // - Once S drops so Ui is no longer fully above, sticky disappears. - // - Previous message is NOT shown immediately; wait until there is - // UP_HYSTERESIS + GAP rows of space above Ui's start, then show it. - let chat_area = if chat_state.compact_mode { - const GAP: usize = 1; - const UP_HYSTERESIS: usize = 5; + // 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; - let positions = &chat_state.chat.message_line_positions; + // 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..positions.len()) - .find_map(|i| positions.get(i).copied()) + (idx + 1..rendered_message_starts.len()) + .find_map(|i| rendered_message_starts.get(i).copied()) .unwrap_or(content_height) }; - // (message_index, start_line) for every non-compaction user message. - let user_messages: Vec<(usize, usize)> = chat_state + // (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() @@ -238,69 +234,26 @@ pub fn render_chat( m.role == MessageRole::User && !crate::session::compaction::is_compaction_display_item(m) }) - .filter_map(|(i, _)| positions.get(i).map(|&start| (i, start))) + .filter_map(|(i, _)| { + rendered_message_starts.get(i).map(|&start| { + let end = msg_end_line(i); + (i, start, user_message_body_end(end)) + }) + }) .collect(); - // Natural sticky (scroll-down rules): last user message FULLY above the - // viewport, unless we're within GAP of the next user message's top. - let natural_sticky = { - let prev = user_messages - .iter() - .rev() - .find(|(idx, _)| msg_end_line(*idx) <= scroll_offset) - .copied(); - match prev { - Some((idx, _)) => { - let next_start = user_messages - .iter() - .find(|(i, _)| *i > idx) - .map(|(_, start)| *start); - match next_start { - // Next message is about to / has entered the top — no sticky. - // Real viewport message must remain visible. - Some(ns) if scroll_offset >= ns.saturating_sub(GAP) => None, - _ => Some(idx), - } - } - None => None, - } - }; - - // Apply scroll-up hysteresis using the remembered sticky index. - // sticky_message_index is a memory of the last sticky even when hidden. - let display_sticky = match (chat_state.sticky_message_index, natural_sticky) { - // No memory yet — follow natural. - (None, nat) => nat, - - // Natural is None — dead zone or message still partially in viewport. - // Never re-show memory once natural has cleared. - (Some(_memory), None) => None, - - // Natural caught up to or passed memory (scroll down / same) — follow natural. - (Some(memory), Some(nat)) if nat >= memory => Some(nat), - - // Natural wants an older message (scroll up) — require clearance above `memory`. - (Some(memory), Some(nat)) => { - let memory_start = positions.get(memory).copied().unwrap_or(0); - if scroll_offset + GAP + UP_HYSTERESIS <= memory_start { - // Enough space above the remembered message → show older sticky. - Some(nat) - } else if msg_end_line(memory) <= scroll_offset { - // Memory is still fully above viewport → keep it sticky. - Some(memory) - } else { - // Memory has re-entered the viewport — no sticky. - None - } - } - }; + 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); + let first_start = user_messages.first().map(|(_, s, _)| *s).unwrap_or(0); if scroll_offset <= first_start { chat_state.sticky_message_index = None; } @@ -313,28 +266,13 @@ pub fn render_chat( chat_state.chat.faded_message_index = display_sticky; let sticky_height: u16 = if let Some(idx) = display_sticky { - let msg_start = positions.get(idx).copied().unwrap_or(0); + let msg_start = rendered_message_starts.get(idx).copied().unwrap_or(0); let msg_end = msg_end_line(idx); - // User messages are rendered as: top pad + content + bottom pad + trailing blank. - // The trailing blank is inter-message spacing, not part of the sticky body. - let msg_body_lines = msg_end.saturating_sub(msg_start).saturating_sub(1); - // 1-line body → 3 rows (pad + content + pad); clamp to 5. - msg_body_lines.min(5).max(3) as u16 + sticky_overlay_height_for_span(msg_start, user_message_body_end(msg_end)) as u16 } else { 0 }; - let sticky_idx = display_sticky; - - let compact_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // header (no bg) - Constraint::Length(sticky_height), // sticky (0 if invisible) - Constraint::Min(0), // chat content - ]) - .split(above_status_chunks[1]); - // 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). @@ -349,7 +287,7 @@ pub fn render_chat( ] .as_ref(), ) - .split(compact_chunks[0]); + .split(header_area); // Title line (accent + bold, no background) f.render_widget( Paragraph::new(title).style( @@ -361,112 +299,126 @@ pub fn render_chat( ); } - // Render sticky message (only if sticky_height > 0 AND sticky_idx is Some) - if sticky_height > 0 { - if let Some(idx) = sticky_idx { - let sticky_rect = compact_chunks[1]; - chat_state.sticky_click_target = Some((sticky_rect, idx)); - - let max_width = sticky_rect.width as usize; - 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()); + 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; - f.render_widget( - Paragraph::new(sticky_lines) - .style(Style::default().bg(colors.background_element)), - sticky_rect, - ); - } else { - chat_state.sticky_click_target = None; - } - } else { - 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_state.last_chat_area = Some(compact_chunks[2]); - compact_chunks[2] + (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] + (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() { render_subagent_footer( @@ -602,6 +554,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, @@ -1235,13 +1350,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 { @@ -1497,4 +1622,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()); + } } From 713b042ed46ac7230099d84d420a2b9d0139dcd0 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 9 Aug 2026 23:23:35 +0800 Subject: [PATCH 09/10] feat(tui): add configurable, persisted compact-mode preference Add support for `tui.compactMode` (and `compact_mode` alias) in config parsing/schema/docs, initialize chat sticky-header compact mode from config with persisted fallback, persist `/compact-mode` toggles in preferences storage, and add/extend tests for the new config + prefs behavior. --- _docs/config/index.mdx | 17 +++++++++++++++++ _plans/__TODOS.md | 1 + crabcode.schema.json | 15 +++++++++++++++ src/app.rs | 20 ++++++++++++++++++-- src/config/configuration.rs | 27 +++++++++++++++++++++++++++ src/persistence/prefs.rs | 25 +++++++++++++++++++++++++ src/views/chat.rs | 8 ++++---- 7 files changed, 107 insertions(+), 6 deletions(-) diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index 4dda361..db9375d 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -38,6 +38,9 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", "model": "openai/gpt-5.2", "theme": "crabcode-orange", + "tui": { + "compactMode": true + }, "notifications": { "terminalCondition": "unfocused", "complete": { @@ -65,6 +68,20 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the } ``` +## Terminal UI + +Use `tui.compactMode` to explicitly control compact mode and its sticky message header: + +```jsonc title="crabcode.jsonc" +{ + "tui": { + "compactMode": false + } +} +``` + +`compactMode` takes priority over the preference saved by `/compact-mode`. Without a config value, crabcode restores the last `/compact-mode` choice; new installations default to enabled. `compact_mode` is accepted as an alias. + ## Permissions crabcode reads the OpenCode-compatible `permission` field. Rules resolve to `allow`, `ask`, or `deny`, with later matching rules taking precedence. diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index c631c31..9b7eba2 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -477,3 +477,4 @@ I think this is how the TUI works already anyway right? - [x] I wanna be able to type `/compact|` (imagine "|" is my cursor) and press `ctrl-t` or `ctrl-x m`.. Right now doing those kinda make me stay in the focus of the autosuggestions popover, so I think it's an event handling thing, but it's such an often thing that happens that I wanna make a special case for it. - [x] I wanna make it scrollable even when doing ctrl-f find, with my mouse +- [ ] "providers" config, does it work diff --git a/crabcode.schema.json b/crabcode.schema.json index 56c5b84..2836db9 100644 --- a/crabcode.schema.json +++ b/crabcode.schema.json @@ -382,6 +382,21 @@ "null" ] }, + "tui": { + "description": "Crabcode terminal UI settings. compactMode takes precedence over the saved /compact-mode preference.", + "type": "object", + "additionalProperties": false, + "properties": { + "compactMode": { + "description": "Enable sticky headers for compact mode. Alias: compact_mode.", + "type": "boolean" + }, + "compact_mode": { + "description": "Alias for compactMode.", + "type": "boolean" + } + } + }, "tools": true, "websearch": { "anyOf": [ diff --git a/src/app.rs b/src/app.rs index d55e94b..12c3924 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1160,7 +1160,18 @@ impl App { .unwrap_or_else(theme::Theme::load_builtin_default); let colors = theme_for_colors.get_colors(true); - let chat_state = init_chat(chat, &agent, &colors); + let configured_compact_mode = loaded_config.merged_config.tui_compact_mode; + let persisted_compact_mode = if configured_compact_mode.is_none() { + prefs_dao + .as_ref() + .and_then(|dao| dao.get_compact_mode().ok().flatten()) + } else { + None + }; + let compact_mode = configured_compact_mode + .or(persisted_compact_mode) + .unwrap_or(true); + let chat_state = init_chat(chat, &agent, &colors, compact_mode); let session_rename_dialog_state = init_session_rename_dialog(colors); let runtime = crate::config::ConfigRuntime::from_merged( &loaded_config.merged_config, @@ -6450,6 +6461,11 @@ impl App { } if parsed.name == "compact-mode" && self.base_focus == BaseFocus::Chat { self.chat_state.compact_mode = !self.chat_state.compact_mode; + if let Some(dao) = &self.prefs_dao { + if let Err(error) = dao.set_compact_mode(self.chat_state.compact_mode) { + eprintln!("Failed to persist compact mode preference: {error}"); + } + } push_toast(Toast::new( if self.chat_state.compact_mode { "Compact mode enabled" @@ -11295,7 +11311,7 @@ mod tests { command_registry: registry, session_manager: SessionManager::new(), home_state: init_home(), - chat_state: init_chat(Chat::new(), "Build", &colors), + chat_state: init_chat(Chat::new(), "Build", &colors, true), suggestions_popup_state: init_suggestions_popup(Popup::new()), agents_dialog_state: init_agents_dialog("Select agent", vec![]), models_dialog_state: init_models_dialog("Models", vec![]), diff --git a/src/config/configuration.rs b/src/config/configuration.rs index b8dc244..615e1ab 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -457,6 +457,7 @@ pub enum ProviderTimeout { #[derive(Debug, Clone, Default)] pub struct MergedConfig { pub theme: Option, + pub tui_compact_mode: Option, pub model: Option, pub small_model: Option, pub default_agent: Option, @@ -1060,6 +1061,7 @@ fn crabcode_allowed_keys() -> BTreeSet<&'static str> { out.insert("notifications"); out.insert("images"); out.insert("websearch"); + out.insert("tui"); out } @@ -1314,6 +1316,12 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M } } + out.tui_compact_mode = obj + .get("tui") + .and_then(Value::as_object) + .and_then(|tui| tui.get("compactMode").or_else(|| tui.get("compact_mode"))) + .and_then(Value::as_bool); + if let Some(Value::String(model)) = obj.get("model") { if !model.trim().is_empty() { out.model = Some(model.trim().to_string()); @@ -2589,6 +2597,7 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { "notifications", "images", "websearch", + "tui", "instructions", "tools", "watcher", @@ -2671,6 +2680,24 @@ mod tests { ); } + #[test] + fn parses_tui_compact_mode_aliases() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ "tui": { "compactMode": false } }), + &mut diagnostics, + ); + + assert_eq!(config.tui_compact_mode, Some(false)); + + let config = parse_merged_config( + &json!({ "tui": { "compact_mode": true } }), + &mut diagnostics, + ); + + assert_eq!(config.tui_compact_mode, Some(true)); + } + #[test] fn parses_enabled_and_disabled_providers() { let mut diagnostics = ConfigDiagnostics::default(); diff --git a/src/persistence/prefs.rs b/src/persistence/prefs.rs index 63a8653..8276b78 100644 --- a/src/persistence/prefs.rs +++ b/src/persistence/prefs.rs @@ -8,6 +8,7 @@ use super::{ensure_data_dir, get_data_dir}; const MODEL_PREFS_KEY: &str = "model_preferences"; const ACTIVE_THEME_KEY: &str = "active_theme"; const TERMINAL_TITLE_ITEMS_KEY: &str = "terminal_title_items"; +const COMPACT_MODE_KEY: &str = "compact_mode"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelRef { @@ -204,6 +205,17 @@ impl PrefsDAO { self.set_pref(ACTIVE_THEME_KEY, theme_id.trim()) } + pub fn get_compact_mode(&self) -> Result> { + match self.get_pref(COMPACT_MODE_KEY)? { + Some(value) => Ok(serde_json::from_str(&value).ok()), + None => Ok(None), + } + } + + pub fn set_compact_mode(&self, enabled: bool) -> Result<()> { + self.set_pref(COMPACT_MODE_KEY, &enabled.to_string()) + } + pub fn get_terminal_title_items( &self, ) -> Result>> { @@ -375,4 +387,17 @@ mod tests { Some("tokyonight".to_string()) ); } + + #[test] + fn test_compact_mode_round_trip() { + let dao = setup_test_dao(); + + assert_eq!(dao.get_compact_mode().unwrap(), None); + + dao.set_compact_mode(false).unwrap(); + assert_eq!(dao.get_compact_mode().unwrap(), Some(false)); + + dao.set_compact_mode(true).unwrap(); + assert_eq!(dao.get_compact_mode().unwrap(), Some(true)); + } } diff --git a/src/views/chat.rs b/src/views/chat.rs index c87efac..f6aa4d4 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -103,11 +103,11 @@ pub struct SubagentTabs { } impl ChatState { - pub fn new(chat: Chat, agent_color: ratatui::style::Color) -> Self { + pub fn new(chat: Chat, agent_color: ratatui::style::Color, compact_mode: bool) -> Self { Self { chat, wave_spinner: WaveSpinner::with_speed(agent_color, 40), - compact_mode: true, + compact_mode, sticky_message_index: None, last_chat_area: None, sticky_click_target: None, @@ -115,9 +115,9 @@ impl ChatState { } } -pub fn init_chat(chat: Chat, agent: &str, colors: &ThemeColors) -> ChatState { +pub fn init_chat(chat: Chat, agent: &str, colors: &ThemeColors, compact_mode: bool) -> ChatState { let agent_color = crate::theme::agent_color(agent, colors); - ChatState::new(chat, agent_color) + ChatState::new(chat, agent_color, compact_mode) } pub fn agent_color_for_tab(agent_index: usize, colors: &ThemeColors) -> ratatui::style::Color { From 5198daa2be93e9c88103cdf4cbbb66f2bafe02cb Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 14 Aug 2026 00:32:25 +0800 Subject: [PATCH 10/10] feat(chat): persist compact mode and simplify sticky overlay rendering - persist compact-mode toggle to prefs storage so the setting survives restarts - remove separate sticky fade state (`faded_message_index`) and derive sticky behavior from current render state - refresh chat render cache before sticky layout math and keep `content_height` aligned to avoid stale overlay geometry - align sticky overlay dimensions/content width with normal transcript rendering and adjust related tests for updated signatures/scroll state --- src/app.rs | 6 ++++- src/ui/components/chat.rs | 34 +++---------------------- src/ui/components/input.rs | 22 ++++++++-------- src/views/chat.rs | 52 +++++++++++++++++++++++++------------- 4 files changed, 55 insertions(+), 59 deletions(-) diff --git a/src/app.rs b/src/app.rs index 12c3924..5092a99 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4957,7 +4957,6 @@ impl App { // 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; @@ -6219,6 +6218,11 @@ impl App { } if parsed.name == "compact-mode" && self.base_focus == BaseFocus::Chat { self.chat_state.compact_mode = !self.chat_state.compact_mode; + if let Some(dao) = &self.prefs_dao { + if let Err(error) = dao.set_compact_mode(self.chat_state.compact_mode) { + eprintln!("Failed to persist compact mode preference: {error}"); + } + } push_toast(Toast::new( if self.chat_state.compact_mode { "Compact mode enabled" diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 496a400..90ab481 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -319,8 +319,6 @@ 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. @@ -1718,7 +1716,6 @@ 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, @@ -1790,7 +1787,6 @@ 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, @@ -3844,6 +3840,10 @@ impl Chat { *line = sanitize_styled_line(line); } + // Keep content_height in sync so callers (e.g. sticky overlay) can + // resolve last-message end lines without waiting for Chat::render. + self.content_height = self.cached_lines.len(); + self.cached_revision = self.render_revision; self.cached_width = max_width; self.cached_colors_hash = colors_hash; @@ -4031,32 +4031,6 @@ 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, diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 223a971..05cecff 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -2523,7 +2523,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -2546,7 +2546,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -2578,7 +2578,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -2624,7 +2624,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -2788,7 +2788,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -2822,7 +2822,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -2851,7 +2851,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -3047,7 +3047,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -3087,7 +3087,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -3113,7 +3113,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); @@ -3304,7 +3304,7 @@ mod tests { "provider", None, &colors, - false, + true, ); }) .unwrap(); diff --git a/src/views/chat.rs b/src/views/chat.rs index f6aa4d4..f177bd0 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -211,6 +211,14 @@ pub fn render_chat( // Fixed layout first so chat_area is independent of sticky overlay height. let (header_area, chat_area) = compact_transcript_layout(above_status_chunks[1]); + // Match Chat::render content width (area.width - 2 scrollbar gutter). + // Refresh layout cache before sticky math so show/hide uses this frame's + // positions, not the previous frame's (stale after resize / new messages). + let content_max_width = (chat_area.width.saturating_sub(2) as usize).max(1); + chat_state + .chat + .ensure_render_cache(content_max_width, &model, colors); + 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; @@ -260,11 +268,6 @@ pub fn render_chat( // 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); @@ -273,10 +276,9 @@ pub fn render_chat( 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 { + // Compact header: title on middle row (accent + bold). Skip empty titles + // but keep the fixed 3-row slot so layout does not jump. + if let Some(title) = session_title.filter(|t| !t.is_empty()) { let header_inner = Layout::default() .direction(Direction::Vertical) .constraints( @@ -288,7 +290,6 @@ pub fn render_chat( .as_ref(), ) .split(header_area); - // Title line (accent + bold, no background) f.render_widget( Paragraph::new(title).style( Style::default() @@ -310,7 +311,6 @@ pub fn render_chat( } 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) @@ -324,7 +324,15 @@ pub fn render_chat( 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; + // Content panel matches Chat content area (full width minus scrollbar gutter). + let content_width = sticky_rect.width.saturating_sub(2); + let content_rect = Rect { + x: sticky_rect.x, + y: sticky_rect.y, + width: content_width, + height: sticky_rect.height, + }; + let max_width = content_width as usize; let sticky_height = sticky_rect.height; let sticky_msg = chat_state.chat.messages.get(idx); @@ -372,8 +380,8 @@ pub fn render_chat( 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. + // Content rows: same wrap width as the live user bubble so sticky text + // matches the faded-out original (image placeholders, agent mentions). let content_lines = chat_state .chat .format_user_message_content_lines(idx, max_width, colors); @@ -402,9 +410,10 @@ pub fn render_chat( // 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 only the content strip so the scrollbar gutter stays free. paint_sticky_overlay( f.buffer_mut(), - sticky_rect, + content_rect, sticky_lines, colors.background_element, ); @@ -2036,6 +2045,10 @@ mod tests { .chat .add_assistant_message("assistant reply\n".repeat(40)); chat_state.chat.add_user_message("later user"); + // Pin to top after adds (add_* sets scroll_offset = MAX while autoscroll is on). + chat_state.chat.autoscroll_enabled = false; + chat_state.chat.scroll_offset = 0; + chat_state.chat.scroll_up(0); // marks user_scrolled_up so pin-to-bottom stays off let mut input = Input::new(); let mut find_bar = FindBar::new(); @@ -2066,6 +2079,7 @@ mod tests { None, &[], &mut find_bar, + true, Some("Session"), ); }) @@ -2090,6 +2104,7 @@ mod tests { user_message_body_end(end) }; chat_state.chat.scroll_offset = first_user_body_end; + chat_state.chat.scroll_up(0); terminal .draw(|f| { @@ -2113,6 +2128,7 @@ mod tests { None, &[], &mut find_bar, + true, Some("Session"), ); }) @@ -2138,7 +2154,6 @@ mod tests { 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] @@ -2153,9 +2168,12 @@ mod tests { sticky_click_target: None, last_chat_area: None, }; + chat_state.chat.autoscroll_enabled = false; chat_state .chat .add_user_message("only message still in view"); + chat_state.chat.scroll_offset = 0; + chat_state.chat.scroll_up(0); let mut input = Input::new(); let mut find_bar = FindBar::new(); @@ -2185,6 +2203,7 @@ mod tests { None, &[], &mut find_bar, + true, Some("Session"), ); }) @@ -2197,7 +2216,6 @@ mod tests { 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()); }