fix(tui): auto-disable DEC 2026 sync output on Ptyxis to stop VTE 0.84 flicker
Ptyxis 50.x (the new default terminal on Ubuntu 26.04) ships with VTE 0.84.x, which parses the `\x1b[?2026h` / `\x1b[?2026l` synchronized- output begin/end pair but still flashes the entire viewport on every wrapped frame instead of deferring rendering. gnome-terminal 3.58 on the same VTE renders cleanly, so the heuristic stays narrow: trigger only on TERM_PROGRAM matching `ptyxis` (case-insensitive) or PTYXIS_VERSION non-empty. Add a new `synchronized_output` setting (`auto` | `on` | `off`, default `auto`) controlling whether the renderer wraps each frame in DEC 2026. `apply_env_overrides` flips `auto` → `off` when Ptyxis is detected; the four wrapping sites in ui.rs (`draw_app_frame_inner`, `reset_terminal_viewport`, `resume_terminal`, and the early-init viewport reset) now respect the resolved flag. Users on Ptyxis who upgrade past an upstream fix or want to confirm one landed can override with `/set synchronized_output on`. 8 new tests cover: default-auto resolves enabled, off disables, on stays enabled, set/aliases, Ptyxis via TERM_PROGRAM, Ptyxis via PTYXIS_VERSION alone, explicit `on` beats the heuristic, explicit `off` is preserved, and no non-Ptyxis TERM_PROGRAM (including Ghostty and VS Code, which both keep DEC 2026 on) regresses. Reported via WeChat by Cyrux on Ubuntu 26.04 with v0.8.30 npm install; analysis by Hunter pinpointed Ptyxis + VTE 0.84 as the cause.
This commit is contained in:
@@ -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 <auto|on|off>`
|
||||
or in `~/.config/deepseek/settings.toml`.
|
||||
|
||||
## [0.8.30] - 2026-05-11
|
||||
|
||||
A "tighten what we shipped" release. Bare single-letter keystrokes
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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<String> {
|
||||
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
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user