fix(settings): reduce motion in VTE flicker terminals

Harvested from PR #1527 by @axobase001.

Co-authored-by: axobase001 <dengzhuoran9@gmail.com>
This commit is contained in:
Hunter Bown
2026-05-12 23:39:44 -05:00
parent ac77f0ff63
commit a4637fe7d1
6 changed files with 113 additions and 8 deletions
+1
View File
@@ -392,6 +392,7 @@ Key environment variables:
| `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, `ollama` |
| `DEEPSEEK_PROFILE` | Config profile name |
| `DEEPSEEK_MEMORY` | Set to `on` to enable user memory |
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks |
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth |
| `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID |
| `SGLANG_BASE_URL` | Self-hosted SGLang endpoint |
+4 -1
View File
@@ -178,7 +178,10 @@ max_subagents = 10 # optional (1-20)
# base_url = "https://integrate.api.nvidia.com/v1"
# model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash
# Generic OpenAI-compatible endpoint
# Generic OpenAI-compatible endpoint. Use the built-in `openai` provider for
# third-party gateways; do not invent a custom provider name. For non-local
# http:// gateways, launch with DEEPSEEK_ALLOW_INSECURE_HTTP=1 only on a
# trusted network.
[providers.openai]
# api_key = "YOUR_OPENAI_COMPATIBLE_API_KEY"
# base_url = "https://api.openai.com/v1"
+65 -5
View File
@@ -372,16 +372,18 @@ impl Settings {
self.low_motion = true;
self.fancy_animations = false;
}
// VS Code (TERM_PROGRAM=vscode, #1356) and Ghostty (TERM_PROGRAM=ghostty,
// #1445) both produce visible flicker at 120 FPS: VS Code's compositor
// cannot keep pace; Ghostty's GPU compositor flash-renders each full-screen
// repaint. Drop to the 30 FPS low-motion cap for both automatically.
// VS Code (TERM_PROGRAM=vscode, #1356), Ghostty (TERM_PROGRAM=ghostty,
// #1445), and a few VTE terminals (#1470) produce visible flicker at
// 120 FPS. Drop to the 30 FPS low-motion cap for them automatically.
// Like NO_ANIMATIONS above, this unconditionally overrides any
// disk-loaded value — consistent precedence: env signals always win.
let vte_env_forces_low_motion = std::env::var_os("TILIX_ID").is_some_and(|v| !v.is_empty())
|| std::env::var_os("TERMINATOR_UUID").is_some_and(|v| !v.is_empty());
if matches!(
std::env::var("TERM_PROGRAM").as_deref(),
Ok("vscode") | Ok("ghostty")
) {
) || vte_env_forces_low_motion
{
self.low_motion = true;
self.fancy_animations = false;
}
@@ -1241,6 +1243,8 @@ mod tests {
let prev = std::env::var_os("TERM_PROGRAM");
let prev_ssh_client = std::env::var_os("SSH_CLIENT");
let prev_ssh_tty = std::env::var_os("SSH_TTY");
let prev_tilix_id = std::env::var_os("TILIX_ID");
let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID");
// SAFETY: serialised by the guard. Clear SSH_* so a real
// SSH session running the test suite doesn't make this
// assertion trivially fail — the SSH path is exercised
@@ -1248,6 +1252,8 @@ mod tests {
unsafe {
std::env::remove_var("SSH_CLIENT");
std::env::remove_var("SSH_TTY");
std::env::remove_var("TILIX_ID");
std::env::remove_var("TERMINATOR_UUID");
}
for program in ["iTerm.app", "Apple_Terminal", "WezTerm", "xterm-256color"] {
// SAFETY: serialised by the guard.
@@ -1273,6 +1279,60 @@ mod tests {
if let Some(v) = prev_ssh_tty {
std::env::set_var("SSH_TTY", v);
}
if let Some(v) = prev_tilix_id {
std::env::set_var("TILIX_ID", v);
}
if let Some(v) = prev_terminator_uuid {
std::env::set_var("TERMINATOR_UUID", v);
}
}
}
#[test]
fn tilix_and_terminator_env_force_low_motion_on() {
let _g = term_program_test_guard();
let prev_term_program = std::env::var_os("TERM_PROGRAM");
let prev_tilix_id = std::env::var_os("TILIX_ID");
let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID");
for (var, val) in [
("TILIX_ID", "d5b5b5d6-tilix-session"),
("TERMINATOR_UUID", "urn:uuid:terminator-session"),
] {
// SAFETY: serialised by the guard.
unsafe {
std::env::remove_var("TERM_PROGRAM");
std::env::remove_var("TILIX_ID");
std::env::remove_var("TERMINATOR_UUID");
std::env::set_var(var, val);
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
settings.apply_env_overrides();
assert!(
settings.low_motion,
"{var} must enable low_motion to prevent VTE flicker (#1470)"
);
assert!(
!settings.fancy_animations,
"{var} must disable fancy_animations"
);
}
// SAFETY: cleanup under the guard.
unsafe {
match prev_term_program {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_tilix_id {
Some(v) => std::env::set_var("TILIX_ID", v),
None => std::env::remove_var("TILIX_ID"),
}
match prev_terminator_uuid {
Some(v) => std::env::set_var("TERMINATOR_UUID", v),
None => std::env::remove_var("TERMINATOR_UUID"),
}
}
}
+6 -1
View File
@@ -6142,6 +6142,11 @@ fn render(f: &mut Frame, app: &mut App) {
crate::config::ApiProvider::Vllm => Some("vLLM"),
crate::config::ApiProvider::Ollama => Some("Ollama"),
};
let status_indicator_started_at = if app.low_motion {
None
} else {
app.turn_started_at
};
let header_data = HeaderData::new(
app.mode,
&model_label,
@@ -6158,7 +6163,7 @@ fn render(f: &mut Frame, app: &mut App) {
.with_reasoning_effort(Some(&effort_label))
.with_provider(provider_label)
.with_status_indicator(crate::tui::widgets::header_status_indicator_frame(
app.turn_started_at,
status_indicator_started_at,
&app.status_indicator,
));
let header_widget = HeaderWidget::new(header_data);
+8 -1
View File
@@ -10,8 +10,9 @@ visual motion and density for screen-reader and low-motion users.
| Toggle | Default | Effect |
| --- | --- | --- |
| `NO_ANIMATIONS=1` env var | unset | At startup, forces `low_motion = true` and `fancy_animations = false`. Overrides whatever's saved in `settings.toml`. |
| `low_motion` setting | `false` | Suppresses spinners' motion, transcript fade-ins, footer drift, and the active-cell pulse. The frame-rate limiter also slows down idle redraws so the cursor doesn't blink as aggressively. |
| `low_motion` setting | `false` | Suppresses spinners' motion, transcript fade-ins, footer drift, the header status-indicator cycle, and the active-cell pulse. The frame-rate limiter also slows down idle redraws so the cursor doesn't blink as aggressively. |
| `fancy_animations` setting | `false` | Footer water-spout strip and pulsing sub-agent counter. Off by default. |
| `status_indicator` setting | `whale` | Header status chip. Set to `dots` for the compact dot cycle or `off` to hide it. |
| `calm_mode` setting | `false` | Collapses tool-output details by default and trims status messages. Useful for screen readers that announce every redraw. |
| `show_thinking` setting | `true` | Set to `false` to hide model `reasoning_content` blocks entirely. |
| `show_tool_details` setting | `true` | Set to `false` to render tool calls as one-liners without expanded payloads. |
@@ -43,11 +44,17 @@ The same toggles are reachable from the command palette:
* `/settings set low_motion on`
* `/settings set fancy_animations off`
* `/settings set calm_mode on`
* `/settings set status_indicator off`
Settings written this way persist to `~/.config/deepseek/settings.toml`.
The `NO_ANIMATIONS` env var still wins at startup if it's set, so
unsetting the env var is the way to honor your saved choice.
Tilix and Terminator sessions automatically start in low-motion mode because
those VTE-based terminals have reported visible redraw flicker during active
turns. You can still override the saved settings after launch if your terminal
version renders cleanly.
## Notes for screen-reader users
* `low_motion` slows the idle redraw loop to ~120ms per frame so
+29
View File
@@ -82,6 +82,35 @@ URLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store
unless API-key auth is explicitly requested; use an env var or config-file key
when a local server does require bearer auth.
### Custom OpenAI-Compatible Gateways
For a third-party service that implements the OpenAI Chat Completions API, use
the built-in `openai` provider name and point its provider table at the gateway:
```toml
provider = "openai"
default_text_model = "your-model-id"
[providers.openai]
api_key = "YOUR_OPENAI_COMPATIBLE_API_KEY"
base_url = "https://your-gateway.example/v1"
```
Do not invent a custom provider name; `provider` must be one of the known
providers listed above. Put the endpoint under `[providers.openai]`, not the
legacy top-level `base_url`, so the OpenAI-compatible provider receives it.
`default_text_model` is the model ID sent to the gateway; if you keep several
provider tables in one config, `[providers.openai].model` can be used as the
OpenAI-provider-specific override.
Local HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default
when they use localhost or loopback addresses. For a non-local `http://`
gateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:
```bash
DEEPSEEK_ALLOW_INSECURE_HTTP=1 deepseek
```
Third-party OpenAI-compatible gateways that need extra request headers can set
`http_headers = { "X-Model-Provider-Id" = "your-model-provider" }` at the top
level or under a provider table such as `[providers.deepseek]`. When configured,