Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 112 additions & 18 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3067,12 +3067,37 @@ impl App {
}

fn current_chat_area(&self) -> Rect {
self.chat_area_for_size(self.last_frame_size)
// Prefer the last-rendered chat content rect (excludes compact chrome).
self.chat_state
.last_chat_area
.unwrap_or_else(|| self.chat_area_for_size(self.last_frame_size))
}

/// Region where a mouse wheel scrolls the chat. In compact mode this
/// extends above the chat content to include the 3-row header and 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 self.overlay_focus == OverlayFocus::None && self.base_focus == BaseFocus::Chat {
let chat_area = self.current_chat_area();
let chat_area = self.chat_scroll_region();
if chat_area.contains(Position::new(mouse.column, mouse.row))
&& self
.chat_state
Expand Down Expand Up @@ -4949,6 +4974,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()
Expand Down Expand Up @@ -6201,6 +6244,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);
Expand Down Expand Up @@ -6430,6 +6486,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;
Expand Down Expand Up @@ -8288,28 +8357,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;
}
Expand Down Expand Up @@ -10645,6 +10736,9 @@ impl App {
subagent_tabs,
&queued_messages,
&mut self.find_bar,
self.session_manager
.get_current_session()
.map(|s| s.title.as_str()),
);

if is_suggestions_visible(&self.suggestions_popup_state)
Expand Down
24 changes: 24 additions & 0 deletions src/command/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,22 @@ pub fn handle_compact<'a>(
})
}

pub fn handle_compact_mode<'a>(
parsed: &'a ParsedCommand,
_sm: &'a mut SessionManager,
) -> Pin<Box<dyn std::future::Future<Output = CommandResult> + 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,
Expand Down Expand Up @@ -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(),
Expand Down
102 changes: 98 additions & 4 deletions src/ui/components/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ pub struct Chat {
pending_click_anchor: Option<(usize, usize)>,
/// Index of the message highlighted by timeline navigation (None = no highlight)
pub highlighted_message_index: Option<usize>,
/// Index of the message whose viewport copy should be faded (sticky message).
pub faded_message_index: Option<usize>,
/// Deferred scroll-to-message index resolved during next render after positions are known.
pending_scroll_to_message: Option<usize>,
/// Match ranges for the active rendered-line chat find query.
Expand Down Expand Up @@ -1625,6 +1627,8 @@ impl Chat {
selection_edge_scroll: None,
pending_click_anchor: None,
highlighted_message_index: None,
faded_message_index: None,
pending_scroll_to_message: None,
search_matches: Vec::new(),
search_active_match: None,
search_query: String::new(),
Expand All @@ -1644,7 +1648,6 @@ impl Chat {
cached_has_active_tools: std::cell::Cell::new(false),
hovered_image: None,
hovered_hyperlink: None,
pending_scroll_to_message: None,
}
}

Expand Down Expand Up @@ -1692,6 +1695,8 @@ impl Chat {
selection_edge_scroll: None,
pending_click_anchor: None,
highlighted_message_index: None,
faded_message_index: None,
pending_scroll_to_message: None,
search_matches: Vec::new(),
search_active_match: None,
search_query: String::new(),
Expand All @@ -1711,7 +1716,6 @@ impl Chat {
cached_has_active_tools: std::cell::Cell::new(false),
hovered_image: None,
hovered_hyperlink: None,
pending_scroll_to_message: None,
}
}

Expand Down Expand Up @@ -2766,6 +2770,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<usize>) {
Expand Down Expand Up @@ -3610,6 +3618,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) {
Expand All @@ -3628,16 +3638,19 @@ 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;
}
}
}

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 {
Expand Down Expand Up @@ -3681,6 +3694,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,
Expand Down Expand Up @@ -4822,6 +4861,61 @@ impl Chat {
(lines, locations)
}

/// Format a user message's content into wrapped, styled lines, mirroring
/// `format_message`'s user branch exactly (image-placeholder colors,
/// `@agent` mention colors, wrap width, horizontal padding). Returns
/// content lines only — no border/padding rows. Used by the compact-mode
/// sticky message so it renders like a real user message.
pub fn format_user_message_content_lines(
&self,
idx: usize,
max_width: usize,
colors: &ThemeColors,
) -> Vec<Line<'static>> {
let Some(message) = self.messages.get(idx) else {
return Vec::new();
};
if message.role != MessageRole::User {
return Vec::new();
}

let max_width = max_width.max(1);
let bg = colors.background_element;
let text_style = Style::default().fg(colors.text).bg(bg);
let image_style = |placeholder: &str| {
let is_hovered = self.hovered_image.as_ref().is_some_and(|target| {
target.message_index == idx && target.placeholder == placeholder
});
if is_hovered {
Style::default().fg(colors.markdown_image_text).bg(bg)
} else {
Style::default().fg(colors.markdown_image).bg(bg)
}
};

let horizontal_padding = 2usize;
let right_padding = 2usize;
let wrap_width = max_width
.saturating_sub(1 + horizontal_padding + right_padding)
.max(1);

message
.content
.split('\n')
.flat_map(|content_line| {
let content_line = content_line.strip_suffix('\r').unwrap_or(content_line);
let styled_content = Line::from(style_agent_mentions_in_line(
content_line,
&self.agent_mention_names,
colors,
text_style,
&image_style,
));
wrap_styled_line(&styled_content, WrapOptions::new(wrap_width))
})
.collect::<Vec<_>>()
}

fn format_tool_row<'a>(
&'a self,
message: &'a Message,
Expand Down
Loading
Loading