fix(ui): Ctrl+O expands thinking blocks still in active_cell

After `ThinkingComplete` the finalized thinking entry sits in
`app.active_cell` with `streaming = false` until the active cell
flushes to history at end-of-turn. During that window the transcript
rendered the "thinking collapsed; press Ctrl+O for full text"
affordance from `render_thinking`, but `open_thinking_pager` only
searched `app.history` — so the handler surfaced "No thinking blocks
to expand" while pointing at the affordance. The affordance was
truthful; the handler was lying.

Routed the lookup through `cell_at_virtual_index` /
`virtual_cell_count`, the existing virtual-index API that
`open_tool_details_pager` already uses for the same active-cell
window. The selection-based path resolves through the virtual index
too, so dragging into an in-flight thinking block and pressing
Ctrl+O now works as well.

Regression guard: `open_thinking_pager_finds_thinking_in_active_cell`
drives the entry into active_cell, finalizes it so the "collapsed"
affordance is what render_thinking emits, then asserts Ctrl+O pushes
the Pager view instead of surfacing the "No thinking blocks" status.
This commit is contained in:
Hunter Bown
2026-05-11 14:03:53 -05:00
parent bfb9da3462
commit dc2433a8b5
3 changed files with 73 additions and 16 deletions
+13
View File
@@ -86,6 +86,19 @@ coverage additions.
`reasoning_content` replay, preventing second-turn HTTP 400s `reasoning_content` replay, preventing second-turn HTTP 400s
after tool calls when users keep the onboarding default model after tool calls when users keep the onboarding default model
alias. alias.
- **`Ctrl+O` expands thinking blocks still in flight.**
`open_thinking_pager` only searched `app.history`, but after
`ThinkingComplete` the finalized thinking entry sits in
`app.active_cell` with `streaming = false` until the active cell
flushes at end-of-turn. During that window the transcript still
rendered the "thinking collapsed; press Ctrl+O for full text"
affordance from `render_thinking`, so the handler surfaced
"No thinking blocks to expand" while the affordance pointed at
it. Routed through the existing `cell_at_virtual_index` /
`virtual_cell_count` resolver that `open_tool_details_pager`
already uses; selection-based and most-recent lookups both reach
in-flight entries now. Regression-guarded by
`open_thinking_pager_finds_thinking_in_active_cell`.
- **Skill completions no longer flood the top-level slash menu** - **Skill completions no longer flood the top-level slash menu**
(#1437, PR #1442 from **@reidliu41**). Installed skills now (#1437, PR #1442 from **@reidliu41**). Installed skills now
complete under `/skill <name>` while the root `/` menu stays complete under `/skill <name>` while the root `/` menu stays
+24 -16
View File
@@ -8493,8 +8493,16 @@ fn open_pager_for_last_message(app: &mut App) -> bool {
/// Open a pager showing the full thinking block. Targets the cell at the /// Open a pager showing the full thinking block. Targets the cell at the
/// current selection if it's a Thinking cell; otherwise falls back to the /// current selection if it's a Thinking cell; otherwise falls back to the
/// most recent Thinking cell in history. Bound to Ctrl+O so users can read /// most recent Thinking cell across the virtual transcript (history +
/// reasoning content that's been collapsed in calm-mode rendering. /// in-flight `active_cell`). Bound to Ctrl+O so users can read reasoning
/// content that's been collapsed in calm-mode rendering.
///
/// The virtual-index lookup matters: after `ThinkingComplete` fires the
/// finalized thinking entry sits in `active_cell` with `streaming = false`
/// until the active cell flushes to history. During that window the
/// transcript already renders the "thinking collapsed; press Ctrl+O for
/// full text" affordance, so the handler must address active-cell entries
/// or the affordance becomes a lie.
fn open_thinking_pager(app: &mut App) -> bool { fn open_thinking_pager(app: &mut App) -> bool {
let selected_cell = app let selected_cell = app
.viewport .viewport
@@ -8510,23 +8518,18 @@ fn open_thinking_pager(app: &mut App) -> bool {
}) })
.filter(|&idx| { .filter(|&idx| {
matches!( matches!(
app.history.get(idx), app.cell_at_virtual_index(idx),
Some(crate::tui::history::HistoryCell::Thinking { .. }) Some(crate::tui::history::HistoryCell::Thinking { .. })
) )
}); });
let target_idx = selected_cell.or_else(|| { let target_idx = selected_cell.or_else(|| {
app.history (0..app.virtual_cell_count()).rev().find(|&idx| {
.iter() matches!(
.enumerate() app.cell_at_virtual_index(idx),
.rev() Some(crate::tui::history::HistoryCell::Thinking { .. })
.find_map(|(idx, cell)| { )
if matches!(cell, crate::tui::history::HistoryCell::Thinking { .. }) { })
Some(idx)
} else {
None
}
})
}); });
let Some(idx) = target_idx else { let Some(idx) = target_idx else {
@@ -8534,13 +8537,18 @@ fn open_thinking_pager(app: &mut App) -> bool {
return true; return true;
}; };
let cell = &app.history[idx];
let width = app let width = app
.viewport .viewport
.last_transcript_area .last_transcript_area
.map(|area| area.width) .map(|area| area.width)
.unwrap_or(80); .unwrap_or(80);
let text = history_cell_to_text(cell, width); let text = {
let Some(cell) = app.cell_at_virtual_index(idx) else {
app.status_message = Some("No thinking blocks to expand".to_string());
return true;
};
history_cell_to_text(cell, width)
};
app.view_stack.push(PagerView::from_text( app.view_stack.push(PagerView::from_text(
"Thinking", "Thinking",
&text, &text,
+36
View File
@@ -3948,6 +3948,42 @@ fn flush_active_cell_finalizes_unclosed_thinking_block() {
); );
} }
#[test]
fn open_thinking_pager_finds_thinking_in_active_cell() {
// After ThinkingComplete fires, the finalized thinking entry stays in
// `app.active_cell` with `streaming = false` until the active cell is
// flushed to history (end-of-turn, or when an assistant text arrives).
// During that window the transcript still renders the
// "thinking collapsed; press Ctrl+O for full text" affordance from
// `render_thinking`, so the handler must reach across the virtual
// transcript — not just `app.history` — or the promise is a lie.
// Regression guard for the v0.8.29 affordance/handler mismatch.
let mut app = create_test_app();
let _ = ensure_streaming_thinking_active_entry(&mut app);
append_streaming_thinking(&mut app, 0, "deliberating");
let finalized = finalize_streaming_thinking_active_entry(&mut app, Some(1.2), "");
assert!(finalized);
assert!(
app.history.is_empty(),
"thinking entry stays in active_cell until flush"
);
let active = app.active_cell.as_ref().expect("active cell present");
assert!(matches!(
active.entries().first(),
Some(HistoryCell::Thinking {
streaming: false,
..
})
));
assert!(open_thinking_pager(&mut app));
assert_eq!(
app.view_stack.top_kind(),
Some(ModalKind::Pager),
"pager must open for thinking entries still in active_cell"
);
}
#[test] #[test]
fn engine_error_finalizes_active_thinking_block() { fn engine_error_finalizes_active_thinking_block() {
use crate::error_taxonomy::StreamError; use crate::error_taxonomy::StreamError;