Skip to content

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

Merged
Hmbown merged 5 commits into
Hmbown:mainfrom
HUQIANTAO:feat/collapsible-thinking
May 31, 2026
Merged

feat: per-cell thinking fold/unfold via Space key#2385
Hmbown merged 5 commits into
Hmbown:mainfrom
HUQIANTAO:feat/collapsible-thinking

Conversation

@HUQIANTAO
Copy link
Copy Markdown
Contributor

@HUQIANTAO HUQIANTAO commented May 31, 2026

Problem

Thinking output is long and takes up a lot of space in the conversation view, making it hard to read the actual assistant responses. Users want to fold/collapse individual thinking blocks to see summaries instead of full content.

Solution

Add per-cell thinking fold/unfold via the Space key. When the composer is empty and the cursor is on a thinking cell, Space toggles between folded (summary preview) and unfolded (full content). Other cells retain the existing hide/show behavior.

This is independent of the global /verbose toggle — even when verbose mode is on, individual thinking blocks can be folded.

Changes

  • Add folded_thinking: HashSet<usize> to App for per-cell fold tracking
  • Add lines_with_options_folded / lines_with_copy_metadata_folded methods 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

Greptile Summary

This PR adds per-cell fold/unfold of thinking blocks via the Space key. It introduces a folded_thinking: HashSet<usize> on App, new lines_with_options_folded / lines_with_copy_metadata_folded rendering methods, and plumbs fold state through the transcript cache's ensure_split call so that toggling fold state properly invalidates cached renders.

  • Fold state is XOR'd with the global verbose flag so Space always toggles the current visual state regardless of whether verbose mode is on or off.
  • The slow path (collapsed cells active) correctly passes the filtered_to_original map as original_index_map so fold-set lookups use original virtual indices.

Confidence Score: 3/5

Two correctness bugs in the Space key handler need fixing before this ships: one causes inverted status messages under the default verbose=false setting, and the other looks up the wrong history cell when any cell is collapsed.

The Space handler has two defects on the changed path. First, the XOR-based collapse logic means folded_thinking.insert() expands the cell when verbose is off (the default), so every user who presses Space sees a status message that says the opposite of what happened visually. Second, app.history.get(idx) receives the filtered position index from line_meta rather than the original virtual index; in the slow path this reads the wrong slot in history, making is_thinking unreliable and storing an incorrect index in folded_thinking. The cache invalidation logic and index mapping in transcript.rs and widgets/mod.rs are correct on their own.

crates/tui/src/tui/ui.rs — both bugs are in the Space key handler block.

Important Files Changed

Filename Overview
crates/tui/src/tui/ui.rs Space key handler contains two bugs: status messages inverted under verbose=false, and app.history.get(idx) uses a filtered index in the slow path.
crates/tui/src/tui/history.rs XOR-based collapse toggle and new folded rendering methods look correct; lines_with_copy_metadata is now dead code with suppress attribute added.
crates/tui/src/tui/transcript.rs Cache invalidation via folded_changed + per_cell.clear() is correct; original_index_map lookup properly translates filtered positions to original indices.
crates/tui/src/tui/widgets/mod.rs Fast path passes None (identity), slow path passes Some(&collapsed_cell_map) — both correctly wired to ensure_split.
crates/tui/src/tui/app.rs Adds folded_thinking field initialised to HashSet::new() — straightforward, no issues.
crates/tui/src/tui/ui/tests.rs Test updated to pass folded_thinking to ensure_split — looks correct.

Sequence Diagram

