fix(tui): forward resize dimensions to ratatui viewport

@imakid reported on Windows PowerShell that clicking the OS restore
button (maximize → windowed) during a long task turns the entire
terminal black, unrecoverable until Ctrl+C. The "refreshing context"
chip in the footer indicates the freeze coincides with a compaction
summary call (engine state `CoherenceState::RefreshingContext`).

Hypothesis (no Windows dev box, awaiting @imakid confirmation):

* Symptom 1 — "transcript stops refreshing" — is the expected
  no-stream window during the side-channel compaction summary call.
  Tokens don't stream until the summary returns and the next assistant
  turn resumes. That's not a bug, but the UI doesn't currently surface
  the distinction well.

* Symptom 2 — "black screen on restore-from-maximized" — most likely
  comes from a Windows ConHost transient: `crossterm::terminal::size()`
  briefly returns stale (maximized) dimensions during the
  maximize→windowed transition, while the `Event::Resize(w, h)`
  payload itself already carries the correct post-restore size. The
  current handler does `terminal.clear() + terminal.draw()` and
  relies on ratatui's internal autoresize, which calls
  `crossterm::terminal::size()` — meaning we paint a frame sized to
  the stale dimensions into the post-restore viewport, leaving most
  of the visible area unpainted (the user-reported black screen).

The change forwards the event-reported `(w, h)` to ratatui via
`Terminal::resize(Rect)` before the clear+draw, so the viewport
commit always uses the OS-truthful new size regardless of whether
`crossterm::terminal::size()` has caught up. This is a defensive
change everywhere — the event payload is authoritative on every
platform — and it specifically addresses the Windows ConHost stale-
size race.

Also widens the resize tracing event to include `coherence_state`
and `use_alt_screen` so the next user bug report includes the
context we'd ask for in triage.

Tests
=====

`chat_widget_renders_cleanly_after_resize_during_refreshing_context`
pins the renderer-side invariant: a resize cycle that arrives while
`coherence_state == RefreshingContext` must produce non-empty frames
at every cycled width, and must not mutate the engine's
coherence_state. The actual fix lives in the event-handler size
forwarding; the test guards the renderer's no-empty-buffer contract
so a future regression that gates layout on coherence state would be
caught immediately.

What this PR does NOT change
============================

* No platform-specific code path. The fix is universal — passing the
  event-reported size to ratatui is correct everywhere; Windows just
  happens to be where the bug manifests today.
* No change to the freeze symptom directly. If symptom 1 is the
  expected compaction-summary no-stream window, the right
  follow-up is a UX cue ("compacting context, please hold") rather
  than a bugfix.

@imakid — please test by installing this branch:

    cargo install --git https://github.com/Hmbown/DeepSeek-TUI.git \\
        --branch fix/582-powershell-resize

Then run a long task, click the OS restore button mid-task, and
confirm whether the black-screen symptom is gone. If it still
reproduces, please run with `RUST_LOG=deepseek_tui=debug` and post
the resize lines from the log so we can see the dimensions
crossterm/ratatui actually saw.

Verification
============

* `cargo fmt --all -- --check` clean.
* `cargo clippy -p deepseek-tui --bin deepseek-tui --all-features
  --locked -- -D warnings` clean.
* `cargo test -p deepseek-tui --bin deepseek-tui --locked` →
  2029 passed, 2 ignored.

Refs #582.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-06 17:29:00 -05:00
committed by GitHub
parent b4867b835d
commit b59012e765
2 changed files with 97 additions and 1 deletions
+29 -1
View File
@@ -1519,7 +1519,13 @@ async fn run_event_loop(
}
if let Event::Resize(width, height) = evt {
tracing::debug!(width, height, "Event::Resize received; clearing terminal");
tracing::debug!(
width,
height,
coherence = ?app.coherence_state,
use_alt_screen = app.use_alt_screen,
"Event::Resize received; clearing terminal"
);
// Drain any further Resize events queued in this poll cycle so we
// act on the final size only, then issue a single clear + redraw.
// crossterm coalesces some resize events but rapid drag-resizes
@@ -1548,6 +1554,28 @@ async fn run_event_loop(
Err(_) => break,
}
}
// #582: commit the event-reported size to ratatui's
// viewport explicitly before the redraw, instead of
// relying on `crossterm::terminal::size()` which gets
// queried internally during `terminal.draw`. On
// Windows ConHost specifically, `terminal::size()` has
// been observed to return stale dimensions briefly
// during a maximize→windowed transition; the next
// `draw` then paints into a buffer that does not
// match the post-restore viewport, producing the
// unrecoverable black screen reported by @imakid.
// The `Event::Resize` payload itself carries the
// authoritative new size, so we forward it.
if let Err(err) = terminal.resize(Rect::new(0, 0, final_w, final_h)) {
tracing::warn!(
?err,
final_w,
final_h,
"terminal.resize during Resize event failed; falling back to clear+draw"
);
}
terminal.clear()?;
app.handle_resize(final_w, final_h);
// Draw immediately so the cleared screen gets repainted before
+68
View File
@@ -2587,6 +2587,74 @@ mod tests {
);
}
/// Regression for issue #582: a resize event arriving while the
/// engine is in `CoherenceState::RefreshingContext` (i.e. running
/// a compaction summary call) must NOT leave the chat widget with
/// an empty viewport. The user-reported symptom on Windows
/// PowerShell is that the screen turns black on the maximize→
/// windowed transition during a long task; the post-resize render
/// must produce a populated frame regardless of the active
/// coherence intervention. Pins the invariant from the renderer
/// side; the actual ConHost size-stale fix lives in
/// `tui::ui::run_tui` (the `Event::Resize` handler now forwards
/// the event-reported dimensions to ratatui's viewport before the
/// redraw).
#[test]
fn chat_widget_renders_cleanly_after_resize_during_refreshing_context() {
use crate::core::coherence::CoherenceState;
let mut app = create_test_app();
for i in 0..30 {
app.add_message(HistoryCell::User {
content: format!("user message {i} during a long-running task"),
});
}
// Pretend the engine is mid-compaction when the resize arrives.
app.coherence_state = CoherenceState::RefreshingContext;
// Drive the same shrink-then-grow cycle that maximize→windowed
// transitions produce on Windows.
for (width, height) in [(140u16, 40u16), (90, 28), (60, 20), (140, 40)] {
app.handle_resize(width, height);
let area = Rect {
x: 0,
y: 0,
width,
height,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let mut non_empty = 0usize;
for y in 0..height {
for x in 0..width {
let sym = buf[(x, y)].symbol();
if sym != " " && !sym.is_empty() {
non_empty += 1;
}
}
}
assert!(
non_empty > 0,
"resize-during-RefreshingContext at {width}x{height} produced an empty buffer; \
render path must not gate on coherence state (#582)"
);
}
// The engine's coherence_state must survive a resize — it is
// the engine's runtime decision, not a render-loop concern.
// A future regression that bounced the state to `Healthy` on
// resize would silently drop the "refreshing context" footer
// chip while compaction is still in flight.
assert_eq!(
app.coherence_state,
CoherenceState::RefreshingContext,
"resize must not mutate engine-owned coherence_state"
);
}
/// Regression for issue #65: after `App::handle_resize`, the chat widget
/// must produce a clean render at the new width — no stale wrapping,
/// no panic, no content exceeding the requested width. Cycling through