diff --git a/CHANGELOG.md b/CHANGELOG.md index 658ecc69..6a6466a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **DEC 2026 synchronized output is auto-disabled on Ptyxis** (the new + default terminal on Ubuntu 26.04 and an increasingly common Linux + TUI host). Ptyxis 50.x ships on VTE 0.84.x, which parses the + `\x1b[?2026h` / `\x1b[?2026l` begin/end pair but still flashes the + entire viewport on every wrapped frame instead of deferring + rendering — so a TUI that uses DEC 2026 to avoid tearing + experiences visible flicker on every redraw. gnome-terminal 3.58 + on the same VTE renders cleanly, so the heuristic must stay narrow: + we trigger only on `TERM_PROGRAM` matching `ptyxis` + case-insensitively, or `PTYXIS_VERSION` set to any non-empty value. + Either signal flips the new `synchronized_output` setting from + `auto` to `off`; the renderer then skips the begin/end pair on + every draw, in `reset_terminal_viewport`, and in `resume_terminal`. + Users on Ptyxis who upgrade past the upstream fix (or who want to + confirm a fix landed) can override with + `/set synchronized_output on` or by adding + `synchronized_output = "on"` to `~/.config/deepseek/settings.toml`. + +### Added + +- **New `synchronized_output` setting** controls whether the renderer + wraps each frame in DEC mode 2026 synchronized output. Accepts + `auto` (default; respect the Ptyxis env opt-out), `on` (always emit + DEC 2026, override the heuristic), or `off` (never emit DEC 2026). + The cost of `off` is brief tearing on terminals that handle DEC + 2026 cleanly; it is purely a rendering-quality knob, not a + correctness one. Set via `/set synchronized_output ` + or in `~/.config/deepseek/settings.toml`. + ## [0.8.30] - 2026-05-11 A "tighten what we shipped" release. Bare single-letter keystrokes diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index ea7110cd..85ebc2d6 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -236,6 +236,23 @@ pub struct Settings { /// replaced the whale during the dots era. /// - `"off"`: hide the indicator entirely. pub status_indicator: String, + /// Whether to wrap each draw in DEC mode 2026 synchronized output + /// (`\x1b[?2026h` … `\x1b[?2026l`). Synchronized output asks the + /// terminal to defer rendering until the whole frame is staged so + /// GPU-accelerated terminals (Ghostty, VS Code, Kitty, WezTerm) + /// don't flash a blank intermediate frame. + /// + /// - `"auto"` (default): emit DEC 2026 unless an environment signal + /// says the active terminal mishandles it (currently Ptyxis 50.x + /// on VTE 0.84.x — see [`Settings::apply_env_overrides`]). + /// - `"on"`: always emit DEC 2026 (override the auto opt-out). + /// - `"off"`: never emit DEC 2026. Use this if your terminal flashes + /// the whole screen on every redraw — most often Ptyxis on + /// Ubuntu 26.04 today; historically also some legacy ssh+screen + /// stacks. The cost of `off` is brief tearing on terminals that + /// *do* support DEC 2026; it is purely a rendering-quality knob, + /// not a correctness one. + pub synchronized_output: String, } impl Default for Settings { @@ -274,6 +291,7 @@ impl Default for Settings { default_model: None, provider_models: None, status_indicator: "whale".to_string(), + synchronized_output: "auto".to_string(), } } } @@ -315,6 +333,8 @@ impl Settings { s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string(); s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string(); s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string(); + s.synchronized_output = + normalize_synchronized_output(&s.synchronized_output).to_string(); s.locale = normalize_configured_locale(&s.locale) .unwrap_or("en") .to_string(); @@ -349,6 +369,26 @@ impl Settings { self.low_motion = true; self.fancy_animations = false; } + + // Ptyxis 50.x (the new default terminal on Ubuntu 26.04) ships with + // VTE 0.84.x which mishandles DEC mode 2026 synchronized output: the + // begin/end pair is parsed but each wrapped frame still triggers a + // full-viewport flash on the GPU compositor side, so any TUI that + // uses DEC 2026 to avoid tearing instead gets visible flicker on + // every redraw. gnome-terminal 3.58 on the same VTE renders cleanly, + // so we can't broaden the opt-out to all VTE-based terminals — + // only the Ptyxis-specific signals trigger it. Confirmed + // user-visible regression starting with Ubuntu 26.04's default + // terminal swap; cargo-installed binaries are not exempt because + // the bug is in the terminal, not the binary. + // + // Only flip `auto` to `off`; respect an explicit `"on"` so users + // who upgrade Ptyxis or want to confirm the fix landed upstream + // can override the heuristic from `~/.config/deepseek/settings.toml` + // or `/set synchronized_output on`. + if self.synchronized_output.eq_ignore_ascii_case("auto") && detected_ptyxis_terminal() { + self.synchronized_output = "off".to_string(); + } } /// Save settings to disk @@ -445,6 +485,15 @@ impl Settings { } self.status_indicator = normalized.to_string(); } + "synchronized_output" | "sync_output" | "sync" => { + let normalized = normalize_synchronized_output(value); + if !["auto", "on", "off"].contains(&normalized) { + anyhow::bail!( + "Failed to update setting: invalid synchronized_output '{value}'. Expected: auto, on, off." + ); + } + self.synchronized_output = normalized.to_string(); + } "default_mode" | "mode" => { let normalized = normalize_mode(value); if !["agent", "plan", "yolo"].contains(&normalized) { @@ -557,6 +606,10 @@ impl Settings { lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode)); lines.push(format!(" transcript_spacing: {}", self.transcript_spacing)); lines.push(format!(" status_indicator: {}", self.status_indicator)); + lines.push(format!( + " synchronized_output: {}", + self.synchronized_output + )); lines.push(format!(" default_mode: {}", self.default_mode)); lines.push(format!( " sidebar_width: {}%", @@ -629,6 +682,10 @@ impl Settings { "status_indicator", "Header status indicator next to effort chip: whale, dots, off", ), + ( + "synchronized_output", + "DEC 2026 synchronized output: auto, on, off (set off if your terminal flickers)", + ), ("default_mode", "Default mode: agent, plan, yolo"), ("sidebar_width", "Sidebar width percentage: 10-50"), ( @@ -650,6 +707,16 @@ impl Settings { .get_or_insert_with(std::collections::HashMap::new) .insert(provider.to_string(), model.to_string()); } + + /// Resolved boolean for whether the renderer should wrap each frame in + /// DEC mode 2026 synchronized output. `auto` and `on` enable; `off` + /// disables. The `auto` → `off` flip for known-bad terminals happens + /// earlier in [`Self::apply_env_overrides`]; this method only inspects + /// the final state. + #[must_use] + pub fn synchronized_output_enabled(&self) -> bool { + !self.synchronized_output.eq_ignore_ascii_case("off") + } } fn normalize_default_model(value: &str) -> Option { @@ -714,6 +781,45 @@ fn normalize_status_indicator(value: &str) -> &str { } } +/// Normalize the `synchronized_output` setting. Accepts the canonical +/// `"auto"` / `"on"` / `"off"` plus the usual truthy/falsey spellings. +/// Unknown values fall through unchanged so the parser in `set` can +/// surface a clear error. +fn normalize_synchronized_output(value: &str) -> &str { + match value.trim().to_ascii_lowercase().as_str() { + "auto" | "default" => "auto", + "on" | "true" | "yes" | "1" | "enabled" => "on", + "off" | "false" | "no" | "0" | "disabled" => "off", + _ => value, + } +} + +/// Returns `true` when the active terminal is Ptyxis (the new default +/// terminal on Ubuntu 26.04). Used by [`Settings::apply_env_overrides`] +/// to flip `synchronized_output` from `auto` to `off` so DEC mode 2026 +/// flicker on Ptyxis 50.x + VTE 0.84.x stops at the source. +/// +/// We deliberately keep this narrow: +/// +/// - `TERM_PROGRAM` matches `ptyxis` case-insensitively (the value +/// Ptyxis sets when it forwards a process-launch context). +/// - `PTYXIS_VERSION` is set to any non-empty value (the binary's +/// own version probe, present whether or not `TERM_PROGRAM` made it +/// into the child environment). +/// +/// Either signal is sufficient. We do *not* trigger on `VTE_VERSION` +/// alone because gnome-terminal 3.58 ships with the same VTE 0.84.x +/// and renders cleanly — broadening the heuristic would regress every +/// gnome-terminal user. +pub fn detected_ptyxis_terminal() -> bool { + if let Ok(program) = std::env::var("TERM_PROGRAM") + && program.trim().to_ascii_lowercase().contains("ptyxis") + { + return true; + } + matches!(std::env::var("PTYXIS_VERSION"), Ok(v) if !v.trim().is_empty()) +} + fn normalize_optional_background_color(value: Option<&str>) -> Option { value.and_then(|raw| normalize_background_color_setting(raw).ok().flatten()) } @@ -1053,6 +1159,227 @@ mod tests { } } + // ──────────────────────────────────────────────────────────────────────── + // synchronized_output / Ptyxis flicker detection + // ──────────────────────────────────────────────────────────────────────── + + #[test] + fn synchronized_output_defaults_to_auto_and_resolves_to_enabled() { + let s = Settings::default(); + assert_eq!(s.synchronized_output, "auto"); + assert!( + s.synchronized_output_enabled(), + "auto must keep DEC 2026 on so terminals that support it stay tear-free" + ); + } + + #[test] + fn synchronized_output_off_disables_dec_2026() { + let s = Settings { + synchronized_output: "off".to_string(), + ..Settings::default() + }; + assert!(!s.synchronized_output_enabled()); + } + + #[test] + fn synchronized_output_on_keeps_dec_2026_enabled() { + let s = Settings { + synchronized_output: "on".to_string(), + ..Settings::default() + }; + assert!(s.synchronized_output_enabled()); + } + + #[test] + fn synchronized_output_set_command_accepts_aliases() { + let mut s = Settings::default(); + for value in ["auto", "AUTO", "default"] { + s.set("synchronized_output", value).expect("valid"); + assert_eq!(s.synchronized_output, "auto"); + } + for value in ["on", "true", "yes", "1", "ENABLED"] { + s.set("sync_output", value).expect("valid"); + assert_eq!(s.synchronized_output, "on"); + } + for value in ["off", "false", "no", "0", "DISABLED"] { + s.set("sync", value).expect("valid"); + assert_eq!(s.synchronized_output, "off"); + } + let err = s + .set("synchronized_output", "maybe") + .expect_err("unknown value rejected"); + assert!( + err.to_string().contains("synchronized_output"), + "error names the offending key: {err}" + ); + } + + #[test] + fn ptyxis_term_program_flips_synchronized_output_off() { + let _g = term_program_test_guard(); + let prev = std::env::var_os("TERM_PROGRAM"); + let prev_ptyxis = std::env::var_os("PTYXIS_VERSION"); + // SAFETY: serialised by the guard. + unsafe { + std::env::set_var("TERM_PROGRAM", "Ptyxis"); + std::env::remove_var("PTYXIS_VERSION"); + } + let mut s = Settings::default(); + assert_eq!(s.synchronized_output, "auto"); + s.apply_env_overrides(); + assert_eq!( + s.synchronized_output, "off", + "Ptyxis 50.x mishandles DEC 2026 — auto must flip to off so VTE 0.84 stops flickering" + ); + assert!( + !s.synchronized_output_enabled(), + "resolved boolean must agree with stored string" + ); + // SAFETY: cleanup under the guard. + unsafe { + match prev { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + match prev_ptyxis { + Some(v) => std::env::set_var("PTYXIS_VERSION", v), + None => std::env::remove_var("PTYXIS_VERSION"), + } + } + } + + #[test] + fn ptyxis_version_env_alone_flips_synchronized_output_off() { + let _g = term_program_test_guard(); + let prev = std::env::var_os("TERM_PROGRAM"); + let prev_ptyxis = std::env::var_os("PTYXIS_VERSION"); + // SAFETY: serialised by the guard. + unsafe { + std::env::remove_var("TERM_PROGRAM"); + std::env::set_var("PTYXIS_VERSION", "50.1"); + } + let mut s = Settings::default(); + s.apply_env_overrides(); + assert_eq!( + s.synchronized_output, "off", + "PTYXIS_VERSION alone is sufficient — Ptyxis sets this even when TERM_PROGRAM isn't propagated" + ); + // SAFETY: cleanup under the guard. + unsafe { + match prev { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + match prev_ptyxis { + Some(v) => std::env::set_var("PTYXIS_VERSION", v), + None => std::env::remove_var("PTYXIS_VERSION"), + } + } + } + + #[test] + fn ptyxis_does_not_override_user_explicit_on() { + // Users who set `synchronized_output = "on"` (e.g. to confirm a + // Ptyxis upgrade fixed it) must keep DEC 2026 even on Ptyxis. + let _g = term_program_test_guard(); + let prev = std::env::var_os("TERM_PROGRAM"); + // SAFETY: serialised by the guard. + unsafe { + std::env::set_var("TERM_PROGRAM", "ptyxis"); + } + let mut s = Settings { + synchronized_output: "on".to_string(), + ..Settings::default() + }; + s.apply_env_overrides(); + assert_eq!( + s.synchronized_output, "on", + "explicit user override must beat the Ptyxis env heuristic" + ); + // SAFETY: cleanup under the guard. + unsafe { + match prev { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + } + } + + #[test] + fn ptyxis_does_not_override_user_explicit_off() { + // A user with `synchronized_output = "off"` on a non-Ptyxis + // terminal stays off after env detection (no-op flip). + let _g = term_program_test_guard(); + let prev = std::env::var_os("TERM_PROGRAM"); + // SAFETY: serialised by the guard. + unsafe { + std::env::set_var("TERM_PROGRAM", "xterm-256color"); + } + let mut s = Settings { + synchronized_output: "off".to_string(), + ..Settings::default() + }; + s.apply_env_overrides(); + assert_eq!(s.synchronized_output, "off"); + // SAFETY: cleanup under the guard. + unsafe { + match prev { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + } + } + + #[test] + fn non_ptyxis_term_programs_keep_synchronized_output_auto() { + let _g = term_program_test_guard(); + let prev = std::env::var_os("TERM_PROGRAM"); + let prev_ptyxis = std::env::var_os("PTYXIS_VERSION"); + // SAFETY: clean slate so non-Ptyxis programs don't see a leaked + // PTYXIS_VERSION from another test. + unsafe { + std::env::remove_var("PTYXIS_VERSION"); + } + for program in [ + "iTerm.app", + "Apple_Terminal", + "WezTerm", + "xterm-256color", + "gnome-terminal-server", + // The Ghostty / VS Code paths force low_motion but must NOT + // disable DEC 2026 — they handle synchronized output cleanly. + "ghostty", + "vscode", + ] { + // SAFETY: serialised by the guard. + unsafe { + std::env::set_var("TERM_PROGRAM", program); + } + let mut s = Settings::default(); + s.apply_env_overrides(); + assert_eq!( + s.synchronized_output, "auto", + "TERM_PROGRAM={program:?} must not opt out of DEC 2026" + ); + assert!( + s.synchronized_output_enabled(), + "resolved boolean for {program:?} must stay enabled" + ); + } + // SAFETY: cleanup under the guard. + unsafe { + match prev { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + match prev_ptyxis { + Some(v) => std::env::set_var("PTYXIS_VERSION", v), + None => std::env::remove_var("PTYXIS_VERSION"), + } + } + } + // ──────────────────────────────────────────────────────────────────────── // TuiPrefs tests // ──────────────────────────────────────────────────────────────────────── diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index cc5e9921..ed47ba03 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -810,6 +810,14 @@ pub struct App { /// until the footer widget consumes it. #[allow(dead_code)] pub fancy_animations: bool, + /// Whether the renderer should wrap each frame in DEC mode 2026 + /// synchronized output. Resolved from `Settings::synchronized_output` + /// at construction; `auto`/`on` → `true`, `off` → `false`. The Ptyxis + /// auto-detect path in `Settings::apply_env_overrides` flips `auto` + /// to `off` before App is built, so by the time we read this flag in + /// the draw loop the decision is already made. See the + /// `Settings::synchronized_output` doc for the user-facing knob. + pub synchronized_output_enabled: bool, /// Header status-indicator chip mode. One of `"whale"` (default, cycles /// 🐳→🐋 frames keyed off `turn_started_at`), `"dots"` (geometric ◌ /// frames), or `"off"` (chip hidden entirely). Loaded from settings; @@ -1232,6 +1240,7 @@ impl App { let calm_mode = settings.calm_mode; let low_motion = settings.low_motion; let fancy_animations = settings.fancy_animations; + let synchronized_output_enabled = settings.synchronized_output_enabled(); let status_indicator = settings.status_indicator.clone(); let show_thinking = settings.show_thinking; let show_tool_details = settings.show_tool_details; @@ -1425,6 +1434,7 @@ impl App { calm_mode, low_motion, fancy_animations, + synchronized_output_enabled, status_indicator, show_thinking, verbose_transcript: false, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d5d6485f..d71d11b0 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -271,7 +271,18 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { ); let backend = ColorCompatBackend::new(stdout, color_depth, palette_mode); let mut terminal = Terminal::new(backend)?; - reset_terminal_viewport(&mut terminal)?; + // At this point Settings hasn't loaded yet, so we can't read the + // user's `synchronized_output` knob. Use the same env-based Ptyxis + // detection that `Settings::apply_env_overrides` uses, so the + // startup viewport reset matches what every later draw will do on + // this terminal. A user who has explicitly set + // `synchronized_output = "on"` to override Ptyxis detection will + // get sync wrap from the main draw loop onward; the one-time + // startup viewport reset stays opt-out for them, which is the safe + // default because the cost is at most brief tearing on the first + // frame. + let sync_output_at_init = !crate::settings::detected_ptyxis_terminal(); + reset_terminal_viewport(&mut terminal, sync_output_at_init)?; let event_broker = EventBroker::new(); // Local mutable copy so runtime config flips (e.g. `/provider` switch) @@ -1215,6 +1226,7 @@ async fn run_event_loop( app.use_alt_screen, app.use_mouse_capture, app.use_bracketed_paste, + app.synchronized_output_enabled, )?; event_broker.resume_events(); terminal_paused_at = None; @@ -1289,6 +1301,7 @@ async fn run_event_loop( app.use_alt_screen, app.use_mouse_capture, app.use_bracketed_paste, + app.synchronized_output_enabled, )?; event_broker.resume_events(); terminal_paused_at = None; @@ -1550,6 +1563,7 @@ async fn run_event_loop( app.use_alt_screen, app.use_mouse_capture, app.use_bracketed_paste, + app.synchronized_output_enabled, )?; event_broker.resume_events(); terminal_paused_at = None; @@ -1748,7 +1762,7 @@ async fn run_event_loop( ); } - reset_terminal_viewport(terminal)?; + reset_terminal_viewport(terminal, app.synchronized_output_enabled)?; app.handle_resize(final_w, final_h); // #macos-resize: some terminals (macOS Terminal.app, Windows // ConHost) briefly report stale dimensions via @@ -4821,6 +4835,7 @@ async fn apply_command_result( app.use_alt_screen, app.use_mouse_capture, app.use_bracketed_paste, + app.synchronized_output_enabled, )?; match editor_result { Ok(outcome) => { @@ -5801,7 +5816,15 @@ fn draw_app_frame_inner( full_repaint: bool, ) -> Result<()> { terminal.backend_mut().set_palette_mode(app.ui_theme.mode); - let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE); + // DEC 2026 wrapping is on by default but can be turned off for + // terminals that mishandle it (Ptyxis 50.x + VTE 0.84.x flashes the + // whole viewport on every wrapped frame instead of deferring as the + // standard requires). Settings::synchronized_output_enabled resolves + // the user's setting against the Ptyxis env auto-detect. + let wrap_in_sync_update = app.synchronized_output_enabled; + if wrap_in_sync_update { + let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE); + } // Run fallible draw operations in a closure so END_SYNC_UPDATE is // always sent even if an intermediate step fails. Without this, a @@ -5818,7 +5841,9 @@ fn draw_app_frame_inner( })(); // Always end the synchronized update, regardless of success or failure. - let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE); + if wrap_in_sync_update { + let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE); + } let _ = terminal.backend_mut().flush(); result } @@ -6700,6 +6725,7 @@ fn resume_terminal( use_alt_screen: bool, use_mouse_capture: bool, use_bracketed_paste: bool, + sync_output_enabled: bool, ) -> Result<()> { enable_raw_mode()?; if use_alt_screen { @@ -6710,11 +6736,11 @@ fn resume_terminal( use_mouse_capture, use_bracketed_paste, ); - reset_terminal_viewport(terminal)?; + reset_terminal_viewport(terminal, sync_output_enabled)?; Ok(()) } -fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> { +fn reset_terminal_viewport(terminal: &mut AppTerminal, sync_output_enabled: bool) -> 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 @@ -6728,7 +6754,12 @@ fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> { // (`\x1b[?2026h` … `\x1b[?2026l`) so GPU-accelerated terminals // (Ghostty, VSCode, Kitty, WezTerm) defer rendering until the whole // frame is staged. Terminals that don't support it silently ignore. - let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE); + // The wrap is opt-out via `synchronized_output = "off"` for terminals + // that mishandle the sequence (Ptyxis 50.x on VTE 0.84.x flashes the + // whole viewport on each wrapped frame). + if sync_output_enabled { + let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE); + } let result = (|| -> Result<()> { terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?; @@ -6738,7 +6769,9 @@ fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> { })(); // Always end the synchronized update, regardless of success or failure. - let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE); + if sync_output_enabled { + let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE); + } let _ = terminal.backend_mut().flush(); result }