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>
This commit is contained in:
Hunter B
2026-06-04 21:22:15 -07:00
parent 0b07b8189c
commit e5fe46db4f
19 changed files with 420 additions and 59 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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 |
+1
View File
@@ -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
+4
View File
@@ -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
+19
View File
@@ -158,6 +158,7 @@ pub struct DeepSeekClient {
connection_health: Arc<AsyncMutex<ConnectionHealth>>,
rate_limiter: Arc<AsyncMutex<TokenBucket>>,
path_suffix: Option<String>,
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(
+2 -17
View File
@@ -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::<u64>().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.
+205 -1
View File
@@ -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<PathBuf> {
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<bool, String> {
}
}
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::<u64>() {
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!(
+104
View File
@@ -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<u64>,
/// 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<u64>,
/// 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::<u64>().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
+17 -1
View File
@@ -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,
-37
View File
@@ -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::<u64>().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);
}
}
+24 -2
View File
@@ -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;
+3
View File
@@ -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<String>,
+5
View File
@@ -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,
+3
View File
@@ -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(),
),
+4
View File
@@ -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<SubAgentResult>,
/// 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<String>,
},
UpdateCompaction(CompactionConfig),
UpdateStreamChunkTimeout(u64),
OpenContextInspector,
CompactContext,
PurgeContext,
+11
View File
@@ -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 => {}
_ => {}
}
+1
View File
@@ -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,
+11
View File
@@ -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"));
+1
View File
@@ -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 <seconds>`; `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).