fix(tui): reset terminal viewport before repaint

This commit is contained in:
Hunter Bown
2026-05-07 15:17:03 -05:00
parent 8f181c80f8
commit 245e409a20
2 changed files with 82 additions and 2 deletions
+21 -2
View File
@@ -1,7 +1,7 @@
//! TUI event loop and rendering logic for `DeepSeek` CLI.
use std::collections::HashSet;
use std::io::{self, Stdout};
use std::io::{self, Stdout, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
@@ -134,6 +134,7 @@ const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100;
const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
const TERMINAL_ORIGIN_RESET: &[u8] = b"\x1b[r\x1b[?6l\x1b[H\x1b[2J";
/// Run the interactive TUI event loop.
///
@@ -641,6 +642,7 @@ async fn run_event_loop(
// for terminal-native text selection.
let mut shift_bypass_active = false;
let mut terminal_paused_at: Option<Instant> = None;
let mut force_terminal_repaint = false;
loop {
if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
@@ -870,6 +872,7 @@ async fn run_event_loop(
status,
error,
} => {
force_terminal_repaint = true;
// Finalize any in-flight tool group. Cancellation
// marks still-running entries as Failed so the user
// sees they were interrupted rather than the spinner
@@ -1471,6 +1474,7 @@ async fn run_event_loop(
terminal_paused_at = None;
app.status_message = Some("Terminal controls restored".to_string());
app.needs_redraw = true;
force_terminal_repaint = true;
}
let now = Instant::now();
@@ -1511,6 +1515,10 @@ async fn run_event_loop(
None
};
if app.needs_redraw && draw_wait.is_none() {
if force_terminal_repaint {
reset_terminal_viewport(terminal)?;
force_terminal_repaint = false;
}
terminal.draw(|f| render(f, app))?; // app is &mut
frame_rate_limiter.mark_emitted(Instant::now());
app.needs_redraw = false;
@@ -1630,7 +1638,7 @@ async fn run_event_loop(
);
}
terminal.clear()?;
reset_terminal_viewport(terminal)?;
app.handle_resize(final_w, final_h);
// #macos-resize: some terminals (macOS Terminal.app, Windows
// ConHost) briefly report stale dimensions via
@@ -6208,6 +6216,17 @@ fn resume_terminal(
if use_bracketed_paste {
execute!(terminal.backend_mut(), EnableBracketedPaste)?;
}
reset_terminal_viewport(terminal)?;
Ok(())
}
fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> {
// Reset scroll margins and origin mode before clearing. Some interactive
// child processes leave DECSTBM/DECOM behind; if ratatui's diff renderer
// then writes "row 0", terminals can place it relative to the leaked
// scroll region and the whole viewport appears shifted down.
terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?;
terminal.backend_mut().flush()?;
terminal.clear()?;
Ok(())
}
+61
View File
@@ -23,6 +23,21 @@ const KEY_TIMEOUT: Duration = Duration::from_secs(5);
fn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {
let ws = make_sealed_workspace()?;
spawn_minimal(ws)
}
fn boot_minimal_without_retry() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {
let ws = make_sealed_workspace()?;
std::fs::write(
ws.home().join(".deepseek").join("config.toml"),
"[retry]\nenabled = false\n",
)?;
spawn_minimal(ws)
}
fn spawn_minimal(
ws: qa_harness::harness::SealedWorkspace,
) -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {
let h = Harness::builder(Harness::cargo_bin("deepseek-tui"))
.cwd(ws.workspace())
.seal_home(ws.home())
@@ -55,6 +70,26 @@ fn write_skill(root: std::path::PathBuf, name: &str, description: &str) -> anyho
Ok(())
}
fn first_non_blank_row(frame: &qa_harness::Frame) -> Option<u16> {
(0..frame.rows()).find(|&row| !frame.row(row).trim().is_empty())
}
fn assert_viewport_starts_at_top(frame: &qa_harness::Frame) {
let dump = frame.debug_dump();
let first_row = first_non_blank_row(frame).expect("expected visible frame text");
assert_eq!(
first_row, 0,
"viewport content drifted below row 0:\n{dump}"
);
assert!(
frame.row(0).contains("Plan")
|| frame.row(0).contains("Agent")
|| frame.row(0).contains("Yolo")
|| frame.row(0).contains("DeepSeek"),
"expected header content on row 0:\n{dump}"
);
}
/// Smoke: the binary boots into an alt-screen, paints a composer, and the
/// header shows the project label. If this fails, the harness itself is
/// broken before we worry about any scenario.
@@ -76,6 +111,32 @@ fn smoke_boot_paints_composer() -> anyhow::Result<()> {
Ok(())
}
/// Regression for #1085: after a turn exits through the error path, terminal
/// origin/scroll-region state must not leave blank rows above the TUI.
#[test]
fn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> {
let (_ws, mut h) = boot_minimal_without_retry()?;
h.wait_for_text("Composer", BOOT_TIMEOUT)?;
assert_viewport_starts_at_top(h.frame());
h.send(keys::key::text("trigger a failed turn"))?;
h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(2))?;
h.send(keys::key::enter())?;
h.wait_for(
|frame| {
frame.contains("Turn failed")
|| frame.contains("Connection refused")
|| frame.contains("error")
},
Duration::from_secs(15),
)?;
h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;
assert_viewport_starts_at_top(h.frame());
let _ = h.shutdown();
Ok(())
}
/// Verifies the harness actually sees keystrokes — type a character and watch
/// it appear in the composer. This is the lowest-effort sanity check before
/// we lean on it for real scenarios.