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