From 0aa34bed649f11c809f001ceb9fe92d7e69c3515 Mon Sep 17 00:00:00 2001 From: lixueyi Date: Mon, 3 Aug 2026 14:49:51 +0800 Subject: [PATCH] refactor(chat ui): optimize thinking block expand/collapse logic and interaction UX Refactor the thinking_disclosures comments and logic to support global expand/collapse modes. Ensure that mouse hover tracking and click event handling are only active when the thinking mode is set to Hide. Preserve the state of manually expanded blocks when toggling the global thinking display mode. Add test cases to verify that manually expanded states persist across back-and-forth mode switches. --- src/apps/cli/src/ui/chat/mouse.rs | 24 +++++-- src/apps/cli/src/ui/chat/render.rs | 107 +++++++++++++++++++++++++---- src/apps/cli/src/ui/chat/state.rs | 19 ++++- 3 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/apps/cli/src/ui/chat/mouse.rs b/src/apps/cli/src/ui/chat/mouse.rs index b2f445a74..ced62bdb1 100644 --- a/src/apps/cli/src/ui/chat/mouse.rs +++ b/src/apps/cli/src/ui/chat/mouse.rs @@ -329,10 +329,14 @@ impl ChatView { let list_offset = *self.list_state.offset_mut(); let absolute_row = list_offset + relative_row; - for (block_id, y_start, y_end) in &self.thinking_regions { - if absolute_row >= *y_start as usize && absolute_row <= *y_end as usize { - self.hovered_thinking_block_id = Some(block_id.clone()); - return; + // Hover is only tracked while thinking is interactive (Hide mode); + // in fully-expanded (Show) mode blocks are non-interactive. + if self.presentation.thinking == crate::config::ThinkingMode::Hide { + for (block_id, y_start, y_end) in &self.thinking_regions { + if absolute_row >= *y_start as usize && absolute_row <= *y_end as usize { + self.hovered_thinking_block_id = Some(block_id.clone()); + return; + } } } @@ -360,9 +364,15 @@ impl ChatView { // Check against thinking regions (header line) for (block_id, y_start, y_end) in &self.thinking_regions { if absolute_row >= *y_start as usize && absolute_row <= *y_end as usize { - let block_id = block_id.clone(); - self.thinking_disclosures.toggle(&block_id); - self.invalidate_render_cache(); + // Per-block expand only applies while thinking defaults to + // collapsed; in fully-expanded (Show) mode `/thinking` is the + // sole toggle and clicks are a no-op so manual-expand state + // is preserved across toggles instead of being polluted. + if self.presentation.thinking == crate::config::ThinkingMode::Hide { + let block_id = block_id.clone(); + self.thinking_disclosures.toggle(&block_id); + self.invalidate_render_cache(); + } self.hovered_thinking_block_id = None; return; } diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index 95457ae67..304425293 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -330,15 +330,20 @@ impl ChatView { } } - // Apply hover styling (without invalidating per-message render caches) - if let Some(ref hovered_id) = self.hovered_thinking_block_id { - for (block_id, y_start, y_end) in &self.thinking_regions { - if block_id == hovered_id && y_start == y_end { - let idx = *y_start as usize; - if idx < messages.len() { - messages[idx] = messages[idx] - .clone() - .style(Style::default().bg(self.theme.block_bg_hover)); + // Apply hover styling (without invalidating per-message render + // caches). Hover is only shown while thinking is interactive + // (Hide mode); in fully-expanded (Show) mode blocks are + // non-interactive so no hover background is applied. + if self.presentation.thinking == crate::config::ThinkingMode::Hide { + if let Some(ref hovered_id) = self.hovered_thinking_block_id { + for (block_id, y_start, y_end) in &self.thinking_regions { + if block_id == hovered_id && y_start == y_end { + let idx = *y_start as usize; + if idx < messages.len() { + messages[idx] = messages[idx] + .clone() + .style(Style::default().bg(self.theme.block_bg_hover)); + } } } } @@ -630,15 +635,39 @@ impl ChatView { let trimmed = content.trim_end(); let clean_content = trimmed.trim_end_matches("").trim_end(); - let collapsed = self.thinking_disclosures.is_collapsed( - &thinking_block_id, - self.presentation.thinking == crate::config::ThinkingMode::Hide, - ); - let caret = if collapsed { "\u{25b8}" } else { "\u{25be}" }; // ▸ / ▾ + // `/thinking` toggles between fully-collapsed and + // fully-expanded. Manual per-block expands are only + // meaningful in collapsed (Hide) mode; in Show mode + // every block is expanded regardless of overrides, so + // manually-expanded blocks survive a full toggle + // round-trip instead of being inverted by XOR. + let collapsed = match self.presentation.thinking { + crate::config::ThinkingMode::Show => false, + crate::config::ThinkingMode::Hide => { + self.thinking_disclosures + .is_collapsed(&thinking_block_id, true) + } + }; + // In Show mode the header is non-interactive: hide the + // caret so users don't expect to click-collapse an + // already fully-expanded block. + let caret = if self.presentation.thinking + == crate::config::ThinkingMode::Show + { + "" + } else if collapsed { + "\u{25b8}" + } else { + "\u{25be}" + }; let header_y = items.len().min(u16::MAX as usize) as u16; thinking_regions.push((thinking_block_id.clone(), header_y, header_y)); - let left_label = format!("{} Thinking", caret); + let left_label = if caret.is_empty() { + "Thinking".to_string() + } else { + format!("{} Thinking", caret) + }; if collapsed { let hint = "click to expand"; let indent = " "; @@ -1160,6 +1189,54 @@ mod shortcut_contract_tests { assert!(!plain.contains("private streaming reasoning"), "{plain}"); } + #[test] + fn thinking_toggle_keeps_manual_expand_across_round_trip() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + // Two thinking blocks in one message: block 0 will be manually + // expanded, block 1 stays collapsed by default. + let message = ChatMessage { + id: "assistant-1".to_string(), + turn_id: Some("turn-1".to_string()), + role: MessageRole::Assistant, + timestamp: std::time::SystemTime::now(), + flow_items: vec![ + FlowItem::Thinking { + content: "reasoning-alpha".to_string(), + }, + FlowItem::Thinking { + content: "reasoning-beta".to_string(), + }, + ], + is_streaming: false, + version: 1, + }; + + // Default Hide: both collapsed (header only). + let plain = view.render_message(&message, 80).plain_lines.join("\n"); + assert!(plain.contains("click to expand"), "{plain}"); + assert!(!plain.contains("reasoning-alpha"), "{plain}"); + assert!(!plain.contains("reasoning-beta"), "{plain}"); + + // Manually expand block 0; block 1 stays collapsed. + view.toggle_thinking_block_for_test("assistant-1", 0); + let plain = view.render_message(&message, 80).plain_lines.join("\n"); + assert!(plain.contains("reasoning-alpha"), "{plain}"); + assert!(!plain.contains("reasoning-beta"), "{plain}"); + + // `/thinking` → fully expanded: both blocks visible (spec 1). + view.toggle_thinking(); + let plain = view.render_message(&message, 80).plain_lines.join("\n"); + assert!(plain.contains("reasoning-alpha"), "{plain}"); + assert!(plain.contains("reasoning-beta"), "{plain}"); + + // `/thinking` back → Hide default: manual expand persists for block 0 + // while block 1 collapses again (spec 2). + view.toggle_thinking(); + let plain = view.render_message(&message, 80).plain_lines.join("\n"); + assert!(plain.contains("reasoning-alpha"), "{plain}"); + assert!(!plain.contains("reasoning-beta"), "{plain}"); + } + #[test] fn timestamps_render_inside_user_messages_only_when_enabled() { let mut view = ChatView::new(Theme::dark(), Vec::new()); diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index cd55ef3a4..6a3f48d00 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -271,7 +271,10 @@ pub(crate) struct ChatView { focused_block_tool: Option, // -- Thinking expand/collapse state -- - /// Per-thinking-block overrides relative to the configured default. + /// Per-thinking-block manual-expand overrides. Only consulted while + /// thinking defaults to collapsed (Hide); in Show mode all blocks are + /// expanded regardless of these overrides so a `/thinking` round-trip + /// preserves manual expansions. thinking_disclosures: DisclosureOverrides, presentation: TranscriptPresentation, @@ -445,6 +448,20 @@ impl ChatView { self.presentation.thinking } + /// Simulate a mouse click that manually expands/collapses a thinking + /// block, identified by the owning message id and the block's index + /// within that message. Mirrors the id scheme used by `render_message`. + #[cfg(test)] + pub(crate) fn toggle_thinking_block_for_test( + &mut self, + message_id: &str, + block_index: usize, + ) { + let id = format!("{}::thinking:{}", message_id, block_index); + self.thinking_disclosures.toggle(&id); + self.invalidate_render_cache(); + } + #[cfg(test)] pub(crate) fn tool_details_visible(&self) -> bool { self.presentation.tool_details