sequenceDiagram
    participant User
    participant SpaceHandler as Space Key Handler (ui.rs)
    participant App
    participant Cache as TranscriptViewCache (transcript.rs)
    participant Renderer as HistoryCell::lines_with_options_folded (history.rs)

    User->>SpaceHandler: Press Space on thinking cell
    SpaceHandler->>App: detail_target_cell_index(app) to idx
    Note over SpaceHandler: Fast path: idx == original. Slow path: idx == filtered (bug)
    SpaceHandler->>App: history.get(idx) to is_thinking
    SpaceHandler->>App: folded_thinking.insert/remove(idx)
    SpaceHandler->>App: "mark_history_updated + needs_redraw=true"
    User->>Cache: Next frame render
    Cache->>Cache: folded_changed check
    alt folded state changed
        Cache->>Cache: per_cell.clear full re-render
    end
    loop each cell
        Cache->>Cache: original_idx via original_index_map
        Cache->>Cache: "folded = folded_cells.contains(original_idx)"
        Cache->>Renderer: lines_with_copy_metadata_folded(width, options, folded)
        Renderer->>Renderer: "collapsed = folded XOR not verbose"
        Renderer-->>Cache: rendered lines
    end
    Cache-->>User: Updated transcript view
Loading

Comments Outside Diff (1)

  1. crates/tui/src/tui/widgets/mod.rs, line 201-208 (link)

    P1 Fold state silently lost when any cell is collapsed (slow path index mismatch)

    In the slow path, filtered_cells is built by excluding collapsed cells — so its sequential indices (0, 1, 2…) no longer correspond to original virtual indices. But folded_thinking stores original virtual indices, and inside ensure_split the lookup is folded_cells.contains(&idx) where idx is the position in the filtered set.

    Consider: cells at original indices [0=User, 1=Tool(collapsed), 2=Thinking(folded)]. After filtering, the thinking cell is at filtered index 1, but folded_thinking contains {2}. folded_cells.contains(&1) returns false, so the cell renders unfolded.

    The fast path is unaffected (no filtering). The slow path should translate each filtered index back to its original index before the lookup, using the filtered_to_original map that is already being built in ChatWidget::new.

    Fix in Codex Fix in Claude Code Fix in Cursor

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

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 Hmbown#2348
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a per-cell folding feature for thinking cells in the TUI, allowing users to toggle individual thinking blocks between a collapsed summary and full content using the Space key. The feedback focuses on improving the toggle logic and user experience: first, by using an XOR operation (folded ^ !options.verbose) to allow unfolding individual blocks even when global verbose mode is off; second, by updating the Space key handler to support this relative toggle behavior; and third, by dynamically updating the footer label to show 'Space to fold' or 'Space to expand' depending on the block's current state.

Comment thread crates/tui/src/tui/history.rs Outdated
*streaming,
*duration_secs,
!options.verbose,
folded || !options.verbose,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using folded || !options.verbose means that when global verbose mode is off (!options.verbose is true), the expression is always true. As a result, individual thinking blocks can never be unfolded when verbose mode is off, even though pressing the Space key will still toggle the state in folded_thinking and show misleading status messages like 'Thinking block expanded'.

By changing this to folded ^ !options.verbose (XOR), we can treat folded_thinking as a toggle relative to the default state. This allows users to fold individual blocks when verbose mode is on, and unfold individual blocks when verbose mode is off, making the feature truly independent of the global setting.

Suggested change
folded || !options.verbose,
folded ^ !options.verbose,

Comment thread crates/tui/src/tui/ui.rs
Comment on lines +2961 to +2970
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());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To support the XOR-based toggle logic that allows folding/unfolding independent of the global verbose setting, we should update the Space key handler to toggle the presence of the cell index in folded_thinking relative to the current visual state.

                        if is_thinking {
                            let is_currently_folded = app.folded_thinking.contains(&idx) ^ !app.transcript_render_options().verbose;
                            if is_currently_folded {
                                if app.transcript_render_options().verbose {
                                    app.folded_thinking.remove(&idx);
                                } else {
                                    app.folded_thinking.insert(idx);
                                }
                                app.status_message = Some("Thinking block expanded".to_string());
                            } else {
                                if app.transcript_render_options().verbose {
                                    app.folded_thinking.insert(idx);
                                } else {
                                    app.folded_thinking.remove(&idx);
                                }
                                app.status_message = Some("Thinking block folded".to_string());
                            }
                        }

