From f3c7ec225d2e467d32649da085c43b683abc9e93 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:00:40 -0400 Subject: [PATCH] Repaint terminal after idle tab return --- src/bin/dosh-client.rs | 149 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 10 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 4fbd6aa..4c34f9c 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -66,6 +66,8 @@ const STARTUP_INPUT_HOLD: Duration = Duration::from_millis(750); const POST_SUBMIT_ALL_INPUT_HOLD: Duration = Duration::from_millis(120); const STALE_TERMINAL_INPUT_AFTER: Duration = Duration::from_secs(2); const POST_RECONNECT_STALE_INPUT_GRACE: Duration = Duration::from_secs(2); +const FOCUS_REPAINT_COOLDOWN: Duration = Duration::from_secs(1); +const ALT_SCREEN_IDLE_REPAINT_AFTER: Duration = Duration::from_secs(15); /// Sentinel `target_host` the server uses on a server-initiated `StreamOpen` that /// represents an SSH-agent connection (rather than a TCP target). The client @@ -4334,10 +4336,14 @@ async fn run_terminal( let mut startup_input_hold_until: Option = None; let mut startup_gate_mode = StartupGateMode::HoldControl; let mut stale_terminal_input_suppress_until: Option = None; + let mut last_terminal_frame_at = Instant::now(); + let mut last_focus_repaint_at = Instant::now() - FOCUS_REPAINT_COOLDOWN; + let mut last_idle_repaint_attempt_at = Instant::now() - ALT_SCREEN_IDLE_REPAINT_AFTER; if let Some(frame) = first_frame { if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); } if frame.closed { return Ok(()); @@ -4395,6 +4401,41 @@ async fn run_terminal( if input_matches_escape(&bytes, escape_key.as_deref()) { break; } + if !forward_only + && input_contains_focus_in(&bytes) + && last_focus_repaint_at.elapsed() >= FOCUS_REPAINT_COOLDOWN + { + last_focus_repaint_at = Instant::now(); + if let Some(frame) = reconnect( + &socket, + &mut cred, + &mut send_seq, + last_size, + &mut frame_buffer, + &mut predictor, + ) + .await? + { + refresh_live_addr(&mut addr, &cred)?; + arm_stale_terminal_input_suppression( + &mut stale_terminal_input_suppress_until, + ); + render_frame(&frame)?; + predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); + last_packet_at = Instant::now(); + flush_pending_user_input( + &socket, + addr, + &cred, + &mut send_seq, + &mut predictor, + &mut pending_user_input, + &mut pending_user_input_bytes, + ) + .await?; + } + } if should_strip_stale_terminal_reports( last_packet_at.elapsed(), stale_terminal_input_suppress_until, @@ -4508,6 +4549,7 @@ async fn run_terminal( if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); } last_packet_at = Instant::now(); flush_pending_user_input( @@ -4570,6 +4612,7 @@ async fn run_terminal( if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); } last_packet_at = Instant::now(); flush_pending_user_input( @@ -4623,6 +4666,7 @@ async fn run_terminal( if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); } last_packet_at = Instant::now(); flush_pending_user_input( @@ -4648,6 +4692,7 @@ async fn run_terminal( if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); flush_startup_input_if_ready( &socket, addr, @@ -4747,6 +4792,7 @@ async fn run_terminal( if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); } last_packet_at = Instant::now(); flush_pending_user_input( @@ -5142,6 +5188,7 @@ async fn run_terminal( if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); } last_packet_at = Instant::now(); flush_pending_user_input( @@ -5183,6 +5230,44 @@ async fn run_terminal( (&mut startup_input_hold_until, &mut startup_gate_mode), ) .await?; + let now = Instant::now(); + if should_repaint_idle_alternate_screen( + predictor.alternate_screen, + last_terminal_frame_at, + last_idle_repaint_attempt_at, + now, + ) { + last_idle_repaint_attempt_at = now; + if let Some(frame) = reconnect( + &socket, + &mut cred, + &mut send_seq, + last_size, + &mut frame_buffer, + &mut predictor, + ) + .await? + { + refresh_live_addr(&mut addr, &cred)?; + arm_stale_terminal_input_suppression( + &mut stale_terminal_input_suppress_until, + ); + render_frame(&frame)?; + predictor.observe_output(&frame.bytes); + last_terminal_frame_at = Instant::now(); + last_packet_at = Instant::now(); + flush_pending_user_input( + &socket, + addr, + &cred, + &mut send_seq, + &mut predictor, + &mut pending_user_input, + &mut pending_user_input_bytes, + ) + .await?; + } + } } // Refresh / clear the non-destructive disconnect status line based // on how long the link has been silent (recomputed after any @@ -5552,6 +5637,21 @@ fn should_strip_stale_terminal_reports( || suppress_until.is_some_and(|deadline| Instant::now() < deadline) } +fn input_contains_focus_in(bytes: &[u8]) -> bool { + contains_bytes(bytes, b"\x1b[I") || contains_bytes(bytes, b"\x9bI") +} + +fn should_repaint_idle_alternate_screen( + alternate_screen: bool, + last_terminal_frame_at: Instant, + last_attempt_at: Instant, + now: Instant, +) -> bool { + alternate_screen + && now.duration_since(last_terminal_frame_at) >= ALT_SCREEN_IDLE_REPAINT_AFTER + && now.duration_since(last_attempt_at) >= ALT_SCREEN_IDLE_REPAINT_AFTER +} + fn arm_stale_terminal_input_suppression(suppress_until: &mut Option) { *suppress_until = Some(Instant::now() + POST_RECONNECT_STALE_INPUT_GRACE); } @@ -7230,11 +7330,12 @@ const TERMINAL_CLEANUP: &[u8] = concat!( #[cfg(test)] mod tests { use super::{ - CachedCredential, DisconnectStatus, DynamicForward, FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer, - LocalForward, MAX_PENDING_USER_INPUT_BYTES, POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, - PredictMode, Predictor, RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, - SshConfig, StartupGateMode, StatusAction, auth_allows, cache_key, cache_server_prefix, - clear_cached_credentials, ensure_tui_safe_status_overlay, input_matches_escape, + ALT_SCREEN_IDLE_REPAINT_AFTER, CachedCredential, DisconnectStatus, DynamicForward, + FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer, LocalForward, MAX_PENDING_USER_INPUT_BYTES, + POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode, Predictor, + RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig, StartupGateMode, + StatusAction, auth_allows, cache_key, cache_server_prefix, clear_cached_credentials, + ensure_tui_safe_status_overlay, input_contains_focus_in, input_matches_escape, is_local_status_target, is_resume_response_for_client, latest_release_download_url, load_first_native_identity_with_prompt, parse_dynamic_forward, parse_escape_key, parse_local_forward, parse_remote_forward, parse_ssh_config, post_submit_hold_duration, @@ -7243,10 +7344,11 @@ mod tests { render_status_clear, render_status_overlay, requested_env, resolved_startup_command, retransmit_stream_opens, rewrite_forward_command, selected_predict_mode, selected_udp_host, server_version_mismatch, should_flush_terminal_input_after_contact, - should_hold_during_startup_gate, should_hold_post_submit_input, split_after_command_submit, - ssh_destination_host, ssh_username, ssh_with_user, startup_command, status_ssh_target, - strip_stale_mouse_reports, toml_bare_key_or_quoted, update_check_requested, - update_version_status, upsert_managed_block, valid_forward_host, vscode_safe_alias, + should_hold_during_startup_gate, should_hold_post_submit_input, + should_repaint_idle_alternate_screen, split_after_command_submit, ssh_destination_host, + ssh_username, ssh_with_user, startup_command, status_ssh_target, strip_stale_mouse_reports, + toml_bare_key_or_quoted, update_check_requested, update_version_status, + upsert_managed_block, valid_forward_host, vscode_safe_alias, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; @@ -7255,7 +7357,7 @@ mod tests { use ed25519_dalek::{SigningKey, VerifyingKey}; use std::collections::{HashMap, VecDeque}; use std::fs; - use std::time::Duration; + use std::time::{Duration, Instant}; fn test_args(server: &str, command: &[&str]) -> super::Args { super::Args { @@ -8551,6 +8653,33 @@ mod tests { assert_eq!(strip_stale_mouse_reports(b"\x9bIhello\x9bO"), b"hello"); } + #[test] + fn focus_in_reports_trigger_local_repaint_detection() { + assert!(input_contains_focus_in(b"\x1b[I")); + assert!(input_contains_focus_in(b"before\x9bIafter")); + assert!(!input_contains_focus_in(b"\x1b[O")); + assert!(!input_contains_focus_in(b"\x1b[A")); + } + + #[test] + fn idle_repaint_only_runs_for_stale_alternate_screen() { + let now = Instant::now(); + let stale = now - ALT_SCREEN_IDLE_REPAINT_AFTER - Duration::from_secs(1); + let recent = now - Duration::from_secs(1); + assert!(should_repaint_idle_alternate_screen( + true, stale, stale, now + )); + assert!(!should_repaint_idle_alternate_screen( + false, stale, stale, now + )); + assert!(!should_repaint_idle_alternate_screen( + true, recent, stale, now + )); + assert!(!should_repaint_idle_alternate_screen( + true, stale, recent, now + )); + } + #[test] fn non_mouse_escape_input_survives_stale_filter() { assert_eq!(strip_stale_mouse_reports(b"\x1b[A"), b"\x1b[A");