diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 17df40b3..13aa6f3a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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>; +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 = 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(()) } diff --git a/crates/tui/tests/qa_pty.rs b/crates/tui/tests/qa_pty.rs index 6654d51e..708e78be 100644 --- a/crates/tui/tests/qa_pty.rs +++ b/crates/tui/tests/qa_pty.rs @@ -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 { + (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.