Comment on lines 2239 to 2243
let label = if streaming {
"More reasoning in Ctrl+O"
} else {
"Full reasoning in Ctrl+O"
"Space to expand · Full reasoning in Ctrl+O"
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the bottom rail/footer is shown in both folded and unfolded states, hardcoding 'Space to expand' when the block is already unfolded is misleading. It should dynamically show 'Space to fold' (or 'Space to collapse') when unfolded, and 'Space to expand' when folded.

Note: If the fourth parameter of render_thinking is named differently (e.g., folded or summary), please adjust the parameter name in the suggestion accordingly.

        let label = if streaming {
            "More reasoning in Ctrl+O"
        } else if collapsed {
            "Space to expand · Full reasoning in Ctrl+O"
        } else {
            "Space to fold · Full reasoning in Ctrl+O"
        };

Comment thread crates/tui/src/tui/ui.rs
Comment thread crates/tui/src/tui/history.rs
@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented May 31, 2026

Thanks for pushing on this. The foldable thinking UI is exactly the kind of readability polish CodeWhale needs, but I am leaving this open for one more pass before merge.

Two things look important to tighten: the toggle updates folded_thinking but cached cell renders may not be invalidated, and the slow render path appears to mix filtered cell positions with the original indices. That means a collapsed thinking block can look correct once and then drift when cached/filtered rendering kicks in.

Next best step: add a focused regression for folded-thinking cache invalidation and filtered/original index handling, then run cargo test -p codewhale-tui folded_thinking -- --nocapture plus the PR checks. Once that is green this should be a strong candidate for #2348.

Change from  to  so Space
toggles the collapsed state relative to the global verbose flag:

- verbose off (default): thinking collapsed; Space unfolds it
- verbose on: thinking expanded; Space folds it

This lets users expand individual thinking blocks even when the global
verbose mode is off, without needing to toggle verbose for all blocks.
@HUQIANTAO
Copy link
Copy Markdown
Contributor Author

Updated to use XOR for the fold toggle, so Space works relative to the global verbose setting:

  • verbose off (default): thinking collapsed, Space unfolds individual blocks
  • verbose on: thinking expanded, Space folds individual blocks

Two bugs in the folded-thinking rendering path:

1. Cache invalidation: changing folded_thinking did not invalidate cached
   cell renders because the cell revision stayed the same. Now track
   folded_cells in the cache and force re-render when it changes.

2. Index mapping: in the slow path (with collapsed cells), the cache idx
   is a filtered position, but folded_cells uses original virtual indices.
   Add original_index_map parameter to ensure_split so fold lookups
   resolve correctly.

Added two regression tests:
- folded_thinking_cache_invalidation: verifies fold/unfold triggers re-render
- folded_thinking_with_collapsed_cells_uses_original_indices: verifies
  correct fold behavior when collapsed_cells filtering is active
@HUQIANTAO
Copy link
Copy Markdown
Contributor Author

Fixed both issues:

  1. Cache invalidation: TranscriptViewCache now tracks folded_cells and forces re-render when the set changes, so fold/unfold always takes effect immediately.

  2. Index mapping: Added original_index_map parameter to ensure_split. In the slow path (with collapsed_cells), the cache now correctly maps filtered positions back to original virtual indices for fold lookups.

Added two regression tests as requested:

  • folded_thinking_cache_invalidation
  • folded_thinking_with_collapsed_cells_uses_original_indices

cargo test -p codewhale-tui folded_thinking -- --nocapture passes cleanly.

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented May 31, 2026

Thanks @HUQIANTAO — the follow-up fixed the cache/index pieces I was worried about, and I pushed a tiny maintainer polish on top so the regression stays warning-free after the main-branch update.\n\nWhat I verified on the updated head:\n- merged cleanly with current main\n- git diff --check\n- cargo fmt --all -- --check\n- python3 scripts/check-provider-registry.py\n- cargo test -p codewhale-tui folded_thinking -- --nocapture\n- cargo check -p codewhale-tui --all-features --locked\n\nThis closes the readability loop from #2348 nicely: Space now toggles a thinking cell between compact summary and full reasoning without losing the cached transcript behavior. Merging this one.

@Hmbown Hmbown merged commit 1afc72e into Hmbown:main May 31, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

希望支持折叠thinking输出 否则影响阅读

2 participants