Skip to content
Merged
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
24 changes: 17 additions & 7 deletions src/apps/cli/src/ui/chat/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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;
}
Expand Down
107 changes: 92 additions & 15 deletions src/apps/cli/src/ui/chat/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
}
Expand Down Expand Up @@ -630,15 +635,39 @@ impl ChatView {
let trimmed = content.trim_end();
let clean_content = trimmed.trim_end_matches("<thinking_end>").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 = " ";
Expand Down Expand Up @@ -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());
Expand Down
19 changes: 18 additions & 1 deletion src/apps/cli/src/ui/chat/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,10 @@ pub(crate) struct ChatView {
focused_block_tool: Option<String>,

// -- 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,

Expand Down Expand Up @@ -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
Expand Down