From e5fe46db4f0500cbea5b76502ac1f88db34d76db Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 21:22:15 -0700 Subject: [PATCH] feat(tui): expose stream chunk timeout config Harvested from PR #2507 by @cyq1017. Reported by @mserrano11 in #2365. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- CHANGELOG.md | 4 + README.md | 2 +- config.example.toml | 1 + crates/tui/CHANGELOG.md | 4 + crates/tui/src/client.rs | 19 +++ crates/tui/src/client/chat.rs | 19 +-- crates/tui/src/commands/config.rs | 206 +++++++++++++++++++++++- crates/tui/src/config.rs | 104 ++++++++++++ crates/tui/src/core/engine.rs | 18 ++- crates/tui/src/core/engine/streaming.rs | 37 ----- crates/tui/src/core/engine/turn_loop.rs | 26 ++- crates/tui/src/core/ops.rs | 3 + crates/tui/src/main.rs | 5 + crates/tui/src/runtime_threads.rs | 3 + crates/tui/src/tui/app.rs | 4 + crates/tui/src/tui/ui.rs | 11 ++ crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/tui/views/mod.rs | 11 ++ docs/CONFIGURATION.md | 1 + 19 files changed, 420 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 043ed22c..28b1f67f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `completion_sound = "file"` with `[notifications].sound_file` so Windows users can play a custom WAV file for turn-completion sounds without changing the global Windows sound scheme (#2484, #2512). +- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs` + so slow local or OpenAI-compatible model servers can extend the SSE idle + timeout without mutating process environment. The legacy + `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). ### Changed diff --git a/README.md b/README.md index 87376526..508113f5 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Key environment variables: | `DEEPSEEK_BASE_URL` | API base URL | | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | -| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | +| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Legacy stream idle timeout env override, default `300`, clamped to `1..=3600`; `[tui].stream_chunk_timeout_secs` takes precedence when configured | | `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | diff --git a/config.example.toml b/config.example.toml index a2129c05..1f56333b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -472,6 +472,7 @@ max_subagents = 10 # optional (1-20) alternate_screen = "auto" # auto/always use the TUI screen; never uses terminal scrollback mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms) +stream_chunk_timeout_secs = 300 # optional SSE idle timeout per chunk (0 = default, 1-3600) osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender # Ordered footer chips shown in the TUI status line. Omit the key to use the # built-in default; set [] to hide all configurable chips. You can also edit diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 043ed22c..28b1f67f 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `completion_sound = "file"` with `[notifications].sound_file` so Windows users can play a custom WAV file for turn-completion sounds without changing the global Windows sound scheme (#2484, #2512). +- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs` + so slow local or OpenAI-compatible model servers can extend the SSE idle + timeout without mutating process environment. The legacy + `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). ### Changed diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 81745b8c..74c4713d 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -158,6 +158,7 @@ pub struct DeepSeekClient { connection_health: Arc>, rate_limiter: Arc>, path_suffix: Option, + pub(super) stream_idle_timeout: Duration, } const CONNECTION_FAILURE_THRESHOLD: u32 = 2; @@ -325,6 +326,7 @@ impl Clone for DeepSeekClient { connection_health: self.connection_health.clone(), rate_limiter: self.rate_limiter.clone(), path_suffix: self.path_suffix.clone(), + stream_idle_timeout: self.stream_idle_timeout, } } } @@ -581,6 +583,7 @@ impl DeepSeekClient { validate_base_url_security(&base_url)?; let retry = config.retry_policy(); let default_model = config.default_model(); + let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs()); let http_headers = config.http_headers(); let path_suffix = config .provider_config_for(api_provider) @@ -615,6 +618,7 @@ impl DeepSeekClient { connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())), rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())), path_suffix, + stream_idle_timeout, }) } @@ -1683,6 +1687,21 @@ mod tests { assert!(headers.get("x-blank").is_none()); } + #[test] + fn client_stream_idle_timeout_uses_tui_config() { + let client = DeepSeekClient::new(&Config { + api_key: Some("sk-test".to_string()), + tui: Some(crate::config::TuiConfig { + stream_chunk_timeout_secs: Some(777), + ..crate::config::TuiConfig::default() + }), + ..Config::default() + }) + .expect("client"); + + assert_eq!(client.stream_idle_timeout, Duration::from_secs(777)); + } + #[test] fn xiaomi_mimo_token_plan_endpoint_uses_api_key_header() { let headers = DeepSeekClient::default_headers_for_provider( diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index a3ecf4b4..6acc77e6 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -16,11 +16,6 @@ use tokio::time::timeout as tokio_timeout; use crate::config::wire_model_for_provider; -/// Default idle timeout for SSE stream reads (300 seconds = 5 minutes). -/// After this period with no data, the stream is considered stalled and -/// yields a recoverable error so the caller can retry. -const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300); - /// Default timeout for the initial streaming response headers. /// /// `doctor` uses a bounded non-streaming request, but normal TUI turns first @@ -48,17 +43,6 @@ fn stream_open_timeout_from_env(value: Option<&str>) -> Duration { Duration::from_secs(secs) } -/// Reads the `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var, falling back to -/// the default 300s. The parsed value is clamped to [1, 3600] seconds. -fn stream_idle_timeout() -> Duration { - let secs = std::env::var("DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_STREAM_IDLE_TIMEOUT.as_secs()) - .clamp(1, 3600); - Duration::from_secs(secs) -} - use crate::config::ApiProvider; use crate::llm_client::StreamEventBox; use crate::llm_client::sanitize_http_error_body; @@ -283,6 +267,7 @@ impl DeepSeekClient { // gzip-compressor failure when investigating #103. let response_headers = format_stream_headers(response.headers()); let byte_stream = response.bytes_stream(); + let stream_idle_timeout = self.stream_idle_timeout; let stream = async_stream::stream! { use futures_util::StreamExt; @@ -315,7 +300,7 @@ impl DeepSeekClient { let is_reasoning_model = is_reasoning_model_for_stream(api_provider, &model); let mut byte_stream = std::pin::pin!(byte_stream); - let idle = stream_idle_timeout(); + let idle = stream_idle_timeout; // Telemetry for #103 stream-decode diagnostics: bytes received // since the start of this stream and last successful event time. diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index c7718fb6..9f860ecd 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -6,7 +6,8 @@ use std::time::Duration; use super::CommandResult; use crate::client::DeepSeekClient; use crate::config::{ - ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_XIAOMI_MIMO_BASE_URL, + ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, + DEFAULT_XIAOMI_MIMO_BASE_URL, MAX_STREAM_CHUNK_TIMEOUT_SECS, MIN_STREAM_CHUNK_TIMEOUT_SECS, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, effective_home_dir, expand_path, normalize_model_name_for_provider, }; @@ -152,6 +153,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { }; Some(config.deepseek_base_url()) } + "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()), "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), "theme" | "ui_theme" => { Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string()) @@ -417,6 +419,45 @@ fn persist_root_bool_key( Ok(path) } +fn persist_tui_integer_key( + config_path: Option<&Path>, + key: &str, + value: u64, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let tui_entry = table + .entry("tui".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let tui_table = tui_entry + .as_table_mut() + .context("`tui` section in config.toml must be a table")?; + let value = i64::try_from(value).context("integer value is too large for TOML")?; + tui_table.insert(key.to_string(), toml::Value::Integer(value)); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + fn persist_provider_base_url_key( config_path: Option<&Path>, provider: ApiProvider, @@ -525,6 +566,14 @@ fn parse_config_bool(value: &str) -> Result { } } +fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { + if raw == 0 { + format!("0 (default {resolved})") + } else { + resolved.to_string() + } +} + /// Resolve the path to `~/.codewhale/config.toml` (or /// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we /// never write to a different file than the one we read. @@ -729,6 +778,55 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.", ); } + "stream_chunk_timeout_secs" => { + let raw = match value.trim().parse::() { + Ok(value) => value, + Err(_) => { + return CommandResult::error( + "stream_chunk_timeout_secs must be a whole number", + ); + } + }; + if raw != 0 + && !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw) + { + return CommandResult::error(format!( + "stream_chunk_timeout_secs must be 0 or {}..={}", + MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS + )); + } + let resolved = if raw == 0 { + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + } else { + raw + }; + app.stream_chunk_timeout_secs = resolved; + let value_label = stream_chunk_timeout_value_label(raw, resolved); + if persist { + match persist_tui_integer_key( + app.config_path.as_deref(), + "stream_chunk_timeout_secs", + raw, + ) { + Ok(path) => { + return CommandResult::with_message_and_action( + format!( + "stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)", + path.display() + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } + return CommandResult::with_message_and_action( + format!( + "stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)" + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } _ => {} } @@ -2371,6 +2469,112 @@ mod tests { assert!(saved.contains("base_url = \"https://example.session.local/v1\"")); } + #[test] + fn config_command_stream_chunk_timeout_session_query_uses_live_value() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90")); + assert!(!result.is_error); + assert_eq!(app.stream_chunk_timeout_secs, 90); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(90)) + )); + + let query = config_command(&mut app, Some("stream_chunk_timeout_secs")); + assert_eq!( + query.message.as_deref(), + Some("stream_chunk_timeout_secs = 90") + ); + } + + #[test] + fn config_command_stream_chunk_timeout_save_persists_tui_key() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-stream-timeout-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join("custom-config.toml"); + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)", + config_path.display() + ) + ); + assert!(saved.contains("[tui]")); + assert!(saved.contains("stream_chunk_timeout_secs = 120")); + assert_eq!(app.stream_chunk_timeout_secs, 120); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(120)) + )); + } + + #[test] + fn config_command_stream_chunk_timeout_rejects_invalid_input() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc")); + assert!(text.is_error); + assert!( + text.message + .unwrap() + .contains("stream_chunk_timeout_secs must be a whole number") + ); + + let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601")); + assert!(high.is_error); + assert!( + high.message + .unwrap() + .contains("stream_chunk_timeout_secs must be 0 or 1..=3600") + ); + } + + #[test] + fn config_command_stream_chunk_timeout_zero_reports_effective_default() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0")); + + assert!(!result.is_error); + assert_eq!( + app.stream_chunk_timeout_secs, + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + assert_eq!( + result.message.as_deref(), + Some( + "stream_chunk_timeout_secs = 0 (default 300) (session only; affects subsequent turns in this session)" + ) + ); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout( + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + )) + )); + } + #[test] fn config_command_provider_url_token_plan_persists_provider_base_url() { let temp_root = env::temp_dir().join(format!( diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 20f752a1..02c36a2f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -39,6 +39,13 @@ pub const DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 300; pub const MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 30; /// Maximum accepted `[subagents] heartbeat_timeout_secs` (1 hour). pub const MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 3600; +/// Default per-SSE-chunk idle timeout, in seconds. +pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300; +/// Minimum accepted stream chunk timeout. +pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1; +/// Maximum accepted stream chunk timeout. +pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600; +pub(crate) const STREAM_CHUNK_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS"; pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; @@ -835,6 +842,9 @@ pub struct TuiConfig { /// Timeout for startup terminal mode/probe calls in milliseconds. /// Defaults to 500ms when omitted. pub terminal_probe_timeout_ms: Option, + /// Per-SSE-chunk idle timeout in seconds. Defaults to 300 seconds when + /// omitted. `0` maps to the default; values clamp to `1..=3600`. + pub stream_chunk_timeout_secs: Option, /// Ordered list of footer items the user wants visible. `None` (the field /// missing from `config.toml`) means "use the built-in default order"; an /// empty `Some(vec![])` means "show nothing in the footer". @@ -2784,6 +2794,30 @@ impl Config { configured.max(min_for_api) } + /// Resolved per-SSE-chunk idle timeout in seconds. + /// + /// Reads `[tui].stream_chunk_timeout_secs`, falling back to the legacy + /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var when the config key is + /// omitted. `None` or `0` resolve to the default 300 seconds; explicit + /// values are clamped to `1..=3600`. + #[must_use] + pub fn stream_chunk_timeout_secs(&self) -> u64 { + let raw = self + .tui + .as_ref() + .and_then(|cfg| cfg.stream_chunk_timeout_secs) + .or_else(|| { + std::env::var(STREAM_CHUNK_TIMEOUT_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS); + if raw == 0 { + return DEFAULT_STREAM_CHUNK_TIMEOUT_SECS; + } + raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS) + } + /// Raw sub-agent model override map. Values are validated at spawn time /// so an invalid role/type model fails before any partial agent spawn. #[must_use] @@ -6417,6 +6451,76 @@ mod tests { ); } + #[test] + fn tui_stream_chunk_timeout_defaults_env_and_clamps() { + let _lock = lock_test_env(); + let previous = env::var_os(STREAM_CHUNK_TIMEOUT_ENV); + unsafe { + env::remove_var(STREAM_CHUNK_TIMEOUT_ENV); + } + + assert_eq!( + Config::default().stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + let zero = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(0), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + zero.stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + let explicit_min = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(MIN_STREAM_CHUNK_TIMEOUT_SECS), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + explicit_min.stream_chunk_timeout_secs(), + MIN_STREAM_CHUNK_TIMEOUT_SECS + ); + + let high = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(MAX_STREAM_CHUNK_TIMEOUT_SECS + 1), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + high.stream_chunk_timeout_secs(), + MAX_STREAM_CHUNK_TIMEOUT_SECS + ); + + unsafe { + env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "123"); + } + assert_eq!(Config::default().stream_chunk_timeout_secs(), 123); + + unsafe { + env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "0"); + } + assert_eq!( + Config::default().stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + unsafe { + match previous { + Some(value) => env::set_var(STREAM_CHUNK_TIMEOUT_ENV, value), + None => env::remove_var(STREAM_CHUNK_TIMEOUT_ENV), + } + } + } + #[test] fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> { // `save_api_key` writes to the shared user config file. This diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 5f58d925..ef92332a 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -351,6 +351,10 @@ pub struct EngineConfig { /// once at engine construction, then threaded onto every /// `SubAgentRuntime` the engine builds (#1806, #1808). pub subagent_api_timeout: Duration, + /// Per-SSE-chunk idle timeout for streamed model responses. + /// Resolved from `[tui].stream_chunk_timeout_secs` (or the legacy + /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS`) and updated live by `/config`. + pub stream_chunk_timeout: Duration, /// No-progress heartbeat timeout for live sub-agents. Used by the manager /// and parent wait loop to auto-cancel stuck children before they exhaust /// the sub-agent slot pool indefinitely (#2614). @@ -414,6 +418,9 @@ impl Default for EngineConfig { subagent_api_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS, ), + stream_chunk_timeout: Duration::from_secs( + crate::config::DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, + ), subagent_heartbeat_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, ), @@ -1271,6 +1278,15 @@ impl Engine { ))) .await; } + Op::SetStreamChunkTimeout { timeout_secs } => { + self.config.stream_chunk_timeout = Duration::from_secs(timeout_secs); + let _ = self + .tx_event + .send(Event::status(format!( + "Stream chunk timeout set to {timeout_secs}s" + ))) + .await; + } Op::SyncSession { session_id, messages, @@ -2745,7 +2761,7 @@ use self::streaming::{ ContentBlockKind, FAKE_WRAPPER_NOTICE, MAX_STREAM_ERRORS_BEFORE_FAIL, MAX_TRANSPARENT_STREAM_RETRIES, STREAM_MAX_CONTENT_BYTES, STREAM_MAX_DURATION_SECS, ToolUseState, contains_fake_tool_wrapper, filter_tool_call_delta, - should_transparently_retry_stream, stream_chunk_timeout_secs, + should_transparently_retry_stream, }; use self::tool_catalog::{ CODE_EXECUTION_TOOL_NAME, JS_EXECUTION_TOOL_NAME, MULTI_TOOL_PARALLEL_NAME, diff --git a/crates/tui/src/core/engine/streaming.rs b/crates/tui/src/core/engine/streaming.rs index 0da4d5ae..35adca04 100644 --- a/crates/tui/src/core/engine/streaming.rs +++ b/crates/tui/src/core/engine/streaming.rs @@ -22,26 +22,6 @@ pub(super) struct ToolUseState { pub(super) input_buffer: String, } -/// Default maximum time to wait for a single stream chunk before assuming a stall. -/// **This is the idle timeout** — it resets on every SSE chunk, so long -/// thinking turns that ARE producing reasoning_content stay alive. Only a -/// genuine `chunk_timeout` window of silence kills the stream. -const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300; -const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1; -const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600; -const STREAM_IDLE_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS"; - -/// Reads the shared stream idle-timeout override used by the SSE client. -pub(super) fn stream_chunk_timeout_secs() -> u64 { - stream_chunk_timeout_secs_from_env(std::env::var(STREAM_IDLE_TIMEOUT_ENV).ok().as_deref()) -} - -fn stream_chunk_timeout_secs_from_env(value: Option<&str>) -> u64 { - value - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS) - .clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS) -} /// Maximum total bytes of text/thinking content before aborting the stream. pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB /// Sanity backstop for total stream wall-clock duration. **Not** a routine @@ -150,20 +130,3 @@ pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> St output } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stream_chunk_timeout_defaults_and_clamps_env_values() { - assert_eq!(stream_chunk_timeout_secs_from_env(None), 300); - assert_eq!( - stream_chunk_timeout_secs_from_env(Some("not-a-number")), - 300 - ); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("0")), 1); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("90")), 90); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("99999")), 3600); - } -} diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index de71c5fa..bcb3dc3a 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -469,8 +469,7 @@ impl Engine { // budget restarts with the fresh stream. let mut stream_start = Instant::now(); let mut stream_content_bytes: usize = 0; - let chunk_timeout_secs = stream_chunk_timeout_secs(); - let chunk_timeout = Duration::from_secs(chunk_timeout_secs); + let (chunk_timeout_secs, chunk_timeout) = stream_chunk_timeout_budget(&self.config); let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS); // Process stream events @@ -2293,6 +2292,29 @@ fn should_hold_turn_for_subagents(queued_completions: usize, running_children: u queued_completions > 0 || running_children > 0 } +fn stream_chunk_timeout_budget(config: &EngineConfig) -> (u64, Duration) { + let secs = config.stream_chunk_timeout.as_secs(); + (secs, Duration::from_secs(secs)) +} + +#[cfg(test)] +mod stream_timeout_tests { + use super::*; + + #[test] + fn stream_chunk_timeout_budget_uses_engine_config() { + let config = EngineConfig { + stream_chunk_timeout: Duration::from_secs(42), + ..EngineConfig::default() + }; + + assert_eq!( + stream_chunk_timeout_budget(&config), + (42, Duration::from_secs(42)) + ); + } +} + fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool { let Some(allowed_tools) = allowed_tools else { return true; diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 4260cf0c..0889c5b2 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -84,6 +84,9 @@ pub enum Op { /// Update auto-compaction settings SetCompaction { config: CompactionConfig }, + /// Update the SSE idle timeout used for subsequent streamed turns. + SetStreamChunkTimeout { timeout_secs: u64 }, + /// Sync engine session state (used for resume/load) SyncSession { session_id: Option, diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 7c9815b7..d5d2dfae 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5765,6 +5765,7 @@ async fn run_exec_agent( runtime_services: crate::tools::spec::RuntimeToolServices::default(), subagent_model_overrides: config.subagent_model_overrides(), subagent_api_timeout: std::time::Duration::from_secs(config.subagent_api_timeout_secs()), + stream_chunk_timeout: std::time::Duration::from_secs(config.stream_chunk_timeout_secs()), subagent_heartbeat_timeout: std::time::Duration::from_secs( config.subagent_heartbeat_timeout_secs(), ), @@ -6743,6 +6744,7 @@ mod terminal_mode_tests { alternate_screen: Some("never".to_string()), mouse_capture: None, terminal_probe_timeout_ms: None, + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, composer_arrows_scroll: None, @@ -6836,6 +6838,7 @@ mod terminal_mode_tests { alternate_screen: None, mouse_capture: Some(false), terminal_probe_timeout_ms: None, + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, composer_arrows_scroll: None, @@ -6867,6 +6870,7 @@ mod terminal_mode_tests { alternate_screen: None, mouse_capture: Some(true), terminal_probe_timeout_ms: None, + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, composer_arrows_scroll: None, @@ -6952,6 +6956,7 @@ mod terminal_mode_tests { alternate_screen: None, mouse_capture: Some(true), terminal_probe_timeout_ms: None, + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, composer_arrows_scroll: None, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 48bf3e44..64369523 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2065,6 +2065,9 @@ impl RuntimeThreadManager { subagent_api_timeout: std::time::Duration::from_secs( self.config.subagent_api_timeout_secs(), ), + stream_chunk_timeout: std::time::Duration::from_secs( + self.config.stream_chunk_timeout_secs(), + ), subagent_heartbeat_timeout: std::time::Duration::from_secs( self.config.subagent_heartbeat_timeout_secs(), ), diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index d2315448..34b56aaf 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1407,6 +1407,8 @@ pub struct App { pub max_input_history: usize, pub allow_shell: bool, pub max_subagents: usize, + /// Per-SSE-chunk idle timeout for streamed turns, in seconds. + pub stream_chunk_timeout_secs: u64, /// Cached sub-agent snapshots for UI views. pub subagent_cache: Vec, /// Last known per-agent progress text for running sub-agents. @@ -2136,6 +2138,7 @@ impl App { max_input_history, allow_shell, max_subagents, + stream_chunk_timeout_secs: config.stream_chunk_timeout_secs(), subagent_cache: Vec::new(), agent_progress: HashMap::new(), subagent_card_index: HashMap::new(), @@ -5047,6 +5050,7 @@ pub enum AppAction { model: Option, }, UpdateCompaction(CompactionConfig), + UpdateStreamChunkTimeout(u64), OpenContextInspector, CompactContext, PurgeContext, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1a814bd5..0ec107e7 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -908,6 +908,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { runtime_services: app.runtime_services.clone(), subagent_model_overrides: config.subagent_model_overrides(), subagent_api_timeout: Duration::from_secs(config.subagent_api_timeout_secs()), + stream_chunk_timeout: Duration::from_secs(app.stream_chunk_timeout_secs), subagent_heartbeat_timeout: Duration::from_secs(config.subagent_heartbeat_timeout_secs()), prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), @@ -5804,6 +5805,11 @@ async fn apply_command_result( AppAction::UpdateCompaction(compaction) => { apply_model_and_compaction_update(engine_handle, compaction, app.mode).await; } + AppAction::UpdateStreamChunkTimeout(timeout_secs) => { + let _ = engine_handle + .send(Op::SetStreamChunkTimeout { timeout_secs }) + .await; + } AppAction::OpenConfigEditor(mode) => match mode { ConfigUiMode::Native => { if app.view_stack.top_kind() != Some(ModalKind::Config) { @@ -7407,6 +7413,11 @@ async fn handle_view_events( apply_model_and_compaction_update(engine_handle, compaction, app.mode) .await; } + AppAction::UpdateStreamChunkTimeout(timeout_secs) => { + let _ = engine_handle + .send(Op::SetStreamChunkTimeout { timeout_secs }) + .await; + } AppAction::OpenConfigView => {} _ => {} } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index f45686c1..b5ba6f09 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1937,6 +1937,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() { alternate_screen: None, mouse_capture: None, terminal_probe_timeout_ms: Some(750), + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, notification_condition: None, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 042357e8..4b2287b7 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -532,6 +532,7 @@ enum ConfigSection { Provider, Model, Permissions, + Network, Display, Composer, Sidebar, @@ -545,6 +546,7 @@ impl ConfigSection { ConfigSection::Provider => "Provider", ConfigSection::Model => "Model", ConfigSection::Permissions => "Permissions", + ConfigSection::Network => "Network", ConfigSection::Display => "Display", ConfigSection::Composer => "Composer", ConfigSection::Sidebar => "Sidebar", @@ -658,6 +660,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Network, + key: "stream_chunk_timeout_secs".to_string(), + value: app.stream_chunk_timeout_secs.to_string(), + editable: true, + scope: ConfigScope::Session, + }, ConfigRow { section: ConfigSection::Display, key: "theme".to_string(), @@ -2371,6 +2380,7 @@ mod tests { ConfigSection::Provider.label(), ConfigSection::Model.label(), ConfigSection::Permissions.label(), + ConfigSection::Network.label(), ConfigSection::Display.label(), ConfigSection::Composer.label(), ConfigSection::Sidebar.label(), @@ -2395,6 +2405,7 @@ mod tests { assert!(keys.contains(&"base_url")); assert!(keys.contains(&"approval_mode")); assert!(keys.contains(&"allow_shell")); + assert!(keys.contains(&"stream_chunk_timeout_secs")); assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); assert!(keys.contains(&"background_color")); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 42cd3bd4..5b82a66b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -966,6 +966,7 @@ If you are upgrading from older releases: - `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport. - `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection. - `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely. +- `tui.stream_chunk_timeout_secs` (int, optional, default `300`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs `; `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted. - `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes. - `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`). - `features.*` (optional): feature flag overrides (see below).