mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
2fb6efa6a0
commit
2f47a370f4
11 files changed
+278
-153
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user