Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
200 changes: 198 additions & 2 deletions crates/tui/src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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<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("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.
Expand Down Expand Up @@ -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::<u64>() {
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),
);
}
_ => {}
}

Expand Down Expand Up @@ -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()
Expand Down
105 changes: 105 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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";
Expand Down Expand Up @@ -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<u64>,
/// 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<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".
Expand Down Expand Up @@ -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::<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]
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading