fix(tui): make a thinking fold an absolute intent, not a relative bit

The stored fold bit was relative to the display preference: the renderer
read `folded ^ !(verbose || thinking_default_expanded)` and Space wrote
the same XOR back. Every already-recorded choice therefore flipped
meaning the moment a preference changed — turn `thinking_default_expanded`
on and a block the user had explicitly expanded closed itself (#5847).

Replace the bit with an explicit tri-state intent. `ThinkingFold`
(`Expanded` / `Collapsed`) says outright what the user chose; absence of
an entry means they have not touched the cell, so the preference baseline
decides its default. `App::folded_thinking: HashSet<usize>` becomes
`App::thinking_folds: HashMap<usize, ThinkingFold>` and every consumer
moves with it — the renderer (`lines_with_options_folded`,
`lines_with_copy_metadata_folded`), the transcript cache
(`TranscriptViewCache::{ensure, ensure_split, ensure_filtered,
ensure_iter}`, `render_cached_cell`), the Space handler
(`handle_transcript_space`), `ChatWidget`'s two render paths, and the
prune/clear paths. No relative fold bit remains.

Default behavior is unchanged: an untouched cell still follows
`verbose || thinking_default_expanded`, and Space still toggles.

Evidence (crates/tui lib test binary, focused filters):
- tui::ui::tests::explicit_reasoning_fold_survives_a_preference_flip
  fails without this fix — "an explicit expand must survive turning
  thinking_default_expanded on" — test result: FAILED. 0 passed; 1 failed
- with the fix: test result: ok. 1 passed; 0 failed
- tui::history::tests: test result: ok. 50 passed; 0 failed
- tui::transcript::tests: test result: ok. 39 passed; 0 failed
- tui::ui::tests: test result: ok. 781 passed; 0 failed
- cargo fmt --all -- --check: clean

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5 committed 2026-09-21 13:09:07 -07:00
1 parent 2fb6efa6a0
commit 2f47a370f4
11 files changed
+278 -153

No files matched your search

+14 -7
View File
@@ -36,7 +36,9 @@ use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult};
use crate::tools::todo::{SharedTodoList, TodoList, new_shared_todo_list};
use crate::tui::active_cell::ActiveCell;
use crate::tui::clipboard::{ClipboardContent, ClipboardHandler};
use crate::tui::history::{HistoryCell, TranscriptActionOwner, TranscriptRenderOptions};
use crate::tui::history::{
HistoryCell, ThinkingFold, TranscriptActionOwner, TranscriptRenderOptions,
};
use crate::tui::hotbar::HotbarActionRegistry;
use crate::tui::motion::MotionPolicy;
use crate::tui::paste_burst::{FlushResult, PasteBurst};
@@ -2481,10 +2483,15 @@ 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>,
/// Explicit expand/collapse intents the user has recorded for thinking
/// cells, keyed by **original** virtual cell index. Set by Space when the
/// composer is empty and the cursor is on a thinking cell.
///
/// An absent index means the user has not touched that cell, so the
/// display preferences decide it. A present index is absolute, so
/// changing `verbose` or `thinking_default_expanded` afterwards leaves
/// the user's own choice alone (#5847).
pub thinking_folds: HashMap<usize, ThinkingFold>,
/// 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
@@ -4408,7 +4415,7 @@ impl App {
.into_iter()
.filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
.collect();
self.folded_thinking.clear();
self.thinking_folds.clear();
self.expanded_tool_runs = std::mem::take(&mut self.expanded_tool_runs)
.into_iter()
.filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
@@ -4824,7 +4831,7 @@ impl App {
pub(crate) fn prune_transcript_index_state(&mut self, len: usize) {
self.transcript_identity_epoch = self.transcript_identity_epoch.wrapping_add(1);
self.collapsed_cells.retain(|idx| *idx < len);
self.folded_thinking.retain(|idx| *idx < len);
self.thinking_folds.retain(|idx, _| *idx < len);
self.expanded_tool_runs.retain(|idx| *idx < len);
self.collapsed_cell_map.clear();
}
+1 -1
View File
@@ -1140,7 +1140,7 @@ impl App {
prefix_drift_count: 0,
prefix_context_updates: 0,
collapsed_cells: HashSet::new(),
folded_thinking: HashSet::new(),
thinking_folds: HashMap::new(),
collapsed_cell_map: Vec::new(),
edit_in_progress: false,
lsp_enabled: config.lsp.as_ref().and_then(|l| l.enabled).unwrap_or(true),
+41 -15
View File
@@ -96,6 +96,28 @@ pub(crate) enum ReasoningAction {
Collapse,
}
/// A user's explicit decision about one thinking cell.
///
/// The absence of a `ThinkingFold` — `None` at a call site, no entry in
/// `App::thinking_folds` — means the user has not touched that cell, so the
/// display preferences (`verbose` or `thinking_default_expanded`) decide its
/// default. An explicit intent is *absolute*: it says expanded or collapsed
/// outright, never "the opposite of whatever the preference currently says".
/// That is what lets a choice outlive a later preference change (#5847).
///
/// Known limitation: the intent is per session and per virtual cell index.
/// It is not persisted across restarts, and destructive transcript edits drop
/// it along with the other per-index state (`prune_transcript_index_state`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThinkingFold {
/// The user expanded this cell; show the whole body whatever the
/// preference says.
Expanded,
/// The user collapsed this cell; show the preview whatever the
/// preference says.
Collapsed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TranscriptActionOwner {
pub cell_index: usize,
@@ -412,21 +434,22 @@ impl HistoryCell {
width: u16,
options: TranscriptRenderOptions,
) -> Vec<Line<'static>> {
self.lines_with_options_folded(width, options, false).0
self.lines_with_options_folded(width, options, None).0
}
/// Render with an explicit per-cell fold override for thinking cells.
/// Render with the user's explicit per-cell fold intent for thinking
/// cells.
///
/// Space toggles the collapsed state *relative* to the expanded
/// baseline, which is on when either the session is verbose or the
/// thinking default is expanded:
/// - baseline off (default): thinking is collapsed; Space unfolds it
/// - baseline on: thinking is expanded; Space folds it
/// `None` means the user has not touched this cell, so the expanded
/// baseline decides: on when the session is verbose or the thinking
/// default is expanded, off otherwise. `Some(..)` is the user's own
/// decision and outranks the baseline in both directions, so changing a
/// preference later never rewrites what they already chose (#5847).
pub fn lines_with_options_folded(
&self,
width: u16,
options: TranscriptRenderOptions,
folded: bool,
fold: Option<ThinkingFold>,
) -> (Vec<Line<'static>>, Option<ReasoningAction>) {
let mut reasoning_action = None;
let mut lines = match self {
@@ -446,7 +469,11 @@ impl HistoryCell {
streaming,
duration_secs,
} => {
let collapsed = folded ^ !(options.verbose || options.thinking_default_expanded);
let collapsed = match fold {
Some(ThinkingFold::Expanded) => false,
Some(ThinkingFold::Collapsed) => true,
None => !(options.verbose || options.thinking_default_expanded),
};
let (lines, expandable) = thinking::render_thinking_with_preview_limit(
content,
width,
@@ -562,19 +589,18 @@ impl HistoryCell {
width: u16,
options: TranscriptRenderOptions,
) -> Vec<RenderedTranscriptLine> {
self.lines_with_copy_metadata_folded(width, options, false)
.0
self.lines_with_copy_metadata_folded(width, options, None).0
}
pub(crate) fn lines_with_copy_metadata_folded(
&self,
width: u16,
options: TranscriptRenderOptions,
folded: bool,
fold: Option<ThinkingFold>,
) -> (Vec<RenderedTranscriptLine>, Option<ReasoningAction>) {
if matches!(self, HistoryCell::Thinking { .. }) {
let (lines, action) =
self.lines_with_options_folded(options.prose_width(width), options, folded);
self.lines_with_options_folded(options.prose_width(width), options, fold);
return (hard_break_copy_lines(lines), action);
}
let lines = match self {
@@ -609,7 +635,7 @@ impl HistoryCell {
)
}
HistoryCell::Tool(_) => self
.lines_with_options_folded(width, options, folded)
.lines_with_options_folded(width, options, fold)
.0
.into_iter()
.map(|line| {
@@ -623,7 +649,7 @@ impl HistoryCell {
})
.collect(),
HistoryCell::Thinking { .. } => unreachable!("reasoning handled above"),
_ => hard_break_copy_lines(self.lines_with_options_folded(width, options, folded).0),
_ => hard_break_copy_lines(self.lines_with_options_folded(width, options, fold).0),
};
(lines, None)
}
+35 -26
View File
@@ -25,10 +25,10 @@ use super::constants::{
use super::thinking::cached_color_depth;
use super::{
ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanUpdateCell,
REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, RenderMode, ToolCell, ToolStatus,
TranscriptRenderOptions, WebSearchCell, assistant_label_style_for, extract_reasoning_summary,
render_spillover_annotation, render_thinking, render_thinking_with_analysis,
running_status_label_with_elapsed,
REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, RenderMode, ThinkingFold, ToolCell,
ToolStatus, TranscriptRenderOptions, WebSearchCell, assistant_label_style_for,
extract_reasoning_summary, render_spillover_annotation, render_thinking,
render_thinking_with_analysis, running_status_label_with_elapsed,
};
use crate::tools::plan::{PlanSnapshot, StepStatus};
use crate::tui::motion::MotionMode;
@@ -506,17 +506,17 @@ fn reasoning_folds_in_live_and_the_fold_is_reversible() {
low_motion: true,
..TranscriptRenderOptions::default()
};
// `folded` is the Space toggle *relative to* the configured default,
// so the expanded state is whichever call disagrees with it. Running
// both defaults proves the toggle survives the inversion.
// An explicit intent says expanded or collapsed outright, so the same
// two calls answer for either configured default. Running both proves
// the intent is not re-read through the preference.
let expanded = lines_text(
&cell
.lines_with_options_folded(80, options, !default_expanded)
.lines_with_options_folded(80, options, Some(ThinkingFold::Expanded))
.0,
);
let collapsed = lines_text(
&cell
.lines_with_options_folded(80, options, default_expanded)
.lines_with_options_folded(80, options, Some(ThinkingFold::Collapsed))
.0,
);
@@ -544,12 +544,14 @@ fn reasoning_folds_in_live_and_the_fold_is_reversible() {
}
}
/// The fold toggle is relative to the expanded baseline (verbose session or
/// expanded default): Space inverts the baseline, never the other flag.
/// In particular verbose plus an expanded default renders expanded — the
/// old triple-XOR collapsed exactly that cell.
/// The preference baseline (verbose session or expanded default) decides only
/// the cells nobody has touched; an explicit intent decides its own cell in
/// both directions. Verbose plus an expanded default renders expanded — the
/// old triple-XOR collapsed exactly that cell — and an explicit expand stays
/// expanded under every preference combination, which the later relative bit
/// still got wrong (#5847).
#[test]
fn thinking_fold_toggle_is_relative_to_the_expanded_baseline() {
fn explicit_thinking_fold_outranks_every_preference_baseline() {
let body = (1..=20)
.map(|i| format!("step {i:02}: baseline check"))
.collect::<Vec<_>>()
@@ -559,16 +561,23 @@ fn thinking_fold_toggle_is_relative_to_the_expanded_baseline() {
streaming: false,
duration_secs: Some(1.0),
};
// (folded, verbose, default_expanded, expect_expanded)
for (folded, verbose, default_expanded, expect_expanded) in [
(false, false, false, false),
(false, false, true, true),
(false, true, false, true),
(false, true, true, true),
(true, false, false, true),
(true, false, true, false),
(true, true, false, false),
(true, true, true, false),
// (fold, verbose, default_expanded, expect_expanded)
for (fold, verbose, default_expanded, expect_expanded) in [
// No explicit intent: the preference baseline decides.
(None, false, false, false),
(None, false, true, true),
(None, true, false, true),
(None, true, true, true),
// An explicit expand renders expanded whatever the preferences say.
(Some(ThinkingFold::Expanded), false, false, true),
(Some(ThinkingFold::Expanded), false, true, true),
(Some(ThinkingFold::Expanded), true, false, true),
(Some(ThinkingFold::Expanded), true, true, true),
// And an explicit collapse renders collapsed whatever they say.
(Some(ThinkingFold::Collapsed), false, false, false),
(Some(ThinkingFold::Collapsed), false, true, false),
(Some(ThinkingFold::Collapsed), true, false, false),
(Some(ThinkingFold::Collapsed), true, true, false),
] {
let options = TranscriptRenderOptions {
verbose,
@@ -576,11 +585,11 @@ fn thinking_fold_toggle_is_relative_to_the_expanded_baseline() {
low_motion: true,
..TranscriptRenderOptions::default()
};
let text = lines_text(&cell.lines_with_options_folded(80, options, folded).0);
let text = lines_text(&cell.lines_with_options_folded(80, options, fold).0);
let expanded = text.contains("step 20: baseline check");
assert_eq!(
expanded, expect_expanded,
"[folded={folded} verbose={verbose} default_expanded={default_expanded}]"
"[fold={fold:?} verbose={verbose} default_expanded={default_expanded}]"
);
}
}
+21 -20
View File
@@ -9,7 +9,7 @@
//! changed cells rather than history; width/option changes still bust all
//! cells because wrapping and visibility depend on them.
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::Arc;
use ratatui::{
@@ -19,7 +19,7 @@ use ratatui::{
use crate::tui::app::TranscriptSpacing;
use crate::tui::history::{
HistoryCell, ReasoningAction, ReasoningActionTarget, TranscriptActionOwner,
HistoryCell, ReasoningAction, ReasoningActionTarget, ThinkingFold, TranscriptActionOwner,
TranscriptRenderOptions,
};
use crate::tui::scrolling::TranscriptLineMeta;
@@ -117,8 +117,9 @@ enum TranscriptBoundary {
pub struct TranscriptViewCache {
width: u16,
options: TranscriptRenderOptions,
/// Fold state affects rendering without changing cell revisions.
folded_cells: HashSet<usize>,
/// Explicit per-cell fold intent affects rendering without changing cell
/// revisions. Keyed by original virtual cell index.
thinking_folds: HashMap<usize, ThinkingFold>,
/// Index of the newest durable Work receipt (checklist / plan snapshot)
/// in the last pass. When a new one lands the previous newest must
/// re-render collapsed, and its revision alone would not say so.
@@ -152,7 +153,7 @@ impl TranscriptViewCache {
Self {
width: 0,
options: TranscriptRenderOptions::default(),
folded_cells: HashSet::new(),
thinking_folds: HashMap::new(),
newest_work_receipt: None,
newest_user_turn: None,
reasoning_action_target: None,
@@ -207,7 +208,7 @@ impl TranscriptViewCache {
cell_revisions,
width,
options,
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -223,7 +224,7 @@ impl TranscriptViewCache {
cell_revisions: &[u64],
width: u16,
options: TranscriptRenderOptions,
folded_cells: &HashSet<usize>,
thinking_folds: &HashMap<usize, ThinkingFold>,
original_index_map: Option<&[usize]>,
action_owner: Option<TranscriptActionOwner>,
) {
@@ -234,7 +235,7 @@ impl TranscriptViewCache {
cell_revisions,
width,
options,
folded_cells,
thinking_folds,
original_index_map,
action_owner,
);
@@ -251,7 +252,7 @@ impl TranscriptViewCache {
cell_revisions: &[u64],
width: u16,
options: TranscriptRenderOptions,
folded_cells: &HashSet<usize>,
thinking_folds: &HashMap<usize, ThinkingFold>,
original_index_map: Option<&[usize]>,
action_owner: Option<TranscriptActionOwner>,
) {
@@ -261,7 +262,7 @@ impl TranscriptViewCache {
cell_revisions,
width,
options,
folded_cells,
thinking_folds,
original_index_map,
action_owner,
);
@@ -275,7 +276,7 @@ impl TranscriptViewCache {
cell_revisions: &[u64],
width: u16,
options: TranscriptRenderOptions,
folded_cells: &HashSet<usize>,
thinking_folds: &HashMap<usize, ThinkingFold>,
original_index_map: Option<&[usize]>,
action_owner: Option<TranscriptActionOwner>,
) {
@@ -288,7 +289,7 @@ impl TranscriptViewCache {
}
self.transcript_action_owner = action_owner;
let layout_changed = self.width != width || self.options != options || identity_changed;
let folded_changed = self.folded_cells != *folded_cells;
let folded_changed = self.thinking_folds != *thinking_folds;
// `todo_write` replaces the whole list on every call, so only the
// newest snapshot is worth a full card (#5871). When a new one lands
// the previous newest must re-render collapsed; its own revision has
@@ -322,7 +323,7 @@ impl TranscriptViewCache {
}
self.width = width;
self.options = options;
self.folded_cells = folded_cells.clone();
self.thinking_folds = thinking_folds.clone();
let previous_rendered_target = self.reasoning_action_rendered_cell;
// Same-index revision reuse is intentional: insert/remove shifts must
@@ -370,9 +371,9 @@ impl TranscriptViewCache {
} else {
width
};
let folded = folded_cells.contains(&original_idx);
let fold = thinking_folds.get(&original_idx).copied();
if is_layout_aware_preview && matches!(cell, HistoryCell::Thinking { .. }) {
newest_reasoning = Some((idx, cell, current_rev, folded));
newest_reasoning = Some((idx, cell, current_rev, fold));
}
if !layout_changed
&& is_layout_aware_preview == was_layout_aware_preview
@@ -483,7 +484,7 @@ impl TranscriptViewCache {
current_rev,
width,
cell_options,
folded,
fold,
));
idx += 1;
}
@@ -532,13 +533,13 @@ impl TranscriptViewCache {
return;
};
let free_rows = viewport_lines.saturating_sub(self.total_lines());
let Some((idx, cell, current_rev, folded)) = newest_reasoning.filter(|_| free_rows > 0)
let Some((idx, cell, current_rev, fold)) = newest_reasoning.filter(|_| free_rows > 0)
else {
return;
};
let mut expanded_options = options;
expanded_options.reasoning_preview_extra_lines = free_rows;
let expanded = render_cached_cell(cell, current_rev, width, expanded_options, folded);
let expanded = render_cached_cell(cell, current_rev, width, expanded_options, fold);
if expanded.lines == self.per_cell[idx].lines {
return;
}
@@ -792,7 +793,7 @@ fn render_cached_cell(
revision: u64,
width: u16,
options: TranscriptRenderOptions,
folded: bool,
fold: Option<ThinkingFold>,
) -> CachedCell {
let is_tool_groupable = matches!(cell, HistoryCell::Tool(_));
let render_width = if is_tool_groupable {
@@ -801,7 +802,7 @@ fn render_cached_cell(
width
};
let (rendered, reasoning_action) =
cell.lines_with_copy_metadata_folded(render_width, options, folded);
cell.lines_with_copy_metadata_folded(render_width, options, fold);
let mut lines = Vec::with_capacity(rendered.len());
let mut links = Vec::with_capacity(rendered.len());
let mut copy_separators = Vec::with_capacity(rendered.len());
+28 -28
View File
@@ -2,7 +2,7 @@ use super::*;
use crate::tools::plan::PlanSnapshot;
use crate::tui::history::{
ExecCell, ExecSource, HistoryCell, PlanUpdateCell, ReasoningAction, ReasoningActionTarget,
ToolCell, ToolStatus, TranscriptActionOwner,
ThinkingFold, ToolCell, ToolStatus, TranscriptActionOwner,
};
use codewhale_localization::Locale;
use codewhale_palette as palette;
@@ -937,7 +937,7 @@ fn hidden_reasoning_cache_never_advertises_or_leaks_content() {
show_thinking: false,
..TranscriptRenderOptions::default()
},
&HashSet::new(),
&HashMap::new(),
None,
Some(reasoning_owner(0)),
);
@@ -1214,7 +1214,7 @@ fn ensure_filtered_matches_ensure_split_output() {
&revisions,
40,
options,
&HashSet::new(),
&HashMap::new(),
Some(&index_map),
None,
);
@@ -1226,7 +1226,7 @@ fn ensure_filtered_matches_ensure_split_output() {
&revisions,
40,
options,
&HashSet::new(),
&HashMap::new(),
Some(&index_map),
None,
);
@@ -1254,7 +1254,7 @@ fn ensure_filtered_reuses_unchanged_cells() {
&revisions,
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -1265,7 +1265,7 @@ fn ensure_filtered_reuses_unchanged_cells() {
&revisions,
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -1284,7 +1284,7 @@ fn ensure_filtered_reuses_unchanged_cells() {
&revisions,
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -1332,7 +1332,7 @@ fn prose_cells_fill_full_width_on_ultrawide_by_default() {
};
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(&refs, &revisions, 220, options, &HashSet::new(), None, None);
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
for idx in 0..3 {
let width = max_line_width(&cache.per_cell[idx].lines);
@@ -1386,7 +1386,7 @@ fn transcript_prose_measure_caps_prose_but_not_tools() {
};
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(&refs, &revisions, 220, options, &HashSet::new(), None, None);
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
for idx in 0..3 {
let width = max_line_width(&cache.per_cell[idx].lines);
@@ -1515,7 +1515,7 @@ fn folded_thinking_cache_invalidation() {
&revisions,
width,
options,
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -1523,8 +1523,8 @@ fn folded_thinking_cache_invalidation() {
// Second render: fold the thinking cell → should invalidate and
// produce fewer lines (collapsed summary).
let mut folded = HashSet::new();
folded.insert(0usize);
let mut folded = HashMap::new();
folded.insert(0usize, ThinkingFold::Collapsed);
cache.ensure_split(&[&cells], &revisions, width, options, &folded, None, None);
let folded_line_count = cache.total_lines();
@@ -1539,7 +1539,7 @@ fn folded_thinking_cache_invalidation() {
&revisions,
width,
options,
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -1581,7 +1581,7 @@ fn folded_thinking_with_collapsed_cells_uses_original_indices() {
&revisions,
width,
options,
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -1594,8 +1594,8 @@ fn folded_thinking_with_collapsed_cells_uses_original_indices() {
let filtered_revs = [2u64];
let index_map: Vec<usize> = vec![1]; // filtered 0 → original 1
let mut folded = HashSet::new();
folded.insert(1usize); // fold original index 1
let mut folded = HashMap::new();
folded.insert(1usize, ThinkingFold::Collapsed); // fold original index 1
let mut cache2 = TranscriptViewCache::new();
cache2.ensure_split(
@@ -1619,7 +1619,7 @@ fn folded_thinking_with_collapsed_cells_uses_original_indices() {
&filtered_revs,
width,
options,
&HashSet::new(),
&HashMap::new(),
Some(&index_map),
None,
);
@@ -1652,7 +1652,7 @@ fn reasoning_target_transfer_rewrites_same_revision_cells() {
&revisions,
80,
options,
&HashSet::new(),
&HashMap::new(),
None,
Some(reasoning_owner(0)),
);
@@ -1730,7 +1730,7 @@ fn layout_aware_reasoning_budget_applies_only_to_the_newest_cell() {
&revisions,
80,
constrained,
&HashSet::new(),
&HashMap::new(),
None,
Some(reasoning_owner(1)),
);
@@ -1763,7 +1763,7 @@ fn layout_aware_reasoning_budget_applies_only_to_the_newest_cell() {
&revisions,
80,
roomy,
&HashSet::new(),
&HashMap::new(),
None,
Some(reasoning_owner(1)),
);
@@ -1789,7 +1789,7 @@ fn layout_aware_reasoning_budget_applies_only_to_the_newest_cell() {
&[1, 1, 1],
80,
roomy,
&HashSet::new(),
&HashMap::new(),
None,
Some(reasoning_owner(2)),
);
@@ -1814,7 +1814,7 @@ fn filtered_reasoning_owner_keeps_original_identity() {
&revisions,
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
Some(&original_map),
Some(reasoning_owner(1)),
);
@@ -1838,7 +1838,7 @@ fn filtered_reasoning_owner_keeps_original_identity() {
&revisions,
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
Some(&original_map),
Some(reasoning_owner(0)),
);
@@ -1855,7 +1855,7 @@ fn streaming_tail_fast_path_cannot_skip_reasoning_retarget() {
&[1, 1],
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
None,
Some(reasoning_owner(0)),
);
@@ -1867,7 +1867,7 @@ fn streaming_tail_fast_path_cannot_skip_reasoning_retarget() {
&[1, 2],
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -1885,7 +1885,7 @@ fn narrow_reasoning_hint_never_changes_cache_geometry() {
&[1],
width,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
None,
None,
);
@@ -1895,7 +1895,7 @@ fn narrow_reasoning_hint_never_changes_cache_geometry() {
&[1],
width,
TranscriptRenderOptions::default(),
&HashSet::new(),
&HashMap::new(),
None,
Some(reasoning_owner(0)),
);
@@ -1931,7 +1931,7 @@ fn reasoning_hint_uses_the_render_locale() {
&[1],
80,
options,
&HashSet::new(),
&HashMap::new(),
None,
Some(reasoning_owner(0)),
);
+2 -2
View File
@@ -150,8 +150,8 @@ use super::approval::{
ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
};
use super::history::{
ExecCell, HistoryCell, ReasoningAction, ToolCell, ToolStatus, history_cells_from_message,
summarize_tool_output,
ExecCell, HistoryCell, ReasoningAction, ThinkingFold, ToolCell, ToolStatus,
history_cells_from_message, summarize_tool_output,
};
use super::slash_menu::{
apply_slash_menu_selection, partial_inline_skill_mention_at_cursor,
+8 -7
View File
@@ -386,13 +386,14 @@ pub(super) fn handle_transcript_space(app: &mut App) -> bool {
if !app.show_thinking || !is_thinking {
return false;
}
let options = app.transcript_render_options();
let folded = !(options.verbose || options.thinking_default_expanded)
^ (target.action == ReasoningAction::Collapse);
app.folded_thinking.remove(&idx);
if folded {
app.folded_thinking.insert(idx);
}
// The rendered action names the state the user is asking for, so
// record that outright. A relative bit would be re-read as its
// opposite the next time a display preference changed (#5847).
let intent = match target.action {
ReasoningAction::Expand => ThinkingFold::Expanded,
ReasoningAction::Collapse => ThinkingFold::Collapsed,
};
app.thinking_folds.insert(idx, intent);
} else if app.toggle_tool_run_expansion_at(idx) {
return true;
} else if !app.collapsed_cells.remove(&idx) {
+122 -42
View File
@@ -6233,10 +6233,15 @@ fn selected_reasoning_hint_and_space_share_one_owner() {
"{copied}"
);
assert!(handle_transcript_space(&mut app));
assert!(app.folded_thinking.contains(&0));
assert_eq!(
app.thinking_folds.get(&0),
Some(&ThinkingFold::Expanded),
"Space on a collapsed cell records an explicit expand"
);
assert!(!handle_transcript_space(&mut app));
assert!(
app.folded_thinking.contains(&0),
assert_eq!(
app.thinking_folds.get(&0),
Some(&ThinkingFold::Expanded),
"rendered action is single-use"
);
@@ -6256,7 +6261,11 @@ fn selected_reasoning_hint_and_space_share_one_owner() {
);
assert!(handle_transcript_space(&mut app));
assert!(!app.folded_thinking.contains(&0));
assert_eq!(
app.thinking_folds.get(&0),
Some(&ThinkingFold::Collapsed),
"Space on an expanded cell records an explicit collapse"
);
let collapsed_again = render_underwater_test_app(&mut app, 100, 32);
assert!(collapsed_again.contains("Space:expand"));
}
@@ -6267,14 +6276,18 @@ fn selected_reasoning_actions_roundtrip_for_every_expansion_baseline() {
for verbose in [false, true] {
for default_expanded in [false, true] {
for folded in [false, true] {
for initial_fold in [
None,
Some(ThinkingFold::Expanded),
Some(ThinkingFold::Collapsed),
] {
let mut app = create_test_app();
app.verbose_transcript = verbose;
app.thinking_default_expanded = default_expanded;
app.thinking_preview_lines = 4;
app.history = vec![oversized_reasoning("baseline", false)];
if folded {
app.folded_thinking.insert(0);
if let Some(fold) = initial_fold {
app.thinking_folds.insert(0, fold);
}
app.resync_history_revisions();
// The adaptive preview fills spare viewport rows. Keep the
@@ -6282,7 +6295,11 @@ fn selected_reasoning_actions_roundtrip_for_every_expansion_baseline() {
let _ = render_underwater_test_app(&mut app, 100, 32);
select_original_cell(&mut app, 0);
let initially_expanded = (verbose || default_expanded) != folded;
let initially_expanded = match initial_fold {
Some(ThinkingFold::Expanded) => true,
Some(ThinkingFold::Collapsed) => false,
None => verbose || default_expanded,
};
for (step, expanded) in
[initially_expanded, !initially_expanded, initially_expanded]
.into_iter()
@@ -6300,7 +6317,7 @@ fn selected_reasoning_actions_roundtrip_for_every_expansion_baseline() {
assert_eq!(
rendered_full_body, expanded,
"verbose={verbose}, default_expanded={default_expanded}, \
folded={folded}, step={step}: {surface}"
initial_fold={initial_fold:?}, step={step}: {surface}"
);
let target = app
.viewport
@@ -6320,12 +6337,80 @@ fn selected_reasoning_actions_roundtrip_for_every_expansion_baseline() {
assert!(handle_transcript_space(&mut app));
}
}
assert_eq!(app.folded_thinking.contains(&0), folded);
// Two toggles land back on the state the cell started in —
// now recorded outright rather than inferred from a baseline.
assert_eq!(
app.thinking_folds.get(&0),
Some(&if initially_expanded {
ThinkingFold::Expanded
} else {
ThinkingFold::Collapsed
}),
);
}
}
}
}
/// An explicit expand or collapse is the user's own decision about *this*
/// cell, so it must outlive a later change to the display preference. The
/// preference only decides the default for cells nobody has touched. The
/// stored bit used to be relative to that preference, so flipping
/// `thinking_default_expanded` silently re-read every recorded choice as its
/// opposite (#5847).
#[test]
fn explicit_reasoning_fold_survives_a_preference_flip() {
fn body_rendered(app: &App) -> bool {
app.viewport
.transcript_cache
.lines()
.iter()
.any(|line| line.to_string().contains("pinned line 40"))
}
let mut app = create_test_app();
app.thinking_preview_lines = 4;
app.history = vec![oversized_reasoning("pinned", false)];
app.resync_history_revisions();
let _ = render_underwater_test_app(&mut app, 100, 32);
select_original_cell(&mut app, 0);
let _ = render_underwater_test_app(&mut app, 100, 32);
// Default preferences collapse reasoning, so Space is an explicit expand.
assert!(handle_transcript_space(&mut app));
let surface = render_underwater_test_app(&mut app, 100, 32);
assert!(
body_rendered(&app),
"an explicit expand must show the whole body: {surface}"
);
// Turning the default-expanded preference on must leave the explicitly
// expanded cell expanded, not flip it closed.
app.thinking_default_expanded = true;
let surface = render_underwater_test_app(&mut app, 100, 32);
assert!(
body_rendered(&app),
"an explicit expand must survive turning thinking_default_expanded on: {surface}"
);
// And the mirror case: an explicit collapse must survive the preference
// being turned back off.
select_original_cell(&mut app, 0);
let _ = render_underwater_test_app(&mut app, 100, 32);
assert!(handle_transcript_space(&mut app));
let surface = render_underwater_test_app(&mut app, 100, 32);
assert!(
!body_rendered(&app),
"Space on an expanded cell must collapse it: {surface}"
);
app.thinking_default_expanded = false;
let surface = render_underwater_test_app(&mut app, 100, 32);
assert!(
!body_rendered(&app),
"an explicit collapse must survive turning thinking_default_expanded off: {surface}"
);
}
#[test]
fn mouse_selection_redraws_and_retargets_reasoning_with_unchanged_revisions() {
let mut app = create_test_app();
@@ -6368,7 +6453,7 @@ fn mouse_selection_redraws_and_retargets_reasoning_with_unchanged_revisions() {
let _ = render_underwater_test_app(&mut app, 100, 32);
assert_eq!(reasoning_hint_cells(&app), vec![0]);
assert!(handle_transcript_space(&mut app));
assert!(app.folded_thinking.contains(&0));
assert_eq!(app.thinking_folds.get(&0), Some(&ThinkingFold::Expanded));
}
#[test]
@@ -6487,7 +6572,7 @@ fn advertised_reasoning_space_dispatches_after_first_char_paste_hold() {
let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
assert!(handle_plain_key_before_composer(&mut app, &space, now));
assert!(
app.folded_thinking.is_empty(),
app.thinking_folds.is_empty(),
"Space remains ambiguous until the first-character hold expires"
);
assert!(app.input.is_empty(), "Space must not enter the composer");
@@ -6499,8 +6584,9 @@ fn advertised_reasoning_space_dispatches_after_first_char_paste_hold() {
&mut app,
now + crate::tui::paste_burst::PasteBurst::recommended_flush_delay()
));
assert!(
app.folded_thinking.contains(&0),
assert_eq!(
app.thinking_folds.get(&0),
Some(&ThinkingFold::Expanded),
"a lone Space must dispatch the rendered transcript action after the hold"
);
assert!(
@@ -6540,7 +6626,7 @@ fn raw_paste_beginning_with_space_preserves_payload_over_reasoning_action() {
now + Duration::from_millis(1),
));
assert!(
app.folded_thinking.is_empty(),
app.thinking_folds.is_empty(),
"a leading-space raw paste must not trigger transcript actions"
);
assert!(flush_paste_burst_before_composer(
@@ -6624,7 +6710,7 @@ fn active_raw_paste_keeps_space_as_payload_over_reasoning_action() {
now + Duration::from_millis(1),
));
assert!(
app.folded_thinking.is_empty(),
app.thinking_folds.is_empty(),
"an in-flight raw paste must not trigger transcript actions"
);
assert!(app.flush_paste_burst_if_due(
@@ -6776,7 +6862,7 @@ fn active_streaming_reasoning_keeps_its_visible_owner_across_a_delta() {
assert_ne!(app.history_version, rendered_version);
assert_eq!(app.transcript_identity_epoch, rendered_epoch);
assert!(handle_transcript_space(&mut app));
assert!(app.folded_thinking.contains(&1));
assert_eq!(app.thinking_folds.get(&1), Some(&ThinkingFold::Expanded));
}
#[test]
@@ -6799,7 +6885,7 @@ fn interrupted_active_reasoning_remains_actionable_after_flush() {
assert!(app.active_cell.is_none());
assert_eq!(app.transcript_identity_epoch, epoch);
assert!(handle_transcript_space(&mut app));
assert!(app.folded_thinking.contains(&0));
assert_eq!(app.thinking_folds.get(&0), Some(&ThinkingFold::Expanded));
let _ = render_underwater_test_app(&mut app, 60, 16);
}
@@ -6851,7 +6937,7 @@ fn visible_older_reasoning_owns_space_over_a_newer_offscreen_tool() {
);
assert_eq!(detail_target_cell_index(&app), Some(1));
assert!(handle_transcript_space(&mut app));
assert!(app.folded_thinking.contains(&0));
assert_eq!(app.thinking_folds.get(&0), Some(&ThinkingFold::Expanded));
assert!(!app.collapsed_cells.contains(&1) && app.expanded_tool_runs.is_empty());
}
@@ -6867,7 +6953,7 @@ fn hidden_reasoning_is_unhidden_instead_of_folded() {
app.collapsed_cells.insert(0);
assert!(handle_transcript_space(&mut app));
assert!(!app.collapsed_cells.contains(&0));
assert!(!app.folded_thinking.contains(&0));
assert!(!app.thinking_folds.contains_key(&0));
app.collapsed_cells.insert(0);
let surface = render_underwater_test_app(&mut app, 60, 16);
@@ -6881,7 +6967,7 @@ fn hidden_reasoning_is_unhidden_instead_of_folded() {
assert!(handle_transcript_space(&mut app));
assert!(!app.collapsed_cells.contains(&0));
assert!(!app.folded_thinking.contains(&0));
assert!(!app.thinking_folds.contains_key(&0));
let visible = render_underwater_test_app(&mut app, 60, 16);
assert!(visible.contains("Space:expand"));
}
@@ -6897,11 +6983,11 @@ fn stale_rendered_reasoning_owner_cannot_toggle_replacement() {
app.push_history_cell(long_reasoning("replacement", false));
assert_ne!(app.transcript_identity_epoch, rendered_epoch);
assert!(!handle_transcript_space(&mut app));
assert!(app.folded_thinking.is_empty());
assert!(app.thinking_folds.is_empty());
let _ = render_underwater_test_app(&mut app, 80, 24);
assert!(handle_transcript_space(&mut app));
assert!(app.folded_thinking.contains(&0));
assert_eq!(app.thinking_folds.get(&0), Some(&ThinkingFold::Expanded));
}
#[test]
@@ -6916,16 +7002,13 @@ fn pop_and_truncate_prune_index_state_before_replacement() {
streaming: false,
});
let _ = render_underwater_test_app(&mut app, 60, 16);
for set in [
&mut app.collapsed_cells,
&mut app.folded_thinking,
&mut app.expanded_tool_runs,
] {
for set in [&mut app.collapsed_cells, &mut app.expanded_tool_runs] {
set.insert(1);
}
app.thinking_folds.insert(1, ThinkingFold::Expanded);
app.collapsed_cell_map = vec![0, 1];
app.pop_history();
assert!(app.collapsed_cells.is_empty() && app.folded_thinking.is_empty());
assert!(app.collapsed_cells.is_empty() && app.thinking_folds.is_empty());
assert!(app.expanded_tool_runs.is_empty() && app.collapsed_cell_map.is_empty());
app.push_history_cell(HistoryCell::Assistant {
content: "after pop".into(),
@@ -6935,20 +7018,17 @@ fn pop_and_truncate_prune_index_state_before_replacement() {
let first = render_underwater_test_app(&mut app, 60, 16);
assert!(first.contains("after pop") && !first.contains("old tail"));
for set in [
&mut app.collapsed_cells,
&mut app.folded_thinking,
&mut app.expanded_tool_runs,
] {
for set in [&mut app.collapsed_cells, &mut app.expanded_tool_runs] {
set.insert(1);
}
app.thinking_folds.insert(1, ThinkingFold::Expanded);
app.truncate_history_to(1);
app.push_history_cell(HistoryCell::Assistant {
content: "after truncate".into(),
streaming: false,
});
assert!(!handle_transcript_space(&mut app));
assert!(app.collapsed_cells.is_empty() && app.folded_thinking.is_empty());
assert!(app.collapsed_cells.is_empty() && app.thinking_folds.is_empty());
assert!(render_underwater_test_app(&mut app, 60, 16).contains("after truncate"));
}
@@ -6964,7 +7044,7 @@ fn dispatch_rollback_prunes_tail_state_and_keeps_revisions_monotonic() {
.expect("prepare");
let _ = render_underwater_test_app(&mut app, 60, 16);
app.collapsed_cells.insert(0);
app.folded_thinking.insert(0);
app.thinking_folds.insert(0, ThinkingFold::Expanded);
app.expanded_tool_runs.insert(0);
app.collapsed_cell_map.push(0);
let next_revision = app.next_history_revision;
@@ -6973,7 +7053,7 @@ fn dispatch_rollback_prunes_tail_state_and_keeps_revisions_monotonic() {
let error = apply(&mut app, &engine.handle, &config).expect_err("dispatch fails");
assert_eq!(error.to_string(), "failed");
assert_eq!(app.next_history_revision, next_revision);
assert!(app.collapsed_cells.is_empty() && app.folded_thinking.is_empty());
assert!(app.collapsed_cells.is_empty() && app.thinking_folds.is_empty());
assert!(app.expanded_tool_runs.is_empty() && app.collapsed_cell_map.is_empty());
app.push_history_cell(HistoryCell::Assistant {
content: "replacement".into(),
@@ -6997,7 +7077,7 @@ fn dispatch_rollback_prunes_tail_state_and_keeps_revisions_monotonic() {
fn restored_reasoning_and_answer_clear_prior_fold_ownership() {
let mut app = create_test_app();
app.push_history_cell(long_reasoning("old session", false));
app.folded_thinking.insert(0);
app.thinking_folds.insert(0, ThinkingFold::Expanded);
let _ = render_underwater_test_app(&mut app, 80, 24);
let old_epoch = app.transcript_identity_epoch;
let session = saved_session_with_messages(vec![codewhale_models::Message {
@@ -7019,7 +7099,7 @@ fn restored_reasoning_and_answer_clear_prior_fold_ownership() {
}]);
apply_loaded_session(&mut app, &mut Config::default(), &session).expect("restore session");
assert!(app.folded_thinking.is_empty());
assert!(app.thinking_folds.is_empty());
assert_ne!(app.transcript_identity_epoch, old_epoch);
assert!(matches!(
app.history.first(),
@@ -7083,8 +7163,8 @@ fn filtered_selection_toggles_the_original_reasoning_index() {
let _ = render_underwater_test_app(&mut app, 60, 16);
assert_eq!(reasoning_hint_cells(&app), vec![1]);
assert!(handle_transcript_space(&mut app));
assert!(app.folded_thinking.contains(&1));
assert!(!app.folded_thinking.contains(&0));
assert_eq!(app.thinking_folds.get(&1), Some(&ThinkingFold::Expanded));
assert!(!app.thinking_folds.contains_key(&0));
}
#[test]
@@ -18904,7 +18984,7 @@ fn open_tool_details_pager_supports_active_virtual_tool_cell() {
&[1],
100,
app.transcript_render_options(),
&app.folded_thinking,
&app.thinking_folds,
None,
None,
);
+2 -2
View File
@@ -431,7 +431,7 @@ impl ChatWidget {
&cell_revisions,
transcript_width,
render_options,
&app.folded_thinking,
&app.thinking_folds,
None,
provisional_action_owner,
);
@@ -532,7 +532,7 @@ impl ChatWidget {
&filtered_revs,
transcript_width,
render_options,
&app.folded_thinking,
&app.thinking_folds,
Some(&app.collapsed_cell_map),
provisional_action_owner,
);
+4 -3
View File
@@ -1737,9 +1737,10 @@ Common settings keys:
- `show_thinking` (on/off)
- `thinking_default_expanded` (on/off, default off): renders thinking blocks
expanded initially when `show_thinking` is enabled. Space still toggles the
selected block, so setting this to `true` inverts the default without
removing per-block folding. This is useful in SSH/tmux environments where
the Space binding may be intercepted.
selected block, and it decides only the blocks you have not touched: a block
you expanded or collapsed yourself keeps that state if you change this
setting later. This is useful in SSH/tmux environments where the Space
binding may be intercepted.
- `thinking_preview_lines` (integer, default `2`): how many body rows a
**collapsed** completed thought still shows. `0` is header-only; `10` is
the older dump. Live streaming preview is unchanged. Expand a block with