feat: per-cell thinking fold/unfold via Space key

Previously, Space on a thinking cell hid it entirely from the transcript.
Now Space toggles between folded (summary preview) and unfolded (full
content) for thinking cells, while other cells retain the existing
hide/show behavior.

Changes:
- Add folded_thinking HashSet to App for per-cell fold tracking
- Add lines_with_options_folded / lines_with_copy_metadata_folded that
  accept an explicit folded flag, overriding the global verbose setting
- Update transcript cache to pass fold state during rendering
- Update Space key handler to toggle fold for thinking cells
- Update affordance text to mention Space for expanding folded thinking

Closes #2348
This commit is contained in:
Hu Qiantao
2026-05-31 12:03:18 +08:00
parent a83fa59594
commit 607ec155ec
6 changed files with 64 additions and 10 deletions
+5
View File
@@ -1495,6 +1495,10 @@ pub struct App {
/// Transcript cells the user has collapsed (hidden from view).
/// Stores **original** virtual cell indices (pre-filtering).
pub collapsed_cells: HashSet<usize>,
/// Thinking cells the user has folded (showing summary instead of full
/// content). Stores **original** virtual cell indices. Toggled by Space
/// when the composer is empty and the cursor is on a thinking cell.
pub folded_thinking: HashSet<usize>,
/// Mapping from filtered cell index → original virtual index.
/// Populated during `ChatWidget::new` by filtering out collapsed cells.
/// Used by `build_context_menu_entries` to convert line-meta indices
@@ -2033,6 +2037,7 @@ impl App {
last_pinned_prefix_hash: None,
cycle: CycleConfig::default(),
collapsed_cells: HashSet::new(),
folded_thinking: HashSet::new(),
collapsed_cell_map: Vec::new(),
edit_in_progress: false,
lsp_enabled: config.lsp.as_ref().and_then(|l| l.enabled).unwrap_or(true),
+27 -3
View File
@@ -249,6 +249,20 @@ impl HistoryCell {
&self,
width: u16,
options: TranscriptRenderOptions,
) -> Vec<Line<'static>> {
self.lines_with_options_folded(width, options, false)
}
/// Render with an explicit per-cell fold override for thinking cells.
///
/// When `folded` is `true`, the thinking content is collapsed to a
/// summary regardless of the `verbose` flag. This enables per-cell
/// folding independent of the global `/verbose` toggle.
pub fn lines_with_options_folded(
&self,
width: u16,
options: TranscriptRenderOptions,
folded: bool,
) -> Vec<Line<'static>> {
match self {
HistoryCell::Thinking { .. } if !options.show_thinking => Vec::new(),
@@ -261,7 +275,7 @@ impl HistoryCell {
width,
*streaming,
*duration_secs,
!options.verbose,
folded || !options.verbose,
options.low_motion,
),
HistoryCell::Tool(cell) if !options.show_tool_details => {
@@ -303,10 +317,20 @@ impl HistoryCell {
}
}
#[allow(dead_code)]
pub(crate) fn lines_with_copy_metadata(
&self,
width: u16,
options: TranscriptRenderOptions,
) -> Vec<RenderedTranscriptLine> {
self.lines_with_copy_metadata_folded(width, options, false)
}
pub(crate) fn lines_with_copy_metadata_folded(
&self,
width: u16,
options: TranscriptRenderOptions,
folded: bool,
) -> Vec<RenderedTranscriptLine> {
match self {
HistoryCell::User { content } => render_message_with_copy_metadata(
@@ -332,7 +356,7 @@ impl HistoryCell {
width,
)
}
_ => hard_break_copy_lines(self.lines_with_options(width, options)),
_ => hard_break_copy_lines(self.lines_with_options_folded(width, options, folded)),
}
}
@@ -2215,7 +2239,7 @@ fn render_thinking(
let label = if streaming {
"More reasoning in Ctrl+O"
} else {
"Full reasoning in Ctrl+O"
"Space to expand · Full reasoning in Ctrl+O"
};
lines.push(Line::from(vec![
Span::styled(REASONING_RAIL.to_string(), rail_style),
+8 -2
View File
@@ -16,6 +16,7 @@
//! Width or render-option changes still bust the entire cache (correct: wrap
//! layout depends on width and which cells are visible at all).
use std::collections::HashSet;
use std::sync::Arc;
use ratatui::{
@@ -122,19 +123,23 @@ impl TranscriptViewCache {
width: u16,
options: TranscriptRenderOptions,
) {
self.ensure_split(&[cells], cell_revisions, width, options);
self.ensure_split(&[cells], cell_revisions, width, options, &HashSet::new());
}
/// Ensure cached lines match the provided cell shards (logically
/// concatenated) plus per-cell revisions. Avoids the
/// `concat-into-Vec<HistoryCell>` clone the caller would otherwise pay
/// every frame on long transcripts.
///
/// `folded_cells` contains original virtual indices of thinking cells
/// that should render in their folded (summary) form.
pub fn ensure_split(
&mut self,
cell_shards: &[&[HistoryCell]],
cell_revisions: &[u64],
width: u16,
options: TranscriptRenderOptions,
folded_cells: &HashSet<usize>,
) {
let total_cells: usize = cell_shards.iter().map(|s| s.len()).sum();
@@ -190,7 +195,8 @@ impl TranscriptViewCache {
} else {
width
};
let rendered = cell.lines_with_copy_metadata(render_width, options);
let folded = folded_cells.contains(&idx);
let rendered = cell.lines_with_copy_metadata_folded(render_width, options, folded);
let mut lines = Vec::with_capacity(rendered.len());
let mut copy_separators = Vec::with_capacity(rendered.len());
let mut copy_prefix_widths = Vec::with_capacity(rendered.len());
+21 -5
View File
@@ -2946,18 +2946,34 @@ async fn run_event_loop(
{
continue;
}
// Space toggles collapse/expand of the focused thinking block
// when the composer is empty (#1972).
// Space toggles fold/unfold of the focused thinking block
// when the composer is empty. For thinking cells, toggles
// between summary and full content; for other cells, toggles
// visibility (#1972, #2348).
KeyCode::Char(' ')
if key.modifiers == KeyModifiers::NONE && app.input.is_empty() =>
{
if let Some(idx) = detail_target_cell_index(app) {
if app.collapsed_cells.contains(&idx) {
let is_thinking = app
.history
.get(idx)
.is_some_and(|c| matches!(c, HistoryCell::Thinking { .. }));
if is_thinking {
if app.folded_thinking.contains(&idx) {
app.folded_thinking.remove(&idx);
app.status_message =
Some("Thinking block expanded".to_string());
} else {
app.folded_thinking.insert(idx);
app.status_message =
Some("Thinking block folded".to_string());
}
} else if app.collapsed_cells.contains(&idx) {
app.collapsed_cells.remove(&idx);
app.status_message = Some("Thinking block expanded".to_string());
app.status_message = Some("Cell expanded".to_string());
} else {
app.collapsed_cells.insert(idx);
app.status_message = Some("Thinking block collapsed".to_string());
app.status_message = Some("Cell collapsed".to_string());
}
app.mark_history_updated();
app.needs_redraw = true;
+1
View File
@@ -3831,6 +3831,7 @@ fn open_tool_details_pager_supports_active_virtual_tool_cell() {
&[1],
100,
app.transcript_render_options(),
&app.folded_thinking,
);
app.viewport.last_transcript_top = 0;
app.viewport.last_transcript_visible = 4;
+2
View File
@@ -156,6 +156,7 @@ impl ChatWidget {
&cell_revisions,
content_area.width.max(1),
render_options,
&app.folded_thinking,
);
} else {
// Slow path: clone non-collapsed cells into filtered vecs so
@@ -203,6 +204,7 @@ impl ChatWidget {
&filtered_revs,
content_area.width.max(1),
render_options,
&app.folded_thinking,
);
}