diff --git a/config.example.toml b/config.example.toml index b4d21c158..742126f44 100644 --- a/config.example.toml +++ b/config.example.toml @@ -413,6 +413,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 stream stall timeout (seconds) (1-3600); 0 uses default 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 # notification_condition = "always" # always | never — overrides [notifications].threshold_secs. # "always" = notify on every successful turn (no threshold); diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index fad755e1a..bafce988e 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -6,8 +6,9 @@ use std::time::Duration; use super::CommandResult; use crate::client::DeepSeekClient; use crate::config::{ - COMMON_DEEPSEEK_MODELS, Config, clear_api_key, effective_home_dir, expand_path, - normalize_model_name_for_provider, + COMMON_DEEPSEEK_MODELS, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, + MAX_STREAM_CHUNK_TIMEOUT_SECS, MIN_STREAM_CHUNK_TIMEOUT_SECS, clear_api_key, + effective_home_dir, expand_path, normalize_model_name_for_provider, }; use crate::config_ui::{ConfigUiMode, parse_mode}; use crate::llm_client::LlmClient; @@ -136,6 +137,16 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { }; Some(config.deepseek_base_url()) } + "stream_chunk_timeout_secs" => { + let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) + { + Ok(config) => config, + Err(err) => { + return CommandResult::error(format!("Failed to load config: {err}")); + } + }; + Some(config.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()) @@ -366,6 +377,54 @@ pub fn persist_root_string_key( Ok(path) } +pub 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("config.toml [tui] must be a table")?; + let value = i64::try_from(value).context("config value is too large for TOML integer")?; + 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 stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { + if raw == 0 { + format!("0 (default {resolved})") + } else { + raw.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. @@ -490,6 +549,58 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "base_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 value = value.trim(); + let parsed = match value.parse::() { + Ok(v) => v, + Err(_) => { + return CommandResult::error( + "stream_chunk_timeout_secs must be a whole number", + ); + } + }; + if parsed != 0 + && !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS) + .contains(&parsed) + { + 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 parsed == 0 { + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + } else { + parsed + }; + app.stream_chunk_timeout_secs = resolved; + if persist { + match persist_tui_integer_key( + app.config_path.as_deref(), + "stream_chunk_timeout_secs", + parsed, + ) { + Ok(path) => { + let value_label = stream_chunk_timeout_value_label(parsed, resolved); + 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}")), + } + } + let value_label = stream_chunk_timeout_value_label(parsed, resolved); + return CommandResult::with_message_and_action( + format!( + "stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)" + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } _ => {} } @@ -2010,6 +2121,91 @@ mod tests { assert!(saved.contains("base_url = \"https://example.session.local/v1\"")); } + #[test] + fn config_command_stream_chunk_timeout_secs_save_persists_value() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-stream-chunk-timeout-save-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90 --save")); + let msg = result.message.unwrap(); + let saved_path = config_toml_path(None).unwrap(); + let saved = fs::read_to_string(&saved_path).unwrap(); + + assert_eq!( + msg, + format!( + "stream_chunk_timeout_secs = 90 (saved to {}; affects subsequent turns in this session)", + saved_path.display() + ) + ); + assert!(saved.contains("[tui]")); + assert!(saved.contains("stream_chunk_timeout_secs = 90")); + assert_eq!(app.stream_chunk_timeout_secs, 90); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(90)) + )); + } + + #[test] + fn config_command_stream_chunk_timeout_secs_rejects_invalid_input() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("stream_chunk_timeout_secs abc")); + assert!(result.is_error); + let msg = result.message.unwrap(); + assert!( + msg.contains("stream_chunk_timeout_secs must be a whole number"), + "got {msg}" + ); + } + + #[test] + fn config_command_stream_chunk_timeout_secs_rejects_out_of_range_value() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 3601")); + assert!(result.is_error); + let msg = result.message.unwrap(); + assert!( + msg.contains("stream_chunk_timeout_secs must be 0 or 1..=3600"), + "got {msg}" + ); + } + + #[test] + fn config_command_stream_chunk_timeout_secs_zero_uses_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!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout( + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + )) + )); + let msg = result.message.expect("status message"); + assert!( + msg.contains("stream_chunk_timeout_secs = 0 (default 300)"), + "got {msg}" + ); + } + #[test] fn theme_command_accepts_grayscale_arg() { let nanos = SystemTime::now() diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 10dd8493b..204f10cda 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -24,6 +24,10 @@ pub const MAX_SUBAGENTS: usize = 20; /// Matches the legacy hardcoded value so existing configs keep their old /// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808). pub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120; +/// Default per-stream chunk idle timeout, in seconds, used by the SSE response +/// path. This mirrors `streaming.rs`'s default and guards long-hanging model +/// streams while allowing normal token-level heartbeats to pass. +pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300; /// Minimum accepted `[subagents] api_timeout_secs`. Anything lower (including /// `0`, which would otherwise produce an immediate timeout footgun) clamps /// up to this value before the runtime sees it. @@ -32,6 +36,11 @@ pub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1; /// keeps a misconfigured per-step timeout from masking real model/network /// hangs forever. pub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800; +/// Minimum accepted stream chunk timeout (seconds). +pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1; +/// Maximum accepted stream chunk timeout (seconds). +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"; @@ -660,6 +669,14 @@ pub struct TuiConfig { /// Timeout for startup terminal mode/probe calls in milliseconds. /// Defaults to 500ms when omitted. pub terminal_probe_timeout_ms: Option, + /// Timeout for streaming chunk stalls in seconds. The value controls the + /// idle window in the engine's SSE stream loop: no incoming chunk before + /// this timeout cancels the current turn as stalled. + /// + /// Defaults to 300 seconds when omitted. `0` maps to this default. + /// Values are clamped to `[1, 3600]` to avoid immediate timeouts on + /// legitimate stalls and runaway hang states. + 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". @@ -2442,6 +2459,32 @@ impl Config { raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS) } + /// Resolved per-stream chunk idle timeout in seconds. + /// + /// Reads `[tui].stream_chunk_timeout_secs` and returns a safe value for + /// `DEFAULT_STREAM_CHUNK_TIMEOUT_SECS`. `None` falls back to the legacy + /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var when present, otherwise the + /// default. `0` resolves to the default, and explicit values are clamped to + /// `[MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS]`. + #[must_use] + pub fn stream_chunk_timeout_secs(&self) -> u64 { + let raw = match self + .tui + .as_ref() + .and_then(|tui| tui.stream_chunk_timeout_secs) + { + Some(raw) => raw, + None => 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] @@ -5520,6 +5563,68 @@ mod tests { ); } + #[test] + fn tui_stream_chunk_timeout_defaults_and_clamps() { + assert_eq!( + Config::default().stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + let explicit_zero = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(0), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + explicit_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 explicit_max = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(MAX_STREAM_CHUNK_TIMEOUT_SECS + 10), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + explicit_max.stream_chunk_timeout_secs(), + MAX_STREAM_CHUNK_TIMEOUT_SECS + ); + } + + #[test] + fn stream_chunk_timeout_reads_legacy_env_when_config_omitted() { + let _lock = lock_test_env(); + let previous = env::var_os(STREAM_CHUNK_TIMEOUT_ENV); + + unsafe { + env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "123"); + } + assert_eq!(Config::default().stream_chunk_timeout_secs(), 123); + + 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 5813b5381..46028039b 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -187,6 +187,8 @@ pub struct EngineConfig { /// once at engine construction, then threaded onto every /// `SubAgentRuntime` the engine builds (#1806, #1808). pub subagent_api_timeout: Duration, + /// Per-stream chunk idle timeout for the model SSE loop. + pub stream_chunk_timeout: Duration, /// Native tools that should stay in the model-visible catalog even when /// they are outside the small default core surface (#2076). pub tools_always_load: HashSet, @@ -244,6 +246,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, + ), tools_always_load: HashSet::new(), prefer_bwrap: false, tools: None, @@ -806,6 +811,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, @@ -2390,7 +2404,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 0da4d5aea..35adca04f 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 9a3245e16..5719c0486 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -415,8 +415,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 @@ -2264,6 +2263,11 @@ fn is_turn_metadata_text(text: &str) -> bool { text.trim_start().starts_with("") } +fn stream_chunk_timeout_budget(config: &EngineConfig) -> (u64, Duration) { + let chunk_timeout_secs = config.stream_chunk_timeout.as_secs(); + (chunk_timeout_secs, Duration::from_secs(chunk_timeout_secs)) +} + #[cfg(test)] mod tests { use super::*; @@ -2296,6 +2300,19 @@ mod tests { assert!(!should_hold_turn_for_subagents(0, 0)); } + #[test] + fn stream_chunk_timeout_budget_uses_engine_config() { + let config = EngineConfig { + stream_chunk_timeout: Duration::from_secs(42), + ..Default::default() + }; + + assert_eq!( + stream_chunk_timeout_budget(&config), + (42, Duration::from_secs(42)) + ); + } + #[test] fn approval_intent_summary_trims_and_bounds_text() { assert_eq!(approval_intent_summary(" "), None); diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 87f479457..04d38b96e 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -67,6 +67,9 @@ pub enum Op { /// Update auto-compaction settings SetCompaction { config: CompactionConfig }, + /// Update the per-stream chunk idle timeout for subsequent 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 9feaaac46..82d6d21a0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5372,6 +5372,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()), prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), @@ -6332,6 +6333,7 @@ mod terminal_mode_tests { osc8_links: None, composer_arrows_scroll: None, notification_condition: None, + ..crate::config::TuiConfig::default() }), ..Config::default() }; @@ -6425,6 +6427,7 @@ mod terminal_mode_tests { osc8_links: None, composer_arrows_scroll: None, notification_condition: None, + ..crate::config::TuiConfig::default() }), ..Config::default() }; @@ -6456,6 +6459,7 @@ mod terminal_mode_tests { osc8_links: None, composer_arrows_scroll: None, notification_condition: None, + ..crate::config::TuiConfig::default() }), ..Config::default() }; @@ -6541,6 +6545,7 @@ mod terminal_mode_tests { osc8_links: None, composer_arrows_scroll: None, notification_condition: None, + ..crate::config::TuiConfig::default() }), ..Config::default() }; diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 51f79922c..3516f2eb5 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2013,6 +2013,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(), + ), prefer_bwrap: self.config.prefer_bwrap.unwrap_or(false), memory_enabled: self.config.memory_enabled(), memory_path: self.config.memory_path(), diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 6dd40791c..b9e342d33 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -832,6 +832,10 @@ impl Settings { "synchronized_output", "DEC 2026 synchronized output: auto, on, off (set off if your terminal flickers)", ), + ( + "stream_chunk_timeout_secs", + "Per-stream chunk idle timeout in seconds (0=default, 1..=3600)", + ), ( "prefer_external_pdftotext", "Route PDF reads through Poppler's pdftotext instead of the bundled pure-Rust extractor: on/off (default off)", diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 7a8e46bbe..1f565f01c 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1184,6 +1184,7 @@ pub struct App { pub config_path: Option, pub config_profile: Option, pub mcp_config_path: PathBuf, + pub stream_chunk_timeout_secs: u64, pub skills_dir: PathBuf, /// Path to the user-memory file (#489). Always populated; only /// consulted when `use_memory` is `true`. @@ -1936,6 +1937,7 @@ impl App { config_path, config_profile, mcp_config_path: mcp_config_path.clone(), + stream_chunk_timeout_secs: config.stream_chunk_timeout_secs(), skills_dir, memory_path, use_memory, @@ -4825,6 +4827,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 e92a2a056..45bc9d600 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -769,6 +769,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), prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), @@ -5212,6 +5213,11 @@ async fn apply_command_result( AppAction::UpdateCompaction(compaction) => { apply_model_and_compaction_update(engine_handle, compaction).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) { @@ -6718,6 +6724,11 @@ async fn handle_view_events( AppAction::UpdateCompaction(compaction) => { apply_model_and_compaction_update(engine_handle, compaction).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 166031371..eb1664734 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1940,6 +1940,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() { osc8_links: None, notification_condition: None, composer_arrows_scroll: None, + ..crate::config::TuiConfig::default() }), ..Config::default() }; diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 2f79796f5..648ef5bac 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -530,6 +530,7 @@ struct ConfigRow { enum ConfigSection { Model, Permissions, + Network, Display, Composer, Sidebar, @@ -542,6 +543,7 @@ impl ConfigSection { match self { ConfigSection::Model => "Model", ConfigSection::Permissions => "Permissions", + ConfigSection::Network => "Network", ConfigSection::Display => "Display", ConfigSection::Composer => "Composer", ConfigSection::Sidebar => "Sidebar", @@ -639,6 +641,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::Saved, + }, ConfigRow { section: ConfigSection::Display, key: "theme".to_string(), @@ -1176,6 +1185,7 @@ fn config_hint_for_key(key: &str) -> &'static str { "background_color" => "#RRGGBB | default", "base_url" => "save user config; e.g. https://api.deepseek.com/beta or https://gateway/v1", "cost_currency" => "usd | cny", + "stream_chunk_timeout_secs" => "0=default, or 1..=3600 seconds", "default_mode" => "agent | plan | yolo", "sidebar_width" => "10..=50", "sidebar_focus" => "auto | work | tasks | agents | context | hidden", @@ -2272,6 +2282,7 @@ mod tests { vec![ ConfigSection::Model.label(), ConfigSection::Permissions.label(), + ConfigSection::Network.label(), ConfigSection::Display.label(), ConfigSection::Composer.label(), ConfigSection::Sidebar.label(), @@ -2293,6 +2304,7 @@ mod tests { assert!(keys.contains(&"model")); assert!(keys.contains(&"reasoning_effort")); assert!(keys.contains(&"base_url")); + assert!(keys.contains(&"stream_chunk_timeout_secs")); assert!(keys.contains(&"approval_mode")); assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); @@ -2337,6 +2349,21 @@ mod tests { assert_eq!(row.value, "https://ui-config-view.local/v1"); } + #[test] + fn config_view_stream_chunk_timeout_secs_uses_loaded_app_state() { + let mut app = create_test_app(); + app.stream_chunk_timeout_secs = 90; + let view = ConfigView::new_for_app(&app); + + let row = view + .rows + .iter() + .find(|row| row.key == "stream_chunk_timeout_secs") + .expect("stream_chunk_timeout_secs row missing"); + assert_eq!(row.value, "90"); + assert_eq!(row.section, ConfigSection::Network); + } + #[test] fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() { let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n");