fix(tui): reset terminal viewport before repaint
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user