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:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user