From e4dec78acb39abcd075589ad5c86189f75ef094b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:56:01 -0700 Subject: [PATCH 001/107] Pin the git widget in the review-turn onboarding artifact The recheck found the render still captured the generating repo's live ahead/behind/dirty counts, which change with every commit. Add a test-only git-info cache seed and pin the widget to a clean fixture branch. The version label is compile-time build meta and is left as is. --- crates/jcode-tui/src/tui/app/helpers.rs | 13 +++++++++++++ .../src/tui/app/tests/onboarding_golden.rs | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/jcode-tui/src/tui/app/helpers.rs b/crates/jcode-tui/src/tui/app/helpers.rs index 47807ac6dc..8137056466 100644 --- a/crates/jcode-tui/src/tui/app/helpers.rs +++ b/crates/jcode-tui/src/tui/app/helpers.rs @@ -75,6 +75,19 @@ pub(crate) fn invalidate_git_info_cache() { } } +/// Pin the git-status widget to a fixed value for deterministic renders. +/// +/// Full-frame artifact generators (onboarding screenshots) would otherwise +/// capture the live ahead/behind/dirty counts of whatever repo the generator +/// happens to run in. Marking the entry as `refreshing` keeps the TTL path +/// from spawning a background probe that overwrites the seed mid-render. +#[cfg(test)] +pub(crate) fn seed_git_info_cache_for_tests(info: Option) { + if let Ok(mut guard) = GIT_INFO_CACHE.lock() { + *guard = Some((std::time::Instant::now(), info, true)); + } +} + /// Force the todos widget cache to refetch the given session on its next read. /// /// Call this right after the app persists a local todo write so the info widget diff --git a/crates/jcode-tui/src/tui/app/tests/onboarding_golden.rs b/crates/jcode-tui/src/tui/app/tests/onboarding_golden.rs index 94bd73902f..d5fbfa7201 100644 --- a/crates/jcode-tui/src/tui/app/tests/onboarding_golden.rs +++ b/crates/jcode-tui/src/tui/app/tests/onboarding_golden.rs @@ -709,6 +709,21 @@ fn onboarding_import_happy_path_images() { // the generator has unseen changelog entries, which makes the artifact // depend on developer-local state. Force it empty for determinism. crate::tui::ui::header::set_unseen_changelog_entries_override_for_tests(Some(Vec::new())); + // The git info widget would otherwise capture the live ahead/behind and + // dirty counts of the repo the generator runs in. Pin it to a clean + // fixture branch. (The version label is compile-time build meta, which + // is deterministic for a given checkout and expected to advance.) + crate::tui::app::helpers::seed_git_info_cache_for_tests(Some( + crate::tui::info_widget::GitInfo { + branch: "main".to_string(), + modified: 0, + staged: 0, + untracked: 0, + ahead: 0, + behind: 0, + dirty_files: Vec::new(), + }, + )); let mut app = create_test_app(); // The header shows a randomly drawn session mascot ("client: Goat 🐐"), // which would make the artifact differ run to run. Pin it. From 6057b9f0d3e03552206bf0c10ef56f1b0e6ccb60 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:35:34 -0700 Subject: [PATCH 002/107] feat(providers): support Anthropic-compatible profiles --- README.md | 28 +++ crates/jcode-base/src/provider/anthropic.rs | 34 ++++ .../jcode-base/src/provider/catalog_routes.rs | 35 +++- crates/jcode-base/src/provider/mod.rs | 56 +++++- crates/jcode-base/src/provider/selection.rs | 22 ++- crates/jcode-base/src/provider/startup.rs | 28 ++- crates/jcode-base/src/provider_catalog.rs | 114 +++++++++++ .../jcode-base/src/provider_catalog_tests.rs | 67 +++++-- crates/jcode-config-types/src/lib.rs | 6 + .../src/anthropic_tests.rs | 88 +++++++++ .../src/lib.rs | 183 ++++++++++++++++-- src/cli/commands/provider_setup.rs | 1 + 12 files changed, 619 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index ef8191ed67..eb0665157c 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,34 @@ id = "my-model-id" context_window = 128000 ``` +Anthropic Messages-compatible gateways use the same named-profile surface with +`type = "anthropic-compatible"`. The profile can select bearer, custom-header, +or no authentication and attach gateway-specific headers to every request: + +```toml +[provider] +default_provider = "corp-claude" +default_model = "claude-sonnet-4-6" + +[providers.corp-claude] +type = "anthropic-compatible" +base_url = "https://gateway.example.com/anthropic/v1" +auth = "bearer" +api_key_env = "CORP_CLAUDE_TOKEN" +default_model = "claude-sonnet-4-6" + +[providers.corp-claude.headers] +x-tenant-id = "tenant-42" + +[[providers.corp-claude.models]] +id = "claude-sonnet-4-6" +context_window = 200000 +``` + +For direct environment-based configuration, `ANTHROPIC_BASE_URL` overrides the +non-OAuth Messages endpoint and `ANTHROPIC_AUTH_TOKEN` is sent as a bearer token. +Claude OAuth traffic always continues to use Anthropic's official endpoints. + ##### Extra request-body fields (`extra_body`) Some OpenAI-compatible backends require non-standard top-level request fields. For example, NVIDIA NIM DeepSeek-V4 reasoning models (`deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro`) only enable thinking when the request includes `chat_template_kwargs`; without it they reply without reasoning (or, for some deployments, hang). jcode lets you inject arbitrary top-level fields two ways. diff --git a/crates/jcode-base/src/provider/anthropic.rs b/crates/jcode-base/src/provider/anthropic.rs index effad3a75d..ad5d27ea21 100644 --- a/crates/jcode-base/src/provider/anthropic.rs +++ b/crates/jcode-base/src/provider/anthropic.rs @@ -86,6 +86,40 @@ pub const AVAILABLE_MODELS: &[&str] = &[ ]; pub fn load_anthropic_api_key() -> Result { + if std::env::var("JCODE_ANTHROPIC_AUTH") + .ok() + .is_some_and(|value| value.eq_ignore_ascii_case("none")) + { + return Ok(String::new()); + } + if let Ok(env_name) = std::env::var("JCODE_ANTHROPIC_API_KEY_NAME") { + let env_name = env_name.trim(); + if !env_name.is_empty() { + if let Ok(value) = std::env::var(env_name) + && !value.trim().is_empty() + { + return Ok(value); + } + if let Ok(env_file) = std::env::var("JCODE_ANTHROPIC_ENV_FILE") + && let Some(value) = crate::provider_catalog::load_env_value_from_config_file( + env_name, + env_file.trim(), + ) + && !value.trim().is_empty() + { + return Ok(value); + } + anyhow::bail!( + "Anthropic-compatible profile credential '{}' is not configured", + env_name + ); + } + } + if let Ok(value) = std::env::var("ANTHROPIC_AUTH_TOKEN") + && !value.trim().is_empty() + { + return Ok(value); + } let key = crate::provider_catalog::load_api_key_from_env_or_config( "ANTHROPIC_API_KEY", "anthropic.env", diff --git a/crates/jcode-base/src/provider/catalog_routes.rs b/crates/jcode-base/src/provider/catalog_routes.rs index 0b769b51b5..90bb8d3841 100644 --- a/crates/jcode-base/src/provider/catalog_routes.rs +++ b/crates/jcode-base/src/provider/catalog_routes.rs @@ -222,12 +222,8 @@ pub(super) fn multiprovider_model_routes(provider: &MultiProvider) -> Vec { + let switching_from_named_anthropic = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .and_then(|name| crate::config::config().providers.get(&name)) + .is_some_and(|profile| { + matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) + }); + if switching_from_named_anthropic { + crate::env::remove_var("JCODE_NAMED_PROVIDER_PROFILE"); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_ACTIVE"); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_NAME"); + crate::provider_catalog::clear_anthropic_profile_env(); + crate::env::set_var( + "JCODE_RUNTIME_PROVIDER", + match anthropic_credential_mode { + Some(mode) => match mode { + anthropic::AnthropicCredentialMode::ApiKey => "anthropic-api", + anthropic::AnthropicCredentialMode::OAuth => "claude-oauth", + anthropic::AnthropicCredentialMode::Auto => "claude", + }, + None => "claude", + }, + ); + let official = external::instantiate_expected_external_provider( + external::ANTHROPIC_RUNTIME, + ) + .ok_or_else(|| anyhow::anyhow!("Anthropic runtime is not registered"))?; + *self + .anthropic + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(official); + } + crate::provider_catalog::clear_anthropic_profile_env(); let model = model_name_for_provider(provider, model); if let Some(anthropic) = self.anthropic_provider() { if let Some(mode) = anthropic_credential_mode { @@ -1551,7 +1605,7 @@ impl MultiProvider { // Same reasoning for user-defined named provider profiles from // config: bind the named profile runtime directly instead of the // generic OpenRouter slot path. - if let selection::ConfigProviderSelection::NamedProfile(profile_name) = &selection { + if let selection::ConfigProviderSelection::NamedProfile(profile_name, _) = &selection { return self.set_model_on_named_provider_profile(profile_name, model); } diff --git a/crates/jcode-base/src/provider/selection.rs b/crates/jcode-base/src/provider/selection.rs index 1b283dc561..d9cddddebf 100644 --- a/crates/jcode-base/src/provider/selection.rs +++ b/crates/jcode-base/src/provider/selection.rs @@ -6,14 +6,15 @@ pub(super) use jcode_provider_core::{ActiveProvider, ProviderAvailability}; pub(crate) enum ConfigProviderSelection { BuiltIn(ActiveProvider), OpenAiCompatibleProfile(&'static str), - NamedProfile(String), + NamedProfile(String, ActiveProvider), } impl ConfigProviderSelection { pub(crate) fn active_provider(&self) -> ActiveProvider { match self { Self::BuiltIn(provider) => *provider, - Self::OpenAiCompatibleProfile(_) | Self::NamedProfile(_) => ActiveProvider::OpenRouter, + Self::OpenAiCompatibleProfile(_) => ActiveProvider::OpenRouter, + Self::NamedProfile(_, provider) => *provider, } } @@ -31,7 +32,7 @@ impl ConfigProviderSelection { None => format!("OpenAI-compatible profile {}", profile_id), } } - Self::NamedProfile(profile) => format!("provider profile '{}'", profile), + Self::NamedProfile(profile, _) => format!("provider profile '{}'", profile), } } } @@ -500,8 +501,19 @@ impl MultiProvider { return Some(ConfigProviderSelection::OpenAiCompatibleProfile(profile.id)); } - if cfg.providers.contains_key(trimmed) { - return Some(ConfigProviderSelection::NamedProfile(trimmed.to_string())); + if let Some(profile) = cfg.providers.get(trimmed) { + let provider = if matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) { + ActiveProvider::Claude + } else { + ActiveProvider::OpenRouter + }; + return Some(ConfigProviderSelection::NamedProfile( + trimmed.to_string(), + provider, + )); } // Accept the dual-auth `--provider` vocabulary (`anthropic-api`, diff --git a/crates/jcode-base/src/provider/startup.rs b/crates/jcode-base/src/provider/startup.rs index d40b589f3b..51272d9d1c 100644 --- a/crates/jcode-base/src/provider/startup.rs +++ b/crates/jcode-base/src/provider/startup.rs @@ -144,7 +144,21 @@ impl MultiProvider { }; let anthropic = if has_claude_creds && !use_claude_cli { - external::instantiate_expected_external_provider(external::ANTHROPIC_RUNTIME) + let provider = + external::instantiate_expected_external_provider(external::ANTHROPIC_RUNTIME); + let active_profile_is_anthropic = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .and_then(|name| cfg.providers.get(&name)) + .is_some_and(|profile| { + matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) + }); + if active_profile_is_anthropic { + crate::provider_catalog::clear_anthropic_profile_env(); + } + provider } else { None }; @@ -198,7 +212,17 @@ impl MultiProvider { None }; - let openrouter = if has_openrouter_creds { + let active_named_profile_is_anthropic = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .or_else(|| default_named_provider_profile.clone()) + .and_then(|name| cfg.providers.get(&name)) + .is_some_and(|profile| { + matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) + }); + let openrouter = if has_openrouter_creds && !active_named_profile_is_anthropic { let named_profile = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") .ok() .or_else(|| default_named_provider_profile.clone()); diff --git a/crates/jcode-base/src/provider_catalog.rs b/crates/jcode-base/src/provider_catalog.rs index 785874022d..5aa81572eb 100644 --- a/crates/jcode-base/src/provider_catalog.rs +++ b/crates/jcode-base/src/provider_catalog.rs @@ -648,6 +648,20 @@ fn inline_key_env_name(profile_name: &str) -> String { format!("JCODE_PROVIDER_{}_API_KEY", suffix) } +pub fn clear_anthropic_profile_env() { + for key in [ + "JCODE_ANTHROPIC_API_BASE", + "JCODE_ANTHROPIC_API_KEY_NAME", + "JCODE_ANTHROPIC_ENV_FILE", + "JCODE_ANTHROPIC_AUTH", + "JCODE_ANTHROPIC_AUTH_HEADER", + "JCODE_ANTHROPIC_HEADERS", + "JCODE_ANTHROPIC_MODEL", + ] { + crate::env::remove_var(key); + } +} + pub fn apply_named_provider_profile_env(profile_name: &str) -> anyhow::Result { let config = crate::config::Config::load_strict()?; apply_named_provider_profile_env_from_config(profile_name, &config) @@ -673,6 +687,106 @@ pub fn apply_named_provider_profile_env_from_config( ) })?; + if matches!( + profile.provider_type, + crate::config::NamedProviderType::AnthropicCompatible + ) { + crate::env::set_var("JCODE_NAMED_PROVIDER_PROFILE", profile_name); + crate::env::set_var("JCODE_ANTHROPIC_API_BASE", &api_base); + crate::env::set_var("JCODE_RUNTIME_PROVIDER", "anthropic-api"); + if let Some(model) = profile + .default_model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + crate::env::set_var("JCODE_ANTHROPIC_MODEL", model); + } + + let key_env = profile + .api_key_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| { + profile + .api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|key| { + let env_name = inline_key_env_name(profile_name); + crate::env::set_var(&env_name, key); + crate::logging::warn(&format!( + "Provider profile '{}' stores an inline API key in config.toml. Prefer api_key_env to avoid accidental leaks.", + profile_name + )); + env_name + }) + }); + if let Some(key_env) = key_env { + if !is_safe_env_key_name(&key_env) { + anyhow::bail!( + "Provider profile '{}' has invalid api_key_env '{}'.", + profile_name, + key_env + ); + } + crate::env::set_var("JCODE_ANTHROPIC_API_KEY_NAME", key_env); + } else { + crate::env::remove_var("JCODE_ANTHROPIC_API_KEY_NAME"); + } + if let Some(env_file) = profile + .env_file + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !is_safe_env_file_name(env_file) { + anyhow::bail!( + "Provider profile '{}' has invalid env_file '{}'.", + profile_name, + env_file + ); + } + crate::env::set_var("JCODE_ANTHROPIC_ENV_FILE", env_file); + } else { + crate::env::remove_var("JCODE_ANTHROPIC_ENV_FILE"); + } + + match profile.auth { + crate::config::NamedProviderAuth::Bearer => { + crate::env::set_var("JCODE_ANTHROPIC_AUTH", "bearer"); + crate::env::remove_var("JCODE_ANTHROPIC_AUTH_HEADER"); + } + crate::config::NamedProviderAuth::Header => { + crate::env::set_var("JCODE_ANTHROPIC_AUTH", "header"); + crate::env::set_var( + "JCODE_ANTHROPIC_AUTH_HEADER", + profile.auth_header.as_deref().unwrap_or("x-api-key"), + ); + } + crate::config::NamedProviderAuth::None => { + crate::env::set_var("JCODE_ANTHROPIC_AUTH", "none"); + crate::env::remove_var("JCODE_ANTHROPIC_AUTH_HEADER"); + } + } + if profile.headers.is_empty() { + crate::env::remove_var("JCODE_ANTHROPIC_HEADERS"); + } else { + crate::env::set_var( + "JCODE_ANTHROPIC_HEADERS", + serde_json::to_string(&profile.headers).map_err(|err| { + anyhow::anyhow!("failed to serialize Anthropic-compatible headers: {err}") + })?, + ); + } + return Ok(profile_name.to_string()); + } + + clear_anthropic_profile_env(); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_ACTIVE"); crate::env::remove_var("JCODE_PROVIDER_PROFILE_NAME"); crate::env::remove_var("JCODE_NAMED_PROVIDER_PROFILE"); diff --git a/crates/jcode-base/src/provider_catalog_tests.rs b/crates/jcode-base/src/provider_catalog_tests.rs index 2874bd2751..793efd8b57 100644 --- a/crates/jcode-base/src/provider_catalog_tests.rs +++ b/crates/jcode-base/src/provider_catalog_tests.rs @@ -536,8 +536,18 @@ fn named_provider_config_accepts_openai_compatible_spelling() { } #[test] -fn named_provider_profile_reports_malformed_config_instead_of_unknown_profile() { +fn named_anthropic_compatible_profile_maps_endpoint_auth_headers_and_model() { let _lock = crate::storage::lock_test_env(); + let _guard = EnvGuard::save(&[ + "JCODE_NAMED_PROVIDER_PROFILE", + "JCODE_ANTHROPIC_API_BASE", + "JCODE_ANTHROPIC_API_KEY_NAME", + "JCODE_ANTHROPIC_AUTH", + "JCODE_ANTHROPIC_AUTH_HEADER", + "JCODE_ANTHROPIC_HEADERS", + "JCODE_ANTHROPIC_MODEL", + "JCODE_RUNTIME_PROVIDER", + ]); let previous_home = std::env::var_os("JCODE_HOME"); let temp = tempfile::TempDir::new().expect("tempdir"); crate::env::set_var("JCODE_HOME", temp.path()); @@ -549,32 +559,53 @@ fn named_provider_profile_reports_malformed_config_instead_of_unknown_profile() std::fs::write( &config_path, r#" - [providers.antigravity] + [providers.corporate-claude] type = "anthropic-compatible" - base_url = "http://192.168.1.202:8080" - api_key_env = "ANTIGRAVITY_API_KEY" - default_model = "gemini-3.1-pro-low" + base_url = "https://gateway.example.com/anthropic/v1/" + auth = "bearer" + api_key_env = "CORPORATE_CLAUDE_TOKEN" + default_model = "claude-custom" + + [providers.corporate-claude.headers] + x-tenant-id = "tenant-42" - [[providers.antigravity.models]] - id = "gemini-3.1-pro-low" + [[providers.corporate-claude.models]] + id = "claude-custom" context_window = 128000 "#, ) .expect("write config"); - let err = apply_named_provider_profile_env("antigravity").expect_err("malformed config"); - let message = err.to_string(); - assert!( - message.contains("Failed to parse config file"), - "unexpected error: {message}" + apply_named_provider_profile_env("corporate-claude").expect("apply Anthropic profile"); + assert_eq!( + std::env::var("JCODE_ANTHROPIC_API_BASE").ok().as_deref(), + Some("https://gateway.example.com/anthropic/v1") ); - assert!( - message.contains("anthropic-compatible"), - "unexpected error: {message}" + assert_eq!( + std::env::var("JCODE_ANTHROPIC_API_KEY_NAME") + .ok() + .as_deref(), + Some("CORPORATE_CLAUDE_TOKEN") ); - assert!( - !message.contains("Unknown provider profile"), - "unexpected error: {message}" + assert_eq!( + std::env::var("JCODE_ANTHROPIC_AUTH").ok().as_deref(), + Some("bearer") + ); + assert_eq!( + std::env::var("JCODE_ANTHROPIC_MODEL").ok().as_deref(), + Some("claude-custom") + ); + let headers: std::collections::BTreeMap = serde_json::from_str( + &std::env::var("JCODE_ANTHROPIC_HEADERS").expect("custom headers env"), + ) + .expect("headers JSON"); + assert_eq!( + headers.get("x-tenant-id").map(String::as_str), + Some("tenant-42") + ); + assert_eq!( + std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(), + Some("anthropic-api") ); if let Some(previous_home) = previous_home { diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 9758785f17..10027ec417 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -405,6 +405,8 @@ pub enum NamedProviderType { #[serde(alias = "openai-compatible", alias = "openai_compatible")] #[default] OpenAiCompatible, + #[serde(alias = "anthropic-compatible", alias = "anthropic_compatible")] + AnthropicCompatible, OpenRouter, } @@ -443,6 +445,9 @@ pub struct NamedProviderConfig { pub api: Option, pub auth: NamedProviderAuth, pub auth_header: Option, + /// Extra HTTP headers sent with every request to this provider. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub headers: std::collections::BTreeMap, pub api_key_env: Option, pub api_key: Option, pub env_file: Option, @@ -485,6 +490,7 @@ impl Default for NamedProviderConfig { api: None, auth: NamedProviderAuth::Bearer, auth_header: None, + headers: std::collections::BTreeMap::new(), api_key_env: None, api_key: None, env_file: None, diff --git a/crates/jcode-provider-anthropic-runtime/src/anthropic_tests.rs b/crates/jcode-provider-anthropic-runtime/src/anthropic_tests.rs index 43835deba4..2af22d12ef 100644 --- a/crates/jcode-provider-anthropic-runtime/src/anthropic_tests.rs +++ b/crates/jcode-provider-anthropic-runtime/src/anthropic_tests.rs @@ -30,6 +30,94 @@ impl Drop for EnvVarGuard { } } +#[test] +fn direct_api_url_supports_standard_and_profile_overrides() { + let _lock = jcode_base::storage::lock_test_env(); + let _standard = EnvVarGuard::set("ANTHROPIC_BASE_URL", "https://proxy.example/v1/"); + assert_eq!(direct_api_url(), "https://proxy.example/v1/messages"); + + let _profile = EnvVarGuard::set( + "JCODE_ANTHROPIC_API_BASE", + "https://gateway.example/anthropic/v1/messages", + ); + assert_eq!( + direct_api_url(), + "https://gateway.example/anthropic/v1/messages" + ); +} + +#[test] +fn configured_direct_headers_parse_and_reject_invalid_values() { + let _lock = jcode_base::storage::lock_test_env(); + let _headers = EnvVarGuard::set( + "JCODE_ANTHROPIC_HEADERS", + r#"{"x-tenant":"alpha","x-route":"claude"}"#, + ); + let parsed = configured_direct_headers().expect("valid custom headers"); + assert_eq!(parsed.get("x-tenant").unwrap(), "alpha"); + assert_eq!(parsed.get("x-route").unwrap(), "claude"); + + let _invalid = EnvVarGuard::set("JCODE_ANTHROPIC_HEADERS", r#"{"bad header":"x"}"#); + assert!(configured_direct_headers().is_err()); +} + +#[test] +fn anthropic_auth_token_selects_bearer_without_affecting_explicit_profile_auth() { + let _lock = jcode_base::storage::lock_test_env(); + let _token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", "gateway-token"); + assert_eq!(direct_auth_mode(), "bearer"); + + let _explicit = EnvVarGuard::set("JCODE_ANTHROPIC_AUTH", "header"); + assert_eq!(direct_auth_mode(), "header"); +} + +#[test] +fn named_profile_runtime_captures_transport_and_credential_immutably() { + let _lock = jcode_base::storage::lock_test_env(); + let _base = EnvVarGuard::set("JCODE_ANTHROPIC_API_BASE", "https://one.example/v1"); + let _auth = EnvVarGuard::set("JCODE_ANTHROPIC_AUTH", "bearer"); + let _key_name = EnvVarGuard::set("JCODE_ANTHROPIC_API_KEY_NAME", "PROFILE_ONE_KEY"); + let _key = EnvVarGuard::set("PROFILE_ONE_KEY", "one-secret"); + let provider = AnthropicProvider::new(); + + let _changed_base = EnvVarGuard::set("JCODE_ANTHROPIC_API_BASE", "https://two.example/v1"); + let _changed_key = EnvVarGuard::set("PROFILE_ONE_KEY", "two-secret"); + assert_eq!( + provider.direct_transport.api_url, + "https://one.example/v1/messages" + ); + assert_eq!(provider.direct_transport.auth_mode, "bearer"); + assert_eq!( + provider.profile_api_key.as_ref().unwrap().as_ref().unwrap(), + "one-secret" + ); +} + +#[test] +fn named_anthropic_profile_accepts_its_configured_custom_model() { + let _lock = jcode_base::storage::lock_test_env(); + let _home = tempfile::TempDir::new().expect("temp home"); + let _home_guard = EnvVarGuard::set("JCODE_HOME", _home.path()); + std::fs::write( + _home.path().join("config.toml"), + r#" + [providers.custom] + type = "anthropic-compatible" + base_url = "http://localhost:12345/v1" + default_model = "claude-private" + "#, + ) + .expect("write config"); + jcode_base::config::Config::invalidate_cache(); + let _profile = EnvVarGuard::set("JCODE_NAMED_PROVIDER_PROFILE", "custom"); + let models = active_anthropic_profile_models().expect("active profile models"); + assert!(models.iter().any(|model| model == "claude-private")); + assert!(!models.iter().any(|model| model == "not-configured")); + drop(_profile); + drop(_home_guard); + jcode_base::config::Config::invalidate_cache(); +} + async fn collect_live_smoke_stream( mut stream: EventStream, timeout: std::time::Duration, diff --git a/crates/jcode-provider-anthropic-runtime/src/lib.rs b/crates/jcode-provider-anthropic-runtime/src/lib.rs index b26c261ed2..2d9096acf4 100644 --- a/crates/jcode-provider-anthropic-runtime/src/lib.rs +++ b/crates/jcode-provider-anthropic-runtime/src/lib.rs @@ -45,6 +45,7 @@ use jcode_provider_core::{ anthropic_strip_1m_suffix as strip_1m_suffix, }; use reqwest::Client; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::Serialize; use serde_json::{Value, json}; use std::sync::Arc; @@ -59,6 +60,103 @@ const API_URL: &str = "https://api.anthropic.com/v1/messages"; /// OAuth endpoint (with beta=true query param) const API_URL_OAUTH: &str = "https://api.anthropic.com/v1/messages?beta=true"; +fn direct_api_url() -> String { + let base = std::env::var("JCODE_ANTHROPIC_API_BASE") + .ok() + .or_else(|| std::env::var("ANTHROPIC_BASE_URL").ok()) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()); + match base { + Some(base) if base.ends_with("/messages") => base, + Some(base) => format!("{base}/messages"), + None => API_URL.to_string(), + } +} + +fn configured_direct_headers() -> Result { + let Some(raw) = std::env::var("JCODE_ANTHROPIC_HEADERS") + .ok() + .filter(|value| !value.trim().is_empty()) + else { + return Ok(HeaderMap::new()); + }; + let headers: std::collections::BTreeMap = serde_json::from_str(&raw) + .context("JCODE_ANTHROPIC_HEADERS must be a JSON object of string values")?; + let mut result = HeaderMap::new(); + for (name, value) in headers { + let name = HeaderName::from_bytes(name.as_bytes()) + .with_context(|| format!("invalid Anthropic-compatible header name '{name}'"))?; + let value = HeaderValue::from_str(&value) + .with_context(|| format!("invalid value for Anthropic-compatible header '{name}'"))?; + result.insert(name, value); + } + Ok(result) +} + +fn direct_auth_mode() -> String { + std::env::var("JCODE_ANTHROPIC_AUTH") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + if std::env::var("ANTHROPIC_AUTH_TOKEN") + .ok() + .is_some_and(|value| !value.trim().is_empty()) + { + "bearer".to_string() + } else { + "header".to_string() + } + }) + .trim() + .to_ascii_lowercase() +} + +#[derive(Clone)] +struct DirectTransportConfig { + api_url: String, + headers: std::result::Result, + auth_mode: String, + auth_header: String, +} + +impl DirectTransportConfig { + fn from_env() -> Self { + Self { + api_url: direct_api_url(), + headers: configured_direct_headers().map_err(|err| format!("{err:#}")), + auth_mode: direct_auth_mode(), + auth_header: std::env::var("JCODE_ANTHROPIC_AUTH_HEADER") + .unwrap_or_else(|_| "x-api-key".to_string()), + } + } +} + +fn active_anthropic_profile_models() -> Option> { + let Ok(profile_name) = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") else { + return None; + }; + let Some(profile) = jcode_base::config::config().providers.get(&profile_name) else { + return None; + }; + if !matches!( + profile.provider_type, + jcode_base::config::NamedProviderType::AnthropicCompatible + ) { + return None; + } + let mut models = profile + .models + .iter() + .map(|configured| configured.id.clone()) + .collect::>(); + if let Some(default) = &profile.default_model + && !models.contains(default) + { + models.push(default.clone()); + } + Some(models) +} + #[cfg(test)] pub(crate) const OAUTH_BETA_HEADERS_1M: &str = jcode_provider_core::ANTHROPIC_OAUTH_BETA_HEADERS_1M; @@ -378,6 +476,11 @@ pub struct AnthropicProvider { max_tokens_override: Option, oauth_session_id: String, oauth_preflight_done: Arc, + direct_transport: DirectTransportConfig, + /// Named profiles pin their credential at runtime construction so another + /// session/profile cannot redirect this runtime to a different process env. + profile_api_key: Option>, + profile_models: Option>, } impl AnthropicProvider { @@ -518,6 +621,11 @@ impl AnthropicProvider { .and_then(Self::normalize_reasoning_effort) .map(|effort| Self::store_effort_for_model(&model, &effort)); + let direct_transport = DirectTransportConfig::from_env(); + let profile_api_key = std::env::var_os("JCODE_ANTHROPIC_API_BASE") + .map(|_| load_anthropic_api_key().map_err(|err| format!("{err:#}"))); + let profile_models = active_anthropic_profile_models(); + Self { client: jcode_provider_core::shared_http_client(), model: Arc::new(std::sync::RwLock::new(model)), @@ -530,6 +638,17 @@ impl AnthropicProvider { max_tokens_override, oauth_session_id: Uuid::new_v4().to_string(), oauth_preflight_done: Arc::new(AtomicBool::new(false)), + direct_transport, + profile_api_key, + profile_models, + } + } + + fn direct_api_key(&self) -> Result { + match &self.profile_api_key { + Some(Ok(key)) => Ok(key.clone()), + Some(Err(err)) => anyhow::bail!(err.clone()), + None => load_anthropic_api_key(), } } @@ -809,7 +928,7 @@ impl AnthropicProvider { // Explicit API-key mode: use the direct API key and surface an error if // one is not configured (never silently fall back to OAuth). if matches!(mode, AnthropicCredentialMode::ApiKey) { - let key = load_anthropic_api_key()?; + let key = self.direct_api_key()?; return Ok((key, false)); // false = not OAuth } @@ -822,7 +941,7 @@ impl AnthropicProvider { if matches!(mode, AnthropicCredentialMode::Auto) { match self.get_oauth_access_token().await { Ok(token) => return Ok(token), - Err(oauth_err) => match load_anthropic_api_key() { + Err(oauth_err) => match self.direct_api_key() { Ok(key) => { jcode_base::logging::warn(&format!( "Claude OAuth is unusable in automatic credential mode ({oauth_err:#}); falling back to the configured Anthropic API key" @@ -1122,6 +1241,7 @@ impl Provider for AnthropicProvider { let credentials = Arc::clone(&self.credentials); let oauth_session_id = self.oauth_session_id.clone(); let model_state = Arc::clone(&self.model); + let direct_transport = self.direct_transport.clone(); // Spawn task to handle streaming with retry logic. // This includes forced OAuth refresh on auth failures. @@ -1145,6 +1265,7 @@ impl Provider for AnthropicProvider { model, oauth_session_id, model_state, + direct_transport, ) .await; }); @@ -1181,9 +1302,13 @@ impl Provider for AnthropicProvider { } else { model }; - if !jcode_base::provider::known_anthropic_model_ids() - .iter() - .any(|known| known == model) + if !self + .profile_models + .as_ref() + .is_some_and(|models| models.iter().any(|configured| configured == model)) + && !jcode_base::provider::known_anthropic_model_ids() + .iter() + .any(|known| known == model) { anyhow::bail!("Model {} not supported by Anthropic provider", model); } @@ -1328,6 +1453,12 @@ impl Provider for AnthropicProvider { } async fn prefetch_models(&self) -> Result<()> { + if self.direct_transport.api_url != API_URL { + // Named Anthropic-compatible profiles use their configured static + // model list. Never send gateway credentials to Anthropic's + // official hard-coded model-catalog endpoint. + return Ok(()); + } let (token, is_oauth) = self.get_access_token().await?; if token.trim().is_empty() { return Ok(()); @@ -1401,6 +1532,9 @@ impl Provider for AnthropicProvider { oauth_preflight_done: Arc::new(AtomicBool::new( self.oauth_preflight_done.load(Ordering::Relaxed), )), + direct_transport: self.direct_transport.clone(), + profile_api_key: self.profile_api_key.clone(), + profile_models: self.profile_models.clone(), }) } @@ -1486,6 +1620,7 @@ impl Provider for AnthropicProvider { let credentials = Arc::clone(&self.credentials); let oauth_session_id = self.oauth_session_id.clone(); let model_state = Arc::clone(&self.model); + let direct_transport = self.direct_transport.clone(); // Spawn task to handle streaming with retry logic tokio::spawn(async move { @@ -1508,6 +1643,7 @@ impl Provider for AnthropicProvider { model, oauth_session_id, model_state, + direct_transport, ) .await; }); @@ -1530,6 +1666,7 @@ async fn run_stream_with_retries( model_name: String, oauth_session_id: String, model_state: Arc>, + direct_transport: DirectTransportConfig, ) { let mut token = initial_token; let mut last_error = None; @@ -1590,6 +1727,7 @@ async fn run_stream_with_retries( attempt_tx, &model_name, &oauth_session_id, + &direct_transport, ) .await { @@ -1839,6 +1977,7 @@ async fn stream_response( tx: mpsc::Sender>, model_name: &str, oauth_session_id: &str, + direct_transport: &DirectTransportConfig, ) -> Result<()> { use jcode_message_types::ConnectionPhase; let requested_model_base = strip_1m_suffix(&request.model).to_ascii_lowercase(); @@ -1859,10 +1998,22 @@ async fn stream_response( let connect_start = std::time::Instant::now(); let stream_idle_timeout = jcode_base::provider::stream_idle_timeout(); // Build request with appropriate auth headers - let url = if is_oauth { API_URL_OAUTH } else { API_URL }; + let url = if is_oauth { + API_URL_OAUTH + } else { + direct_transport.api_url.as_str() + }; - let mut req = client - .post(url) + let mut req = client.post(url); + if !is_oauth { + req = req.headers( + direct_transport + .headers + .clone() + .map_err(anyhow::Error::msg)?, + ); + } + req = req .header("anthropic-version", API_VERSION) .header("content-type", "application/json") .header( @@ -1900,9 +2051,19 @@ async fn stream_response( }; let beta_header = anthropic_beta_header_with_thinking(beta_header, request.thinking.is_some()); - req = req - .header("x-api-key", &token) - .header("anthropic-beta", beta_header); + req = match direct_transport.auth_mode.as_str() { + "none" => req, + "bearer" => req.header("Authorization", format!("Bearer {token}")), + "header" => { + let header = HeaderName::from_bytes(direct_transport.auth_header.trim().as_bytes()) + .context("invalid JCODE_ANTHROPIC_AUTH_HEADER")?; + req.header(header, &token) + } + value => anyhow::bail!( + "invalid JCODE_ANTHROPIC_AUTH value '{value}' (expected bearer, header, or none)" + ), + }; + req = req.header("anthropic-beta", beta_header); } let response = jcode_provider_core::transport::send_with_initial_response_timeout( diff --git a/src/cli/commands/provider_setup.rs b/src/cli/commands/provider_setup.rs index a6c3b73804..9848be31cc 100644 --- a/src/cli/commands/provider_setup.rs +++ b/src/cli/commands/provider_setup.rs @@ -167,6 +167,7 @@ pub(crate) fn configure_provider_profile( .map(ToString::to_string), _ => None, }, + headers: std::collections::BTreeMap::new(), api_key_env: api_key_env.clone(), api_key: None, env_file: env_file.clone(), From 8be48524d25a9b8b8cc5672f2d23cc758c0757dc Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:11:01 -0700 Subject: [PATCH 003/107] docs: point sponsors provenance policy link at live /sponsor page --- crates/jcode-base/src/sponsors/provenance.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/jcode-base/src/sponsors/provenance.rs b/crates/jcode-base/src/sponsors/provenance.rs index 5e97b82b20..9f05795366 100644 --- a/crates/jcode-base/src/sponsors/provenance.rs +++ b/crates/jcode-base/src/sponsors/provenance.rs @@ -8,7 +8,7 @@ //! never user identity. Aggregates are flushed at most once per hour to //! `POST {sponsors.endpoint}/usage` and only while `sponsors.enabled` is //! true. The policy is disclosed at -//! and in the connect-time +//! and in the connect-time //! UI line. //! //! Everything here is process-local and best-effort: metering failures From 8e5a303c255f7d1b21c4601da4ac5f202b35f17b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:15:07 -0700 Subject: [PATCH 004/107] docs: self-host authenticated star history chart --- .github/scripts/generate_star_history.py | 120 ++++++++++++++++++++++ .github/workflows/update-star-history.yml | 34 ++++++ README.md | 2 +- docs/images/star-history.svg | 17 +++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/generate_star_history.py create mode 100644 .github/workflows/update-star-history.yml create mode 100644 docs/images/star-history.svg diff --git a/.github/scripts/generate_star_history.py b/.github/scripts/generate_star_history.py new file mode 100644 index 0000000000..19ec09ae52 --- /dev/null +++ b/.github/scripts/generate_star_history.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Generate a self-hosted GitHub star-history chart for the README.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import urllib.request +from pathlib import Path + + +def fetch_stars(repository: str, token: str) -> list[dt.date]: + url = f"https://api.github.com/repos/{repository}/stargazers?per_page=100" + dates: list[dt.date] = [] + while url: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github.star+json", + "Authorization": f"Bearer {token}", + "User-Agent": "jcode-star-history", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + with urllib.request.urlopen(request) as response: + for star in json.load(response): + dates.append(dt.datetime.fromisoformat(star["starred_at"].replace("Z", "+00:00")).date()) + links = response.headers.get("Link", "") + url = "" + for link in links.split(","): + if 'rel="next"' in link: + url = link[link.index("<") + 1 : link.index(">")] + break + return dates + + +def render_svg(repository: str, dates: list[dt.date]) -> str: + if not dates: + raise RuntimeError("GitHub returned no stargazers") + dates.sort() + start, end = dates[0], max(dates[-1], dt.date.today()) + span = max((end - start).days, 1) + width, height = 800, 420 + left, right, top, bottom = 72, 24, 38, 58 + plot_w, plot_h = width - left - right, height - top - bottom + + points: list[tuple[dt.date, int]] = [] + for index, day in enumerate(dates, 1): + if index == len(dates) or dates[index] != day: + points.append((day, index)) + if points[-1][0] < end: + points.append((end, len(dates))) + + def x(day: dt.date) -> float: + return left + (day - start).days / span * plot_w + + max_stars = len(dates) + grid_max = ((max_stars + 4999) // 5000) * 5000 or 1 + + def y(value: int) -> float: + return top + (1 - value / grid_max) * plot_h + + path = " ".join( + ("M" if index == 0 else "L") + f" {x(day):.1f} {y(count):.1f}" + for index, (day, count) in enumerate(points) + ) + area = f"{path} L {x(end):.1f} {top + plot_h:.1f} L {x(start):.1f} {top + plot_h:.1f} Z" + + y_ticks = [] + for index in range(5): + value = round(grid_max * index / 4) + yy = y(value) + label = f"{value / 1000:g}k" if value >= 1000 else str(value) + y_ticks.append(f'{label}') + + x_ticks = [] + years = range(start.year, end.year + 1) + for year in years: + day = max(start, dt.date(year, 1, 1)) + if day > end: + continue + xx = x(day) + x_ticks.append(f'{day.year}') + + return f''' +{repository} star history +GitHub stars over time, currently {max_stars:,} + + +GitHub stars over time +{''.join(y_ticks)}{''.join(x_ticks)} + + +{max_stars:,} stars + +''' + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", default="1jehuang/jcode") + parser.add_argument("--output", type=Path, default=Path("docs/images/star-history.svg")) + args = parser.parse_args() + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if not token: + raise SystemExit("GITHUB_TOKEN or GH_TOKEN is required") + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render_svg(args.repo, fetch_stars(args.repo, token))) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/update-star-history.yml b/.github/workflows/update-star-history.yml new file mode 100644 index 0000000000..e251d9acdb --- /dev/null +++ b/.github/workflows/update-star-history.yml @@ -0,0 +1,34 @@ +name: Update star history + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: update-star-history + cancel-in-progress: true + +jobs: + update: + if: github.repository == '1jehuang/jcode' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Generate chart using repository-authorized star data + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 .github/scripts/generate_star_history.py + - name: Commit updated chart + run: | + if git diff --quiet -- docs/images/star-history.svg; then + exit 0 + fi + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add docs/images/star-history.svg + git commit -m "docs: update star history chart" + git push diff --git a/README.md b/README.md index eb0665157c..56670f0f1c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The most intelligent harness 1jehuang/jcode | Trendshift -Stargazers over time +jcode GitHub stars over time jcode YC launch video diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg new file mode 100644 index 0000000000..4599cc8b9d --- /dev/null +++ b/docs/images/star-history.svg @@ -0,0 +1,17 @@ + +1jehuang/jcode star history +GitHub stars over time, currently 17,500 + + +GitHub stars over time +05k10k15k20k2026 + + +17,500 stars + From 6886483c782baae8b6ccdb71afd7df67db768528 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:17:09 +0000 Subject: [PATCH 005/107] docs: update star history chart --- docs/images/star-history.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg index 4599cc8b9d..fd72dc5faa 100644 --- a/docs/images/star-history.svg +++ b/docs/images/star-history.svg @@ -1,6 +1,6 @@ 1jehuang/jcode star history -GitHub stars over time, currently 17,500 +GitHub stars over time, currently 17,501 From bbef1f6a8ebf00c52b25bc41ad5dd5226da9d99d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:49:54 -0700 Subject: [PATCH 006/107] chore(release): prepare v0.76.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- changelog/index.json | 4 ++++ changelog/v0.76.0.json | 22 ++++++++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 changelog/v0.76.0.json diff --git a/Cargo.lock b/Cargo.lock index 0d6fe824e3..a74806e917 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3355,7 +3355,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.75.5" +version = "0.76.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index f7f0da8c87..e0edc19704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.75.5" +version = "0.76.0" description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false diff --git a/changelog/index.json b/changelog/index.json index ce60244021..49e70e0ae4 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,5 +1,9 @@ { "entries": [ + { + "version": "0.76.0", + "date": "2026-08-14" + }, { "version": "0.75.5", "date": "2026-08-12" diff --git a/changelog/v0.76.0.json b/changelog/v0.76.0.json new file mode 100644 index 0000000000..6eddfa64dd --- /dev/null +++ b/changelog/v0.76.0.json @@ -0,0 +1,22 @@ +{ + "version": "0.76.0", + "date": "2026-08-14", + "title": "Transcript privacy and broader provider support", + "highlights": [ + "Opt-in transcript telemetry now supports privacy-preserving collection with automatic secret redaction", + "Anthropic-compatible provider profiles can now connect to additional compatible services", + "Grok Build login can now be completed directly inside the TUI" + ], + "improvements": [ + "Startup update checks can now be disabled in configuration", + "Transient provider retries are more resilient and configurable", + "Z.AI Coding Plan supports reasoning effort controls and safely handles text-only models" + ], + "fixes": [ + "OpenRouter responses preserve tool outputs even when the corresponding tool call is unavailable", + "Prompt files are deduplicated and oversized skill context is clipped", + "Repeated paste placeholders expand correctly", + "Self-development builds promote artifacts to the correct paths", + "Grok model lists refresh after login and use the current managed backend" + ] +} From 81a8ca90c9bb28f2258fd4b5cc0f266f442e869a Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:55:23 -0700 Subject: [PATCH 007/107] fix: honor setup-hotkey uninstall (fixes #923) --- crates/jcode-setup-hints/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/jcode-setup-hints/src/lib.rs b/crates/jcode-setup-hints/src/lib.rs index 1e6d0e602c..cf97a37993 100644 --- a/crates/jcode-setup-hints/src/lib.rs +++ b/crates/jcode-setup-hints/src/lib.rs @@ -673,6 +673,9 @@ pub fn run_setup_hotkey( if _listen_macos_hotkey { return run_macos_hotkey_listener(); } + if _uninstall { + return uninstall_macos_hotkey_listener(); + } let mut state = SetupHintsState::load(); let terminal = effective_macos_terminal(); @@ -712,6 +715,10 @@ pub fn run_setup_hotkey( #[cfg(target_os = "linux")] { + if _uninstall { + return uninstall_linux_launch_hotkeys(); + } + let mut state = SetupHintsState::load(); eprintln!("\x1b[1mjcode setup-hotkey\x1b[0m"); eprintln!(); @@ -1676,6 +1683,16 @@ fn install_linux_launch_hotkeys(comp: linux_env::LinuxCompositor) -> Result Result<()> { + anyhow::bail!( + "automatic launch-hotkey removal is not supported for this Linux desktop; no changes were made" + ) +} + /// Install (or refresh) the niri launch-hotkey binds into the user's /// `config.kdl`. Writes a timestamped backup before modifying, and is a no-op /// when the managed block already matches. Returns `Ok(true)` if the config was From 834ea8dcc4db7d303afc19fa0b9f7c7d2f920bee Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:55:24 -0700 Subject: [PATCH 008/107] fix: dispatch hyphenated MCP tool names (fixes #936) --- crates/jcode-app-core/src/tool/mcp.rs | 15 +++++++-------- crates/jcode-base/src/mcp/mod.rs | 2 +- crates/jcode-base/src/mcp/tool.rs | 25 +++++++++++++++++++++++-- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/crates/jcode-app-core/src/tool/mcp.rs b/crates/jcode-app-core/src/tool/mcp.rs index e296d63740..ed62cfd7ba 100644 --- a/crates/jcode-app-core/src/tool/mcp.rs +++ b/crates/jcode-app-core/src/tool/mcp.rs @@ -189,9 +189,8 @@ impl McpManagementTool { } else { for (_, tool) in server_tools { output.push_str(&format!( - " - mcp__{}__{}: {}\n", - server, - tool.name, + " - {}: {}\n", + crate::mcp::dispatch_name(server, &tool.name), tool.description.as_deref().unwrap_or("(no description)") )); } @@ -280,9 +279,8 @@ impl McpManagementTool { ); for (_, tool) in &server_tools { output.push_str(&format!( - " - mcp__{}__{}: {}\n", - server_name, - tool.name, + " - {}: {}\n", + crate::mcp::dispatch_name(&server_name, &tool.name), tool.description.as_deref().unwrap_or("(no description)") )); } @@ -291,8 +289,9 @@ impl McpManagementTool { // Register the new tools in the registry if let Some(ref registry) = self.registry { let mcp_tools = crate::mcp::create_mcp_tools(Arc::clone(&self.manager)).await; + let server_prefix = crate::mcp::dispatch_name(&server_name, ""); for (name, tool) in mcp_tools { - if name.starts_with(&format!("mcp__{}__", server_name)) { + if name.starts_with(&server_prefix) { registry.register(name, tool).await; } } @@ -347,7 +346,7 @@ impl McpManagementTool { // Unregister tools for this server if let Some(ref registry) = self.registry { let removed = registry - .unregister_prefix(&format!("mcp__{}__", server_name)) + .unregister_prefix(&crate::mcp::dispatch_name(&server_name, "")) .await; crate::logging::event_info( "MCP_LIFECYCLE", diff --git a/crates/jcode-base/src/mcp/mod.rs b/crates/jcode-base/src/mcp/mod.rs index 0f7063ce63..0d23520364 100644 --- a/crates/jcode-base/src/mcp/mod.rs +++ b/crates/jcode-base/src/mcp/mod.rs @@ -16,4 +16,4 @@ pub use manager::McpManager; pub use pool::{SharedMcpPool, get_shared_pool, init_shared_pool}; pub use protocol::*; pub use schema_cache::{McpSchemaCache, fingerprint_config}; -pub use tool::{McpTool, create_mcp_tools, create_mcp_tools_from_cached}; +pub use tool::{McpTool, create_mcp_tools, create_mcp_tools_from_cached, dispatch_name}; diff --git a/crates/jcode-base/src/mcp/tool.rs b/crates/jcode-base/src/mcp/tool.rs index 9a2683caf9..1777e23b48 100644 --- a/crates/jcode-base/src/mcp/tool.rs +++ b/crates/jcode-base/src/mcp/tool.rs @@ -106,6 +106,10 @@ impl Tool for McpTool { } } +pub fn dispatch_name(server_name: &str, tool_name: &str) -> String { + format!("mcp__{}__{}", server_name, tool_name).replace('-', "_") +} + /// Create tools from an MCP manager pub async fn create_mcp_tools(manager: Arc>) -> Vec<(String, Arc)> { let mgr = manager.read().await; @@ -114,7 +118,7 @@ pub async fn create_mcp_tools(manager: Arc>) -> Vec<(String, let mut tools = Vec::new(); for (server_name, tool_def) in all_tools { - let prefixed_name = format!("mcp__{}__{}", server_name, tool_def.name); + let prefixed_name = dispatch_name(&server_name, &tool_def.name); let mcp_tool = McpTool::new(server_name, tool_def, Arc::clone(&manager)); tools.push((prefixed_name, Arc::new(mcp_tool) as Arc)); } @@ -133,7 +137,7 @@ pub fn create_mcp_tools_from_cached( tool_defs .iter() .map(|tool_def| { - let prefixed_name = format!("mcp__{}__{}", server_name, tool_def.name); + let prefixed_name = dispatch_name(server_name, &tool_def.name); let mcp_tool = McpTool::new( server_name.to_string(), tool_def.clone(), @@ -143,3 +147,20 @@ pub fn create_mcp_tools_from_cached( }) .collect() } + +#[cfg(test)] +mod tests { + use super::dispatch_name; + + #[test] + fn hyphenated_mcp_names_are_safe_for_the_standard_dispatcher() { + assert_eq!( + dispatch_name("context7", "resolve-library-id"), + "mcp__context7__resolve_library_id" + ); + assert_eq!( + dispatch_name("hyphenated-server", "query-docs"), + "mcp__hyphenated_server__query_docs" + ); + } +} From 62de1469718bfccab1125f11934adca72b9e2399 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:55:53 -0700 Subject: [PATCH 009/107] fix: render pasted content after sending (fixes #921) --- crates/jcode-tui/src/tui/app/input.rs | 5 +++-- .../src/tui/app/tests/remote_startup_input_03/part_01.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 1b62dd7a56..02fa006976 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -3767,7 +3767,8 @@ impl App { // Leaving the preview should happen as soon as the user acts on it. self.onboarding_preview_mode = false; - // Add user message to display (show placeholder to user, not full paste) + // Add the expanded user message to the transcript. The composer remains compact + // while editing, but sent turns should show the actual pasted content. // Remember the typed prompt so we can restore it to the input box if this // turn fails (e.g. "token refresh needed"), instead of dropping it. self.last_submitted_input = Some(raw_input.clone()); @@ -3780,7 +3781,7 @@ impl App { self.push_display_message(DisplayMessage { role: "user".to_string(), - content: raw_input, // Show placeholder to user (condensed view) + content: input.clone(), tool_calls: vec![], duration_secs: None, title: None, diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs index 8f0cda0804..d65990bdb8 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs @@ -426,9 +426,9 @@ fn test_paste_expansion_on_submit() { // Submit expands placeholder app.submit_input(); - // Display shows placeholder (user sees condensed view) + // Sent transcript renders the actual pasted content, while the composer above stayed compact. assert_eq!(app.display_messages().len(), 1); - assert_eq!(app.display_messages()[0].content, "A: [pasted 5 lines] B"); + assert_eq!(app.display_messages()[0].content, "A: 1\n2\n3\n4\n5 B"); // Model receives expanded content (actual pasted text). Local sessions keep the // provider message cache lazy, so inspect the materialized provider view. From 3efaff35ab180a9c77531343e6bf67fa5d98fdac Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:55:59 -0700 Subject: [PATCH 010/107] fix: preserve repeated OpenRouter tool outputs (fixes #929) --- .../jcode-provider-openrouter/src/request.rs | 81 ++++++++++++------- 1 file changed, 53 insertions(+), 28 deletions(-) diff --git a/crates/jcode-provider-openrouter/src/request.rs b/crates/jcode-provider-openrouter/src/request.rs index bb1a14a980..b7c68d4469 100644 --- a/crates/jcode-provider-openrouter/src/request.rs +++ b/crates/jcode-provider-openrouter/src/request.rs @@ -2,7 +2,7 @@ use jcode_message_types::{ ContentBlock, Message, Role, TOOL_OUTPUT_MISSING_TEXT, sanitize_tool_id, }; use serde_json::Value; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; /// Normalize a tool `parameters` JSON schema for whichever upstream OpenRouter /// routes the model to. @@ -479,7 +479,8 @@ pub fn build_chat_messages( } // Final pass: ensure tool outputs immediately follow assistant tool calls. - let mut tool_output_map: HashMap = HashMap::new(); + let mut tool_output_map: HashMap> = HashMap::new(); + let mut missing_tool_outputs: HashMap = HashMap::new(); for msg in &api_messages { if msg.get("role").and_then(|v| v.as_str()) == Some("tool") && let Some(id) = msg.get("tool_call_id").and_then(|v| v.as_str()) @@ -489,26 +490,20 @@ pub fn build_chat_messages( .and_then(|v| v.as_str()) .map(|v| v == missing_output) .unwrap_or(false); - match tool_output_map.get(id) { - Some(existing) => { - let existing_missing = existing - .get("content") - .and_then(|v| v.as_str()) - .map(|v| v == missing_output) - .unwrap_or(false); - if existing_missing && !is_missing { - tool_output_map.insert(id.to_string(), msg.clone()); - } - } - None => { - tool_output_map.insert(id.to_string(), msg.clone()); - } + if is_missing { + missing_tool_outputs + .entry(id.to_string()) + .or_insert_with(|| msg.clone()); + } else { + tool_output_map + .entry(id.to_string()) + .or_default() + .push_back(msg.clone()); } } } let mut reordered: Vec = Vec::with_capacity(api_messages.len()); - let mut used_outputs: HashSet = HashSet::new(); let mut injected_ordered = 0usize; let mut dropped_orphans = 0usize; @@ -524,9 +519,12 @@ pub fn build_chat_messages( reordered.push(msg); for call in tool_calls { if let Some(id) = call.get("id").and_then(|v| v.as_str()) { - if let Some(tool_msg) = tool_output_map.get(id) { - reordered.push(tool_msg.clone()); - used_outputs.insert(id.to_string()); + if let Some(tool_msg) = tool_output_map + .get_mut(id) + .and_then(VecDeque::pop_front) + .or_else(|| missing_tool_outputs.get(id).cloned()) + { + reordered.push(tool_msg); } else { injected_ordered += 1; reordered.push(serde_json::json!({ @@ -534,7 +532,6 @@ pub fn build_chat_messages( "tool_call_id": id, "content": missing_output.clone() })); - used_outputs.insert(id.to_string()); } } } @@ -543,12 +540,6 @@ pub fn build_chat_messages( } if role == "tool" { - if let Some(id) = msg.get("tool_call_id").and_then(|v| v.as_str()) - && used_outputs.contains(id) - { - dropped_orphans += 1; - continue; - } dropped_orphans += 1; continue; } @@ -577,9 +568,43 @@ pub fn build_chat_messages( #[cfg(test)] mod request_tests { use super::build_chat_messages; - use jcode_message_types::Message; + use jcode_message_types::{ContentBlock, Message, Role}; use serde_json::json; + fn tool_call(id: &str, output: &str) -> [Message; 2] { + [ + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: id.to_string(), + name: "read".to_string(), + input: json!({"path": output}), + thought_signature: None, + }], + timestamp: None, + tool_duration_ms: None, + }, + Message::tool_result(id, output, false), + ] + } + + #[test] + fn repeated_tool_call_ids_get_their_own_outputs_in_order() { + let messages = tool_call("read:0", "first output") + .into_iter() + .chain(tool_call("read_0", "second output")) + .collect::>(); + + let api_messages = build_chat_messages(&messages, "", false, false, false); + let outputs = api_messages + .iter() + .filter(|message| message["role"] == "tool") + .map(|message| message["content"].as_str().unwrap()) + .collect::>(); + + assert_eq!(outputs, ["first output", "second output"]); + } + #[test] fn orphaned_tool_output_is_recovered_as_a_user_message() { let messages = vec![Message::tool_result("call_orphan", "orphan result", false)]; From 4285784557947441e29bf4b61e83c42f8342ce94 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:56:53 -0700 Subject: [PATCH 011/107] fix: show collapsed bash tool output (fixes #920) --- crates/jcode-tui/src/tui/ui_messages.rs | 18 ++++++++++ crates/jcode-tui/src/tui/ui_messages/tests.rs | 36 +++++++++++++++---- crates/jcode-tui/src/tui/ui_tests/tools.rs | 11 +++--- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/crates/jcode-tui/src/tui/ui_messages.rs b/crates/jcode-tui/src/tui/ui_messages.rs index fb56568ed6..f125dd8e5e 100644 --- a/crates/jcode-tui/src/tui/ui_messages.rs +++ b/crates/jcode-tui/src/tui/ui_messages.rs @@ -4152,6 +4152,24 @@ pub(crate) fn render_tool_message( } } + if tools_ui::canonical_tool_name(&tc.name) == "bash" + && msg.content.trim() != "Command completed successfully (no output)" + { + const MAX_COLLAPSED_OUTPUT_LINES: usize = 3; + let output_lines = msg.content.lines().filter(|line| !line.trim().is_empty()); + let total = output_lines.clone().count(); + for output in output_lines.skip(total.saturating_sub(MAX_COLLAPSED_OUTPUT_LINES)) { + let output_line = Line::from(vec![ + Span::styled(" │ ", Style::default().fg(dim_color())), + Span::styled(output.to_string(), Style::default().fg(dim_color())), + ]); + lines.push(super::truncate_line_with_ellipsis_to_width( + &output_line, + row_width, + )); + } + } + if tc.name == "batch" && let Some(calls) = tc.input.get("tool_calls").and_then(|v| v.as_array()) { diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index c60bf2c6d2..34239cf3c4 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -1951,11 +1951,8 @@ fn render_tool_message_shows_intent_and_technical_preview_on_one_line() { let rendered = extract_line_text(&lines[0]); assert!(rendered.contains("bash · Verify compact progress card · $ cargo test")); - assert_eq!( - lines.len(), - 1, - "intent should not add vertical space: {rendered}" - ); + assert_eq!(lines.len(), 2, "only Bash output should add a detail line"); + assert!(extract_line_text(&lines[1]).contains("ok")); crate::tui::ui::tools_ui::tests_tool_call_details_override::set(false); } @@ -1993,7 +1990,8 @@ fn render_tool_message_hides_technical_preview_by_default() { !rendered.contains("cargo test"), "technical detail should be hidden by default: {rendered}" ); - assert_eq!(lines.len(), 1, "no extra detail line expected: {rendered}"); + assert_eq!(lines.len(), 2, "only Bash output should add a detail line"); + assert!(extract_line_text(&lines[1]).contains("ok")); } /// Even with details off, a failed tool row keeps its error summary so @@ -2054,6 +2052,32 @@ fn render_tool_message_shows_token_badge() { assert_eq!(badge_span.style.fg, Some(rgb(118, 118, 118))); } +#[test] +fn render_tool_message_shows_trimmed_bash_output() { + let msg = DisplayMessage { + role: "tool".to_string(), + content: "\n[('p', 'b'), ('a', 'a'), ('l', 'l'), ('e', 'e')]".to_string(), + tool_calls: Vec::new(), + duration_secs: None, + title: None, + tool_data: Some(crate::message::ToolCall { + id: "call_bash_output".to_string(), + name: "bash".to_string(), + input: serde_json::json!({ + "command": "python3 -c \"s='pale'; t='bale'; print(type(zip(s,t))); print(list(zip(s,t)))\"" + }), + intent: None, + thought_signature: None, + }), + }; + + let lines = render_tool_message(&msg, 120, crate::config::DiffDisplayMode::Off); + let rendered = lines.iter().map(extract_line_text).collect::>(); + + assert!(rendered.iter().any(|line| line.contains(""))); + assert!(rendered.iter().any(|line| line.contains("[('p', 'b')"))); +} + fn gmail_draft_message(content: &str, input: serde_json::Value) -> DisplayMessage { DisplayMessage { role: "tool".to_string(), diff --git a/crates/jcode-tui/src/tui/ui_tests/tools.rs b/crates/jcode-tui/src/tui/ui_tests/tools.rs index 79fa729b16..4ebc6e0bcc 100644 --- a/crates/jcode-tui/src/tui/ui_tests/tools.rs +++ b/crates/jcode-tui/src/tui/ui_tests/tools.rs @@ -1169,15 +1169,14 @@ fn test_render_tool_message_with_intent_never_adds_second_command_line() { let rendered: Vec = lines.iter().map(extract_line_text).collect(); assert!(!rendered.is_empty(), "rendered={rendered:?}"); - assert_eq!( - rendered.len(), - 1, - "intent rows must stay single-line: {rendered:?}" - ); + assert_eq!(rendered.len(), 2, "Bash output should add one line: {rendered:?}"); assert!( - !rendered[0].trim_start().starts_with('$'), + rendered + .iter() + .all(|line| !line.trim_start().starts_with('$')), "rendered={rendered:?}" ); + assert!(rendered[1].contains("ok"), "rendered={rendered:?}"); } #[test] From 911a0248b0ff84a0d5edf5066040d8fc40cda383 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:57:10 -0700 Subject: [PATCH 012/107] fix: report Grok smoke failures accurately (fixes #937) --- crates/jcode-base/src/auth/doctor.rs | 64 +++++++++++++++++-- .../src/bin/fake_acp.rs | 7 ++ .../src/lib.rs | 52 +++++++++++---- .../tests/fake_acp.rs | 18 ++++++ src/cli/auth_test/run.rs | 12 +++- 5 files changed, 135 insertions(+), 18 deletions(-) diff --git a/crates/jcode-base/src/auth/doctor.rs b/crates/jcode-base/src/auth/doctor.rs index 6ae59cfbe8..26fe3500bc 100644 --- a/crates/jcode-base/src/auth/doctor.rs +++ b/crates/jcode-base/src/auth/doctor.rs @@ -170,10 +170,32 @@ pub fn recommended_actions( "Run runtime verification: jcode auth-test --provider {}", provider.id )), - Some(record) if !record.success => actions.push(format!( - "Inspect runtime readiness: jcode auth-test --provider {}", - provider.id - )), + Some(record) if !record.success => { + let summary = record.summary.to_ascii_lowercase(); + if summary.contains("402") + || summary.contains("payment required") + || summary.contains("balance exhausted") + { + actions.push( + "Restore the provider's billing or usage balance. Re-authenticating will not fix this failure." + .to_string(), + ); + } else if summary.contains("429") + || summary.contains("quota exhausted") + || summary.contains("rate limit") + || summary.contains("too many requests") + { + actions.push( + "Wait for or increase the provider quota before retrying. Re-authenticating will not fix this failure." + .to_string(), + ); + } else { + actions.push(format!( + "Inspect runtime readiness: jcode auth-test --provider {}", + provider.id + )); + } + } Some(record) if validation_is_stale(record.checked_at_ms) => actions.push(format!( "Refresh stale runtime verification: jcode auth-test --provider {}", provider.id @@ -263,6 +285,40 @@ mod tests { ); } + #[test] + fn billing_validation_failure_does_not_recommend_reauthentication() { + let mut assessment = base_assessment(); + let validation = assessment.last_validation.as_mut().unwrap(); + validation.success = false; + validation.provider_smoke_ok = Some(false); + validation.summary = "provider_smoke: API error (status 402 Payment Required): Grok Build usage balance exhausted".to_string(); + + let actions = recommended_actions( + crate::provider_catalog::login_providers() + .iter() + .copied() + .find(|provider| provider.id == "grok-build") + .unwrap(), + &assessment, + None, + ); + assert!( + actions + .iter() + .any(|action| action.contains("billing or usage balance")) + ); + assert!( + actions + .iter() + .any(|action| action.contains("Re-authenticating will not fix")) + ); + assert!( + !actions + .iter() + .any(|action| action.contains("Inspect runtime readiness")) + ); + } + #[test] fn stale_validation_marks_provider_as_needing_attention() { let mut assessment = base_assessment(); diff --git a/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs b/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs index f849fb3082..2286b4828f 100644 --- a/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs +++ b/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs @@ -90,6 +90,13 @@ fn main() { if std::env::var_os("JCODE_FAKE_GROK_ACP_HANG").is_some() { continue; } + if std::env::var_os("JCODE_FAKE_GROK_ACP_PAYMENT_REQUIRED").is_some() { + eprintln!( + "Error: Internal error: {{\"message\":\"API error (status 402 Payment Required): Grok Build usage balance exhausted\",\"http_status\":402}}" + ); + response(id, json!({"stopReason":"end_turn"})); + continue; + } send(json!({ "jsonrpc":"2.0", "method":"_x.ai/settings/update", diff --git a/crates/jcode-provider-grok-build-runtime/src/lib.rs b/crates/jcode-provider-grok-build-runtime/src/lib.rs index dc5ac4bd97..a206a00944 100644 --- a/crates/jcode-provider-grok-build-runtime/src/lib.rs +++ b/crates/jcode-provider-grok-build-runtime/src/lib.rs @@ -154,10 +154,7 @@ impl Provider for GrokBuildProvider { tx.clone(), cancel_rx, ) { - let _ = tx.blocking_send(Ok(StreamEvent::Error { - message: format!("{error:#}"), - retry_after_secs: None, - })); + let _ = tx.blocking_send(Err(error)); } }) .context("Failed to start Grok Build ACP runtime thread")?; @@ -576,30 +573,59 @@ where .take() .context("Grok CLI stderr was unavailable")?; let stderr_capture = Arc::new(std::sync::Mutex::new(String::new())); - let stderr_task = tokio::task::spawn_local(capture_stderr(stderr, Arc::clone(&stderr_capture))); + let mut stderr_task = + tokio::task::spawn_local(capture_stderr(stderr, Arc::clone(&stderr_capture))); - let client = GrokAcpClient { tx: event_tx }; + let received_message = Arc::new(AtomicBool::new(false)); + let client = GrokAcpClient { + tx: event_tx, + received_message: Arc::clone(&received_message), + }; let (connection, io) = acp::ClientSideConnection::new(client, stdin.compat_write(), stdout.compat(), |future| { tokio::task::spawn_local(future); }); let io_task = tokio::task::spawn_local(io); let result = operation(connection).await; - drop(child); + let _ = child.kill().await; io_task.abort(); + let _ = tokio::time::timeout(Duration::from_millis(100), &mut stderr_task).await; stderr_task.abort(); + let stderr = stderr_capture + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .trim() + .to_string(); + if result.is_ok() + && !received_message.load(Ordering::Acquire) + && stderr_reports_provider_failure(&stderr) + { + bail!("Grok CLI provider request failed: {stderr}"); + } result.map_err(|error| { - let stderr = stderr_capture - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if stderr.trim().is_empty() { + if stderr.is_empty() { error } else { - error.context(format!("Grok CLI stderr: {}", stderr.trim())) + error.context(format!("Grok CLI stderr: {stderr}")) } }) } +fn stderr_reports_provider_failure(stderr: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + [ + "api error", + "payment required", + "balance exhausted", + "quota exhausted", + "too many requests", + "rate limit", + "http_status", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + async fn capture_stderr( mut stderr: tokio::process::ChildStderr, capture: Arc>, @@ -624,6 +650,7 @@ async fn capture_stderr( struct GrokAcpClient { tx: mpsc::Sender>, + received_message: Arc, } #[async_trait(?Send)] @@ -653,6 +680,7 @@ impl acp::Client for GrokAcpClient { ) -> acp::Result<()> { let event = match notification.update { acp::SessionUpdate::AgentMessageChunk(chunk) => { + self.received_message.store(true, Ordering::Release); text_from_acp_content(chunk.content).map(StreamEvent::TextDelta) } acp::SessionUpdate::AgentThoughtChunk(chunk) => { diff --git a/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs b/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs index e3a5c7b141..6d4cca990e 100644 --- a/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs +++ b/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs @@ -18,6 +18,24 @@ fn fake_process(log: &Path) -> GrokBuildProcess { } } +#[tokio::test(flavor = "current_thread")] +async fn provider_surfaces_payment_failure_written_to_subprocess_stderr() { + let temp = tempfile::tempdir().unwrap(); + let mut process = fake_process(&temp.path().join("requests.jsonl")); + process + .env + .insert("JCODE_FAKE_GROK_ACP_PAYMENT_REQUIRED".into(), "1".into()); + let provider = GrokBuildProvider::with_process(process); + + let error = provider + .complete_simple("Reply exactly AUTH_TEST_OK", "") + .await + .unwrap_err(); + let detail = format!("{error:#}"); + assert!(detail.contains("402 Payment Required"), "{detail}"); + assert!(detail.contains("usage balance exhausted"), "{detail}"); +} + #[tokio::test(flavor = "current_thread")] async fn fake_subprocess_covers_handshake_models_new_prompt_and_auth_isolation() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/cli/auth_test/run.rs b/src/cli/auth_test/run.rs index 6212d359be..a70536a90b 100644 --- a/src/cli/auth_test/run.rs +++ b/src/cli/auth_test/run.rs @@ -38,7 +38,11 @@ async fn maybe_run_auth_test_smoke( }, ); } - Err(err) => report.push_step(kind.step_name(), false, format!("{err:#}")), + Err(err) => { + let detail = format!("{err:#}"); + kind.set_output(report, detail.clone()); + report.push_step(kind.step_name(), false, detail); + } } } else if !target.supports_smoke() { report.push_step(kind.step_name(), true, kind.unsupported_detail()); @@ -79,7 +83,11 @@ async fn maybe_run_auth_test_smoke_for_choice( }, ); } - Err(err) => report.push_step(kind.step_name(), false, format!("{err:#}")), + Err(err) => { + let detail = format!("{err:#}"); + kind.set_output(report, detail.clone()); + report.push_step(kind.step_name(), false, detail); + } } } Ok(AuthTestChoicePlan::Skip(detail)) => { From 5ffba9482ec144723f514a2497fb6fea81759a5d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:57:51 -0700 Subject: [PATCH 013/107] style: format collapsed bash output test --- crates/jcode-tui/src/tui/ui_tests/tools.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/ui_tests/tools.rs b/crates/jcode-tui/src/tui/ui_tests/tools.rs index 4ebc6e0bcc..d955033d71 100644 --- a/crates/jcode-tui/src/tui/ui_tests/tools.rs +++ b/crates/jcode-tui/src/tui/ui_tests/tools.rs @@ -1169,7 +1169,11 @@ fn test_render_tool_message_with_intent_never_adds_second_command_line() { let rendered: Vec = lines.iter().map(extract_line_text).collect(); assert!(!rendered.is_empty(), "rendered={rendered:?}"); - assert_eq!(rendered.len(), 2, "Bash output should add one line: {rendered:?}"); + assert_eq!( + rendered.len(), + 2, + "Bash output should add one line: {rendered:?}" + ); assert!( rendered .iter() From a9602eeaf53374b02823b1d7f418ca677ee06897 Mon Sep 17 00:00:00 2001 From: sergey Date: Mon, 3 Aug 2026 00:24:04 +0300 Subject: [PATCH 014/107] fix(mcp): reconnect when pooled server process died begin_connect returned Connected for any handle present in the pool, even when the backing child process had crashed or been killed. Every tool call on such a stale handle failed with 'Failed to send request' (writer channel closed) and reconnect was impossible without a server restart. begin_connect now checks child liveness (is_running) and drops the stale client/handle/refcount before returning Leader so a fresh process is spawned. Adds begin_connect_replaces_dead_client test. AI-assisted: Jcode --- crates/jcode-base/src/mcp/client.rs | 15 +++++++ crates/jcode-base/src/mcp/pool.rs | 61 ++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/jcode-base/src/mcp/client.rs b/crates/jcode-base/src/mcp/client.rs index 68dd469890..31ff03cc15 100644 --- a/crates/jcode-base/src/mcp/client.rs +++ b/crates/jcode-base/src/mcp/client.rs @@ -26,6 +26,21 @@ pub struct McpHandle { } impl McpHandle { + /// Test-only constructor: a handle that is not backed by any process. + #[cfg(test)] + pub(crate) fn new_dummy_for_tests(name: String) -> Self { + let (writer_tx, _writer_rx) = tokio::sync::mpsc::channel::(8); + Self { + name, + request_id: Arc::new(AtomicU64::new(1)), + pending: Arc::new(Mutex::new(HashMap::new())), + writer_tx, + server_info: Arc::new(std::sync::RwLock::new(None)), + capabilities: Arc::new(std::sync::RwLock::new(ServerCapabilities::default())), + tools: Arc::new(std::sync::RwLock::new(Vec::new())), + } + } + /// Send a request and wait for response pub async fn request(&self, method: &str, params: Option) -> Result { let id = self.request_id.fetch_add(1, Ordering::SeqCst); diff --git a/crates/jcode-base/src/mcp/pool.rs b/crates/jcode-base/src/mcp/pool.rs index a0d69d2b27..13f0d6985f 100644 --- a/crates/jcode-base/src/mcp/pool.rs +++ b/crates/jcode-base/src/mcp/pool.rs @@ -25,6 +25,7 @@ struct FailedConnectRecord { failed_at: Instant, } +#[derive(Debug)] enum ConnectAttempt { Connected, Leader(Arc), @@ -287,7 +288,29 @@ impl SharedMcpPool { } if self.handles.read().await.contains_key(name) { - return ConnectAttempt::Connected; + // The pool may hold a handle whose child process died (e.g. the + // MCP server crashed or was killed externally). Detect that here + // so a reconnect spawns a fresh process instead of returning the + // stale handle, which would make every tool call fail with + // "Failed to send request" (writer channel closed). + let mut clients = self.clients.lock().await; + let dead = match clients.get_mut(name) { + Some(client) => !client.is_running(), + None => true, // handle without client -> treat as dead + }; + if dead { + clients.remove(name); + drop(clients); + let mut handles = self.handles.write().await; + handles.remove(name); + let mut refs = self.ref_counts.lock().await; + refs.remove(name); + let mut errors = self.last_errors.write().await; + errors.remove(name); + } else { + drop(clients); + return ConnectAttempt::Connected; + } } let notify = Arc::new(Notify::new()); @@ -494,6 +517,42 @@ mod tests { assert!(Arc::ptr_eq(&first_notify, &second_notify)); } + #[tokio::test] + async fn begin_connect_replaces_dead_client() { + // A handle whose child process has exited must not be returned as + // "Connected": every tool call on it would fail with a closed writer + // channel. begin_connect should drop the stale client/handle and + // return Leader so a fresh process is spawned. + let pool = Arc::new(SharedMcpPool::new(McpConfig::default())); + + // A handle with no backing client is treated as dead (crashed + // process): begin_connect must clean it up and return Leader. + pool.handles + .write() + .await + .insert( + "stale".to_string(), + crate::mcp::McpHandle::new_dummy_for_tests("stale".to_string()), + ); + pool.ref_counts + .lock() + .await + .insert("stale".to_string(), 1); + + let attempt = pool.begin_connect("stale").await; + match attempt { + ConnectAttempt::Leader(_) => { + // Stale handle was cleaned up; a fresh connect will spawn. + } + other => panic!("stale handle must yield Leader, got {other:?}"), + } + + // The stale handle must be gone from the pool. + assert!(!pool.handles.read().await.contains_key("stale")); + assert!(!pool.clients.lock().await.contains_key("stale")); + assert!(!pool.ref_counts.lock().await.contains_key("stale")); + } + #[tokio::test] async fn connect_all_skips_non_shared_servers() { // Issue #557: shared:false servers are owned per-session and must not From 227c33003b2940dda808f594e865eb1fa71220a0 Mon Sep 17 00:00:00 2001 From: sergey Date: Sat, 15 Aug 2026 00:15:05 +0300 Subject: [PATCH 015/107] test: expose harness attach working dir regression RED: attached SessionInfo drops requested working_dir while permission responses are documented as unsupported without the permissions capability.\n\nAI-assisted: Jcode --- .../src/translate_tests.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/jcode-harness-api-server/src/translate_tests.rs b/crates/jcode-harness-api-server/src/translate_tests.rs index dc722b90b3..d94a89ab58 100644 --- a/crates/jcode-harness-api-server/src/translate_tests.rs +++ b/crates/jcode-harness-api-server/src/translate_tests.rs @@ -144,6 +144,57 @@ fn state_event_answers_pending_attach() { assert_eq!(state.session_id.as_deref(), Some("abc")); } +#[test] +fn attached_session_reports_requested_working_dir() { + let mut state = BridgeState::default(); + let requested_dir = "/tmp/jcode-sdk-requested-working-dir"; + let out = state.api_request_to_legacy(&json!({ + "req": "create_session", + "id": 5, + "working_dir": requested_dir, + })); + let Outbound::Legacy(state_req) = &out[1] else { + panic!("expected legacy state request"); + }; + let state_id = state_req["id"].as_u64().unwrap(); + + let frames = state.legacy_event_to_api(&json!({ + "type": "state", "id": state_id, "session_id": "abc", + "message_count": 0, "is_processing": false, + })); + + match &frames[0].event { + ApiEvent::Attached { session } => { + assert_eq!(session.session_id, "abc"); + assert_eq!(session.working_dir.as_deref(), Some(requested_dir)); + } + other => panic!("unexpected: {other:?}"), + } +} + +#[test] +fn permission_response_is_rejected_when_bridge_has_no_permission_capability() { + let mut state = state_with_session(); + let frames = state.api_request_to_legacy(&json!({ + "req": "permission_response", + "id": 77, + "request_id": "perm-1", + "decision": "allow", + })); + + assert_eq!(frames.len(), 1); + let Outbound::Reply(reply) = &frames[0] else { + panic!("expected local reply"); + }; + assert_eq!(reply.reply_to, Some(77)); + assert!(matches!( + &reply.event, + ApiEvent::Error { code: ErrorCode::InvalidRequest, message } + if message.contains("does not issue permission prompts") + && message.contains("no `permissions` capability") + )); +} + #[test] fn send_message_then_done_becomes_turn_done() { let mut state = state_with_session(); From 1f69fc94b40fd6d94eeb7065819c7b9353ac56cd Mon Sep 17 00:00:00 2001 From: sergey Date: Sat, 15 Aug 2026 00:15:35 +0300 Subject: [PATCH 016/107] fix: report attached harness working directory GREEN: preserve the effective create/attach working_dir through the state reply and cache it for session listings. Permission responses remain rejected because this bridge has no permissions capability or prompt source.\n\nAI-assisted: Jcode --- crates/jcode-harness-api-server/src/translate.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/jcode-harness-api-server/src/translate.rs b/crates/jcode-harness-api-server/src/translate.rs index af513c8502..b39b128d68 100644 --- a/crates/jcode-harness-api-server/src/translate.rs +++ b/crates/jcode-harness-api-server/src/translate.rs @@ -95,6 +95,8 @@ pub struct BridgeState { pending_no_reply_message_id: Option<(u64, u64)>, /// Legacy id of an in-flight `create/attach` subscribe. pending_attach_id: Option<(u64, u64)>, + /// Effective working directory requested for the in-flight attach. + pending_attach_working_dir: Option, /// Legacy id of the unsolicited model-catalog probe sent after attach. Its /// reply becomes a `model_info` event rather than a request reply, so it is /// tracked apart from `pending_simple`. @@ -288,6 +290,7 @@ impl BridgeState { .ok() .map(|d| d.display().to_string()) }); + self.pending_attach_working_dir = working_dir.clone(); let mut subscribe = json!({ "type": "subscribe", "id": id, @@ -743,13 +746,17 @@ impl BridgeState { && state_id == id { self.pending_attach_id = None; + let working_dir = self.pending_attach_working_dir.take(); + if let Some(dir) = &working_dir { + self.session_dirs.insert(session_id.clone(), dir.clone()); + } return vec![ServerFrame::reply( api_id, ApiEvent::Attached { session: SessionInfo { transcript_bytes: Self::transcript_bytes(&session_id), session_id, - working_dir: None, + working_dir, title: None, status: if event["is_processing"].as_bool().unwrap_or(false) { "processing".into() From f10130fa1270e97cc025c9806b495a5522b8a644 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:08:46 -0700 Subject: [PATCH 017/107] fix: allow disabling Claude MCP imports (fixes #944) --- crates/jcode-base/src/mcp/protocol.rs | 9 +++- crates/jcode-base/src/mcp/protocol_tests.rs | 56 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/jcode-base/src/mcp/protocol.rs b/crates/jcode-base/src/mcp/protocol.rs index 5a56265799..5f6564c408 100644 --- a/crates/jcode-base/src/mcp/protocol.rs +++ b/crates/jcode-base/src/mcp/protocol.rs @@ -607,6 +607,7 @@ impl McpConfig { Self::import_from_codex_once(); let mut merged = Self::default(); + let claude_mcp_enabled = std::env::var_os("JCODE_DISABLE_CLAUDE_MCP").is_none(); // Load jcode's own global config (~/.jcode/mcp.json) if let Ok(jcode_dir) = crate::storage::jcode_dir() { @@ -620,7 +621,9 @@ impl McpConfig { // Claude Code user/global config (~/.claude.json): top-level mcpServers // plus per-project entries for the project directory. - if let Ok(claude_json) = crate::storage::user_home_path(".claude.json") { + if claude_mcp_enabled + && let Ok(claude_json) = crate::storage::user_home_path(".claude.json") + { if claude_json.exists() { let cwd = project_dir.map(std::path::Path::to_path_buf); let config = Self::load_claude_json(&claude_json, cwd.as_deref()); @@ -637,7 +640,9 @@ impl McpConfig { // Older Claude Code global config is also a live source. Reading it on // every load preserves compatibility without copying any inline env // values into ~/.jcode/mcp.json. - if let Ok(claude_mcp) = crate::storage::user_home_path(".claude/mcp.json") { + if claude_mcp_enabled + && let Ok(claude_mcp) = crate::storage::user_home_path(".claude/mcp.json") + { if claude_mcp.exists() && let Ok(config) = Self::load_from_file(&claude_mcp) { diff --git a/crates/jcode-base/src/mcp/protocol_tests.rs b/crates/jcode-base/src/mcp/protocol_tests.rs index 4394d3dcfd..9dcadef294 100644 --- a/crates/jcode-base/src/mcp/protocol_tests.rs +++ b/crates/jcode-base/src/mcp/protocol_tests.rs @@ -757,6 +757,62 @@ fn legacy_claude_config_is_live_and_deletions_do_not_leave_a_snapshot() { result.expect("legacy Claude live-source assertions"); } +#[test] +fn disabling_claude_mcp_skips_both_live_sources_but_preserves_jcode_sources() { + let _guard = crate::storage::lock_test_env(); + let previous_home = std::env::var_os("JCODE_HOME"); + let previous_disable = std::env::var_os("JCODE_DISABLE_CLAUDE_MCP"); + let home = tempfile::tempdir().expect("home tempdir"); + let project = tempfile::tempdir().expect("project tempdir"); + crate::env::set_var("JCODE_HOME", home.path()); + crate::env::set_var("JCODE_DISABLE_CLAUDE_MCP", "1"); + + std::fs::write( + home.path().join("mcp.json"), + r#"{"mcpServers":{"jcode-global":{"command":"jcode-global"}}}"#, + ) + .expect("write jcode global config"); + std::fs::create_dir_all(project.path().join(".jcode")).expect("create jcode project dir"); + std::fs::write( + project.path().join(".jcode/mcp.json"), + r#"{"mcpServers":{"jcode-project":{"command":"jcode-project"}}}"#, + ) + .expect("write jcode project config"); + + let external = home.path().join("external"); + std::fs::create_dir_all(external.join(".claude")).expect("create Claude config dirs"); + std::fs::write( + external.join(".claude.json"), + r#"{"mcpServers":{"claude-current":{"command":"claude-current"}}}"#, + ) + .expect("write current Claude config"); + std::fs::write( + external.join(".claude/mcp.json"), + r#"{"mcpServers":{"claude-legacy":{"command":"claude-legacy"}}}"#, + ) + .expect("write legacy Claude config"); + + let result = std::panic::catch_unwind(|| { + let config = McpConfig::load_for_dir(Some(project.path())); + assert!(config.servers.contains_key("jcode-global")); + assert!(config.servers.contains_key("jcode-project")); + assert!(!config.servers.contains_key("claude-current")); + assert!(!config.servers.contains_key("claude-legacy")); + }); + + if let Some(previous_home) = previous_home { + crate::env::set_var("JCODE_HOME", previous_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + if let Some(previous_disable) = previous_disable { + crate::env::set_var("JCODE_DISABLE_CLAUDE_MCP", previous_disable); + } else { + crate::env::remove_var("JCODE_DISABLE_CLAUDE_MCP"); + } + result.expect("Claude MCP opt-out assertions"); +} + #[test] fn mcp_source_logs_explain_provenance_without_config_values() { let live = McpConfig::live_claude_log_message(2, "~/.claude.json"); From 1b968a823a122cde87dbc0ef75001dbe600dc02d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:09:18 -0700 Subject: [PATCH 018/107] fix: report malformed skill files (fixes #952) --- crates/jcode-base/src/skill.rs | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/jcode-base/src/skill.rs b/crates/jcode-base/src/skill.rs index 1e6f9321f0..bd43a345f6 100644 --- a/crates/jcode-base/src/skill.rs +++ b/crates/jcode-base/src/skill.rs @@ -477,6 +477,17 @@ impl SkillRegistry { /// Parse a SKILL.md file fn parse_skill(path: &Path) -> Result { + Self::parse_skill_inner(path).map_err(|error| { + crate::logging::warn(&format!( + "Failed to parse skill file '{}': {}", + path.display(), + error + )); + error + }) + } + + fn parse_skill_inner(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; // Parse YAML frontmatter @@ -962,6 +973,14 @@ mod tests { .expect("write skill"); } + fn write_skill_file(skills_dir: &Path, name: &str, content: &str) -> PathBuf { + let dir = skills_dir.join(name); + std::fs::create_dir_all(&dir).expect("create skill dir"); + let path = dir.join("SKILL.md"); + std::fs::write(&path, content).expect("write skill"); + path + } + #[test] fn parse_invocation_supports_a_trailing_prompt() { assert_eq!( @@ -1101,6 +1120,37 @@ mod tests { assert!(skill.path.starts_with(temp.path())); } + #[test] + fn malformed_skill_does_not_block_directory_loaders() { + let temp = tempfile::tempdir().expect("tempdir"); + let skills_dir = temp.path().join("skills"); + write_skill_file( + &skills_dir, + "valid", + "---\nname: valid\ndescription: Valid skill\n---\n\nUse valid.\n", + ); + write_skill_file( + &skills_dir, + "malformed", + "---\nname: malformed\ndescription: Triggers: invalid yaml\n---\n\nBroken.\n", + ); + + let mut registry = SkillRegistry::default(); + registry + .load_from_dir(&skills_dir) + .expect("load skills while skipping malformed file"); + assert!(registry.contains("valid")); + assert!(!registry.contains("malformed")); + + let mut counted_registry = SkillRegistry::default(); + let count = counted_registry + .load_from_dir_count(&skills_dir) + .expect("count skills while skipping malformed file"); + assert_eq!(count, 1); + assert!(counted_registry.contains("valid")); + assert!(!counted_registry.contains("malformed")); + } + #[test] fn project_overlay_is_session_scoped_and_composes_over_globals() { let temp = tempfile::tempdir().expect("tempdir"); From 2df9b616e5d9581936be8a0df1e9ca8242dafae8 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:10:33 -0700 Subject: [PATCH 019/107] fix: isolate model picker allowlist routes (fixes #947) --- .../src/tui/app/inline_interactive.rs | 115 ++++++++++++++++-- crates/jcode-tui/src/tui/app/model_context.rs | 2 +- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/inline_interactive.rs b/crates/jcode-tui/src/tui/app/inline_interactive.rs index 310485fdef..bbff93bb51 100644 --- a/crates/jcode-tui/src/tui/app/inline_interactive.rs +++ b/crates/jcode-tui/src/tui/app/inline_interactive.rs @@ -97,13 +97,16 @@ use placeholder_routes::route_supports_reasoning_effort; /// "openai-compatible:myprofile"), or a bare openai-compatible profile id /// ("myprofile"). Matching is case/format-insensitive via the shared provider /// label normalizer. Routes for the active model are always kept so the -/// current selection never disappears from the picker, and a filter that +/// current selection never disappears from the picker, but only when their +/// provider and API method match the active route. A filter that /// matches nothing falls back to the unfiltered list instead of an empty /// picker. fn filter_routes_by_provider_allowlist( routes: Vec, allowlist: Option<&[String]>, current_model: &str, + current_provider: &str, + current_api_method: Option<&str>, ) -> Vec { use crate::provider::normalize_model_route_provider_label as normalize; @@ -137,9 +140,17 @@ fn filter_routes_by_provider_allowlist( }) }; + let route_is_current = |route: &crate::provider::ModelRoute| { + route.model == current_model + && crate::provider::model_route_provider_labels_match(&route.provider, current_provider) + && current_api_method.is_none_or(|api_method| { + crate::provider::ModelRouteApiMethod::parse(&route.api_method) + == crate::provider::ModelRouteApiMethod::parse(api_method) + }) + }; let filtered: Vec = routes .iter() - .filter(|route| route.model == current_model || route_matches(route)) + .filter(|route| route_is_current(route) || route_matches(route)) .cloned() .collect(); if filtered.is_empty() { @@ -1401,6 +1412,14 @@ impl App { } else { self.provider.model().to_string() }; + let current_provider = if self.is_remote { + self.remote_provider_name + .clone() + .unwrap_or_else(|| "remote".to_string()) + } else { + self.provider.display_name() + }; + let current_api_method = self.current_route_api_method(); let config = crate::config::config(); let config_default_model = config.provider.default_model.clone(); let config_default_provider = config.provider.default_provider.clone(); @@ -1454,6 +1473,8 @@ impl App { routes, config.provider.model_picker_providers.as_deref(), ¤t_model, + ¤t_provider, + current_api_method.as_deref(), ); if routes.is_empty() { @@ -1534,13 +1555,6 @@ impl App { } } - let current_provider = if self.is_remote { - self.remote_provider_name - .clone() - .unwrap_or_else(|| "remote".to_string()) - } else { - self.provider.name().to_string() - }; let recent_auth_provider = self .recent_authenticated_provider .as_ref() @@ -4273,6 +4287,8 @@ mod tests { routes.clone(), Some(&["Llama.CPP".to_string()]), "unrelated-current", + "OpenAI", + None, ); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].model, "qwen3-coder"); @@ -4282,6 +4298,8 @@ mod tests { routes.clone(), Some(&["llamacpp".to_string()]), "unrelated-current", + "OpenAI", + None, ); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].provider, "llama.cpp"); @@ -4291,6 +4309,8 @@ mod tests { routes.clone(), Some(&["claude-oauth".to_string(), "openrouter".to_string()]), "unrelated-current", + "OpenAI", + None, ); let models: Vec<&str> = filtered.iter().map(|r| r.model.as_str()).collect(); assert_eq!(models, ["claude-fable-5", "deepseek/deepseek-v4-pro"]); @@ -4308,6 +4328,8 @@ mod tests { routes.clone(), Some(&["llamacpp".to_string()]), "gpt-5.5", + "OpenAI", + Some("openai-oauth"), ); let models: Vec<&str> = filtered.iter().map(|r| r.model.as_str()).collect(); assert_eq!(models, ["gpt-5.5", "qwen3-coder"]); @@ -4317,21 +4339,90 @@ mod tests { routes.clone(), Some(&["nonexistent".to_string()]), "unrelated-current", + "OpenAI", + None, ); assert_eq!(filtered.len(), routes.len()); // None / empty / blank-entry allowlists are no-ops. assert_eq!( - filter_routes_by_provider_allowlist(routes.clone(), None, "x").len(), + filter_routes_by_provider_allowlist(routes.clone(), None, "x", "OpenAI", None).len(), 2 ); assert_eq!( - filter_routes_by_provider_allowlist(routes.clone(), Some(&[]), "x").len(), + filter_routes_by_provider_allowlist(routes.clone(), Some(&[]), "x", "OpenAI", None,) + .len(), 2 ); assert_eq!( - filter_routes_by_provider_allowlist(routes, Some(&[" ".to_string()]), "x").len(), + filter_routes_by_provider_allowlist( + routes, + Some(&[" ".to_string()]), + "x", + "OpenAI", + None, + ) + .len(), 2 ); } + + #[test] + fn provider_allowlist_does_not_keep_disallowed_route_sharing_current_model() { + let routes = vec![ + model_route( + "moonshotai/Kimi-K3", + "my-provider", + "openai-compatible:my-provider", + ), + model_route("moonshotai/Kimi-K3", "Copilot", "copilot"), + ]; + + let filtered = filter_routes_by_provider_allowlist( + routes, + Some(&["my-provider".to_string()]), + "moonshotai/Kimi-K3", + "my-provider", + Some("openai-compatible:my-provider"), + ); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].provider, "my-provider"); + } + + #[test] + fn provider_allowlist_keeps_only_exact_active_route_when_model_and_provider_overlap() { + let routes = vec![ + model_route("gpt-5.5", "OpenAI", "openai-oauth"), + model_route("gpt-5.5", "OpenAI", "openai-api-key"), + model_route("gpt-5.5", "Copilot", "copilot"), + model_route("qwen3-coder", "llama.cpp", "openai-compatible:llamacpp"), + ]; + + let filtered = filter_routes_by_provider_allowlist( + routes, + Some(&["llamacpp".to_string()]), + "gpt-5.5", + "OpenAI", + Some("openai-oauth"), + ); + + let routes: Vec<(&str, &str, &str)> = filtered + .iter() + .map(|route| { + ( + route.model.as_str(), + route.provider.as_str(), + route.api_method.as_str(), + ) + }) + .collect(); + assert_eq!( + routes, + [ + ("gpt-5.5", "OpenAI", "openai-oauth"), + ("qwen3-coder", "llama.cpp", "openai-compatible:llamacpp"), + ] + ); + } } diff --git a/crates/jcode-tui/src/tui/app/model_context.rs b/crates/jcode-tui/src/tui/app/model_context.rs index f4e60473f3..9569c47141 100644 --- a/crates/jcode-tui/src/tui/app/model_context.rs +++ b/crates/jcode-tui/src/tui/app/model_context.rs @@ -195,7 +195,7 @@ impl App { /// The api_method string of the route currently in use, used to exclude the /// failed route and to recognize same-model/different-method alternatives. - fn current_route_api_method(&self) -> Option { + pub(super) fn current_route_api_method(&self) -> Option { if self.is_remote { return self.session.route_api_method.clone(); } From 2c8735f69e28ce22c86d35e418fe19e6d2e4f197 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:11:09 -0700 Subject: [PATCH 020/107] fix: configure usage percentage wording (fixes #946) --- crates/jcode-base/src/config/default_file.rs | 3 + crates/jcode-config-types/src/display.rs | 16 ++++ crates/jcode-tui/src/tui/app/tui_state.rs | 1 + crates/jcode-tui/src/tui/info_widget.rs | 8 +- crates/jcode-tui/src/tui/info_widget_model.rs | 1 + crates/jcode-tui/src/tui/info_widget_tests.rs | 2 +- crates/jcode-tui/src/tui/info_widget_usage.rs | 75 ++++++++++++++----- 7 files changed, 86 insertions(+), 20 deletions(-) diff --git a/crates/jcode-base/src/config/default_file.rs b/crates/jcode-base/src/config/default_file.rs index ec762cff6e..9f9f0038fc 100644 --- a/crates/jcode-base/src/config/default_file.rs +++ b/crates/jcode-base/src/config/default_file.rs @@ -154,6 +154,9 @@ debug_socket = false # Set false here or set JCODE_NO_EMOJI=1 for ASCII fallbacks. emoji = true +# Usage percentage wording: "left" (default) or "used". +usage_display = "left" + # Show thinking/reasoning content (default: false) show_thinking = false diff --git a/crates/jcode-config-types/src/display.rs b/crates/jcode-config-types/src/display.rs index f0dce66cf9..5a41e65cf3 100644 --- a/crates/jcode-config-types/src/display.rs +++ b/crates/jcode-config-types/src/display.rs @@ -115,6 +115,8 @@ pub struct DisplayConfig { /// sessions (issue #674). #[serde(default = "default_true")] pub external_sessions: bool, + /// Usage percentage wording: "left" (default) or "used". + pub usage_display: String, /// When to show the overscroll status line below the input /// (off/on/overscroll, default: overscroll). "overscroll" is the elastic /// reveal when scrolling past the bottom, "on" keeps it always visible. @@ -157,6 +159,7 @@ impl Default for DisplayConfig { colors: std::collections::BTreeMap::new(), active_sessions_manager: false, external_sessions: true, + usage_display: "left".to_string(), overscroll_status: OverscrollStatusMode::default(), } } @@ -202,6 +205,10 @@ impl DisplayConfig { pub fn reasoning_enabled(&self) -> bool { !matches!(self.reasoning_display(), ReasoningDisplayMode::Off) } + + pub fn usage_display_used(&self) -> bool { + self.usage_display.eq_ignore_ascii_case("used") + } } #[cfg(test)] @@ -219,4 +226,13 @@ mod tests { serde_json::from_str(r#"{"pin_todos":false}"#).expect("display config"); assert!(!disabled.pin_todos); } + + #[test] + fn usage_percentage_wording_defaults_to_left_and_accepts_used() { + assert_eq!(DisplayConfig::default().usage_display, "left"); + + let used: DisplayConfig = + serde_json::from_str(r#"{"usage_display":"used"}"#).expect("display config"); + assert!(used.usage_display_used()); + } } diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index c5085c4f37..c3a69f7875 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -1593,6 +1593,7 @@ impl crate::tui::TuiState for App { swarm_info, background_info, usage_info, + usage_display_used: crate::config::config().display.usage_display_used(), tokens_per_second, provider_name: if uses_remote_widget_metadata { self.remote_provider_name diff --git a/crates/jcode-tui/src/tui/info_widget.rs b/crates/jcode-tui/src/tui/info_widget.rs index eb0d1c4ccb..3946d24dce 100644 --- a/crates/jcode-tui/src/tui/info_widget.rs +++ b/crates/jcode-tui/src/tui/info_widget.rs @@ -623,6 +623,8 @@ pub struct InfoWidgetData { pub background_info: Option, /// Subscription usage info pub usage_info: Option, + /// Show consumed rather than remaining percentages in usage limits. + pub usage_display_used: bool, /// Streaming output tokens per second (approximate) pub tokens_per_second: Option, /// Active provider name (openrouter/openai/anthropic/...) @@ -2080,7 +2082,11 @@ fn render_sections( if let Some(info) = &data.usage_info && info.available { - lines.extend(render_usage_compact(info, inner.width)); + lines.extend(render_usage_compact( + info, + inner.width, + data.usage_display_used, + )); } if let Some(cache) = data.cache_hit_info.as_ref() { diff --git a/crates/jcode-tui/src/tui/info_widget_model.rs b/crates/jcode-tui/src/tui/info_widget_model.rs index 0d70d33eca..801e04834f 100644 --- a/crates/jcode-tui/src/tui/info_widget_model.rs +++ b/crates/jcode-tui/src/tui/info_widget_model.rs @@ -409,6 +409,7 @@ mod tests { swarm_info: None, background_info: None, usage_info: None, + usage_display_used: false, tokens_per_second: None, provider_name: None, auth_method: crate::tui::info_widget::AuthMethod::Unknown, diff --git a/crates/jcode-tui/src/tui/info_widget_tests.rs b/crates/jcode-tui/src/tui/info_widget_tests.rs index 6f82812e05..c5a9a8afc7 100644 --- a/crates/jcode-tui/src/tui/info_widget_tests.rs +++ b/crates/jcode-tui/src/tui/info_widget_tests.rs @@ -626,7 +626,7 @@ fn cost_based_usage_widgets_show_price_and_tokens() { assert!(expanded_text.contains("$0.0123")); assert!(expanded_text.contains("12.3K in + 678 out")); - let compact_text = lines_text(&render_usage_compact(&usage, 40)); + let compact_text = lines_text(&render_usage_compact(&usage, 40, false)); assert!(compact_text.contains("$0.0123")); assert!(compact_text.contains("12.3K in + 678 out")); } diff --git a/crates/jcode-tui/src/tui/info_widget_usage.rs b/crates/jcode-tui/src/tui/info_widget_usage.rs index b01abdd798..0df30b1985 100644 --- a/crates/jcode-tui/src/tui/info_widget_usage.rs +++ b/crates/jcode-tui/src/tui/info_widget_usage.rs @@ -73,6 +73,7 @@ pub(super) fn render_usage_widget(data: &InfoWidgetData, inner: Rect) -> Vec Vec Vec Vec Vec> { +pub(super) fn render_usage_compact( + info: &UsageInfo, + width: u16, + usage_display_used: bool, +) -> Vec> { if !info.available { return Vec::new(); } @@ -151,6 +158,7 @@ pub(super) fn render_usage_compact(info: &UsageInfo, width: u16) -> Vec Vec Vec, width: u16, + usage_display_used: bool, ) -> Line<'static> { let color = if left_pct <= 20 { rgb(255, 100, 100) @@ -198,25 +209,26 @@ fn render_labeled_bar( const LABEL_WIDTH: usize = 7; const MIN_BAR_WIDTH: usize = 4; + let (display_pct, display_word) = if usage_display_used { + (used_pct, "used") + } else { + (left_pct, "left") + }; + let percentage_suffix = format!(" {}% {}", display_pct, display_word); let full_suffix = match reset_time { Some(reset) if left_pct == 0 => format!(" resets {}", reset), - Some(reset) => format!(" {}% left · {}", left_pct, reset), - None => format!(" {}% left", left_pct), + Some(reset) => format!("{} · {}", percentage_suffix, reset), + None => percentage_suffix.clone(), }; - // On narrow widgets keep the reset visible and progressively shorten the - // percentage wording before sacrificing the bar. The exhausted wording is - // already compact and remains unchanged. + // On narrow widgets keep the percentage wording unambiguous, dropping the + // reset countdown before sacrificing the bar. Exhausted wording is unchanged. let suffix = match reset_time { - Some(reset) if left_pct > 0 => { - let compact = format!(" {}% · {}", left_pct, reset); - let reset_only = format!(" · {}", reset); + Some(_) if left_pct > 0 => { let budget = usize::from(width).saturating_sub(LABEL_WIDTH + MIN_BAR_WIDTH); if UnicodeWidthStr::width(full_suffix.as_str()) <= budget { full_suffix - } else if UnicodeWidthStr::width(compact.as_str()) <= budget { - compact } else { - reset_only + percentage_suffix } } _ => full_suffix, @@ -257,27 +269,54 @@ mod tests { #[test] fn usage_bar_shows_reset_countdown_before_exhaustion() { - let text = line_text(&render_labeled_bar("5-hour", 38, 62, Some("4h 5m"), 40)); + let text = line_text(&render_labeled_bar( + "5-hour", + 38, + 62, + Some("4h 5m"), + 40, + false, + )); assert!(text.contains("62% left · 4h 5m")); assert!(UnicodeWidthStr::width(text.as_str()) <= 40); } #[test] - fn usage_bar_keeps_countdown_within_narrow_width() { - let text = line_text(&render_labeled_bar("Weekly", 19, 81, Some("1d 4h"), 23)); + fn usage_bar_keeps_wording_unambiguous_within_narrow_width() { + let text = line_text(&render_labeled_bar( + "Weekly", + 19, + 81, + Some("1d 4h"), + 23, + true, + )); - assert!(text.contains("81% · 1d 4h")); + assert!(text.contains("19% used")); + assert!(!text.contains("1d 4h")); assert!(UnicodeWidthStr::width(text.as_str()) <= 23); assert!(text.contains('▰') || text.contains('▱')); } + #[test] + fn used_wording_does_not_change_remaining_budget_color_thresholds() { + let left = render_labeled_bar("5-hour", 85, 15, None, 24, false); + let used = render_labeled_bar("5-hour", 85, 15, None, 24, true); + + assert!(line_text(&left).contains("15% left")); + assert!(line_text(&used).contains("85% used")); + assert_eq!(left.spans[1].style.fg, Some(rgb(255, 100, 100))); + assert_eq!(used.spans[1].style.fg, left.spans[1].style.fg); + } + #[test] fn exhausted_usage_bar_preserves_resets_wording_and_width() { - let text = line_text(&render_labeled_bar("5-hour", 100, 0, Some("12m"), 24)); + let text = line_text(&render_labeled_bar("5-hour", 100, 0, Some("12m"), 24, true)); assert!(text.contains("resets 12m")); assert!(!text.contains("0% left")); + assert!(!text.contains("100% used")); assert!(UnicodeWidthStr::width(text.as_str()) <= 24); } @@ -291,7 +330,7 @@ mod tests { ..Default::default() }; - let lines = render_usage_compact(&info, 40); + let lines = render_usage_compact(&info, 40, false); let text = lines.iter().map(line_text).collect::>().join("\n"); assert!(text.contains("Monthly")); From 3309ad37bbdf2a3774a7173512e54d26cc087448 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:11:32 -0700 Subject: [PATCH 021/107] fix: configure auto-poke toggle key (fixes #945) --- crates/jcode-base/src/config/default_file.rs | 3 + .../jcode-base/src/config/display_summary.rs | 6 ++ crates/jcode-base/src/config_tests.rs | 18 +++++ crates/jcode-config-types/src/keybindings.rs | 6 ++ crates/jcode-config-types/src/lib.rs | 3 + .../jcode-tui/src/tui/app/hotkey_feedback.rs | 13 +-- crates/jcode-tui/src/tui/app/input.rs | 8 +- .../src/tui/app/remote/key_handling.rs | 80 +++++++++---------- crates/jcode-tui/src/tui/keybind.rs | 23 ++++++ 9 files changed, 106 insertions(+), 54 deletions(-) diff --git a/crates/jcode-base/src/config/default_file.rs b/crates/jcode-base/src/config/default_file.rs index 9f9f0038fc..011ccdf1df 100644 --- a/crates/jcode-base/src/config/default_file.rs +++ b/crates/jcode-base/src/config/default_file.rs @@ -56,6 +56,9 @@ scroll_prompt_down = "ctrl+]" # Scroll bookmark toggle (stash position, jump to bottom, press again to return) scroll_bookmark = "ctrl+g" +# Auto-poke toggle. Set "" to disable. +auto_poke_toggle = "ctrl+p" + # Optional fallback scroll bindings (useful on macOS terminals that forward Command) # Leave unset by default; on macOS Cmd+K / Cmd+J move up / down by prompt instead. scroll_up_fallback = "" diff --git a/crates/jcode-base/src/config/display_summary.rs b/crates/jcode-base/src/config/display_summary.rs index 45741d2b87..0d7cefd923 100644 --- a/crates/jcode-base/src/config/display_summary.rs +++ b/crates/jcode-base/src/config/display_summary.rs @@ -27,6 +27,7 @@ impl Config { - Prompt up: `{}` - Prompt down: `{}` - Scroll bookmark: `{}` +- Auto-poke toggle: `{}` - Workspace left: `{}` - Workspace down: `{}` - Workspace up: `{}` @@ -143,6 +144,11 @@ impl Config { self.keybindings.scroll_prompt_up, self.keybindings.scroll_prompt_down, self.keybindings.scroll_bookmark, + if self.keybindings.auto_poke_toggle.trim().is_empty() { + "disabled" + } else { + self.keybindings.auto_poke_toggle.trim() + }, self.keybindings.workspace_left, self.keybindings.workspace_down, self.keybindings.workspace_up, diff --git a/crates/jcode-base/src/config_tests.rs b/crates/jcode-base/src/config_tests.rs index 3646115a69..c712d47200 100644 --- a/crates/jcode-base/src/config_tests.rs +++ b/crates/jcode-base/src/config_tests.rs @@ -81,6 +81,24 @@ fn auto_poke_feature_defaults_on_and_parses_false() { assert!(!cfg.features.auto_poke); } +#[test] +fn auto_poke_toggle_key_defaults_parses_and_reports_disabled() { + assert_eq!(Config::default().keybindings.auto_poke_toggle, "ctrl+p"); + + let remapped: Config = toml::from_str("[keybindings]\nauto_poke_toggle = \"alt+p\"\n") + .expect("keybindings.auto_poke_toggle should parse"); + assert_eq!(remapped.keybindings.auto_poke_toggle, "alt+p"); + + let disabled: Config = toml::from_str("[keybindings]\nauto_poke_toggle = \"\"\n") + .expect("an empty auto-poke toggle should parse"); + assert!(disabled.keybindings.auto_poke_toggle.is_empty()); + assert!( + disabled + .display_string() + .contains("- Auto-poke toggle: `disabled`") + ); +} + #[test] fn auto_poke_environment_override_uses_standard_boolean_values() { let _guard = crate::storage::lock_test_env(); diff --git a/crates/jcode-config-types/src/keybindings.rs b/crates/jcode-config-types/src/keybindings.rs index 7141d397ad..46e3b4b927 100644 --- a/crates/jcode-config-types/src/keybindings.rs +++ b/crates/jcode-config-types/src/keybindings.rs @@ -276,6 +276,12 @@ pub const KEYBINDING_DEFAULTS: &[KeybindingDefault] = &[ macos: PlatformDefault::dev("ctrl+g"), other: PlatformDefault::dev("ctrl+g"), }, + KeybindingDefault { + id: "auto_poke_toggle", + description: "Toggle auto-poke", + macos: PlatformDefault::dev("ctrl+p"), + other: PlatformDefault::dev("ctrl+p"), + }, KeybindingDefault { id: "scroll_up_fallback", description: "Optional fallback scroll-up binding", diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 10027ec417..2073ca0590 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -952,6 +952,8 @@ pub struct KeybindingsConfig { pub scroll_prompt_down: String, /// Scroll bookmark toggle key (default: "ctrl+g") pub scroll_bookmark: String, + /// Toggle auto-poke (default: "ctrl+p"). Set "" to disable. + pub auto_poke_toggle: String, /// Scroll up fallback key (default: unset; Cmd+K moves up by prompt on macOS) pub scroll_up_fallback: String, /// Scroll down fallback key (default: unset; Cmd+J moves down by prompt on macOS) @@ -1018,6 +1020,7 @@ impl Default for KeybindingsConfig { scroll_prompt_up: get("scroll_prompt_up", "ctrl+["), scroll_prompt_down: get("scroll_prompt_down", "ctrl+]"), scroll_bookmark: get("scroll_bookmark", "ctrl+g"), + auto_poke_toggle: get("auto_poke_toggle", "ctrl+p"), scroll_up_fallback: get("scroll_up_fallback", ""), scroll_down_fallback: get("scroll_down_fallback", ""), workspace_left: get("workspace_left", "alt+h"), diff --git a/crates/jcode-tui/src/tui/app/hotkey_feedback.rs b/crates/jcode-tui/src/tui/app/hotkey_feedback.rs index 1ac59310a7..18be807f65 100644 --- a/crates/jcode-tui/src/tui/app/hotkey_feedback.rs +++ b/crates/jcode-tui/src/tui/app/hotkey_feedback.rs @@ -115,6 +115,11 @@ pub(super) fn build_registry(inputs: &RegistryInputs<'_>) -> Vec { }; // Configured pane/mode toggles (pre-control shortcuts). + push( + inputs.toggles.auto_poke.binding().cloned(), + "auto_poke_toggle", + "toggle auto-poke", + ); push( inputs.toggles.copy_selection.binding().cloned(), "copy_selection_toggle", @@ -342,11 +347,6 @@ pub(super) fn build_registry(inputs: &RegistryInputs<'_>) -> Vec { "input_stash", "stash or restore the input draft", )); - out.push(KnownHotkey::new( - ctrl('p'), - "auto_poke_toggle", - "toggle auto-poke", - )); out.push(KnownHotkey::new( ctrl('t'), "queue_mode_toggle", @@ -907,7 +907,7 @@ mod tests { } #[test] - fn lookup_finds_builtin_ctrl_p_auto_poke() { + fn lookup_finds_configured_ctrl_p_auto_poke() { let registry = test_inputs_registry(false); let info = lookup(®istry, true, KeyCode::Char('p'), KeyModifiers::CONTROL) .expect("ctrl+p known"); @@ -1122,6 +1122,7 @@ mod tests { let registry = test_inputs_registry(false); let toggles = crate::tui::keybind::load_toggle_keys(); let toggle_bindings: &[(&str, Option<&KeyBinding>)] = &[ + ("auto_poke_toggle", toggles.auto_poke.binding()), ("side_panel_toggle", toggles.side_panel.binding()), ("copy_selection_toggle", toggles.copy_selection.binding()), ("diagram_pane_toggle", toggles.diagram_pane.binding()), diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 1b62dd7a56..e0ab4861a5 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1948,10 +1948,6 @@ pub(super) fn handle_control_key(app: &mut App, code: KeyCode) -> bool { app.toggle_input_stash(); true } - KeyCode::Char('p') => { - super::commands::toggle_auto_poke_hotkey_local(app); - true - } KeyCode::Char('v') => { paste_from_clipboard(app); true @@ -2278,6 +2274,10 @@ pub(super) fn handle_pre_control_shortcuts( let macos_option_shortcut = crate::tui::keybind::shortcut_char_for_macos_option_key(code, modifiers); + if app.toggle_keys.auto_poke.matches(code, modifiers) { + super::commands::toggle_auto_poke_hotkey_local(app); + return true; + } if app.toggle_keys.copy_selection.matches(code, modifiers) { app.toggle_copy_selection_mode(); return true; diff --git a/crates/jcode-tui/src/tui/app/remote/key_handling.rs b/crates/jcode-tui/src/tui/app/remote/key_handling.rs index 872c026361..3500bdb2b1 100644 --- a/crates/jcode-tui/src/tui/app/remote/key_handling.rs +++ b/crates/jcode-tui/src/tui/app/remote/key_handling.rs @@ -390,6 +390,42 @@ async fn handle_remote_key_internal( return Ok(()); } + if app.toggle_keys.auto_poke.matches(code, modifiers) { + if app.auto_poke_incomplete_todos { + let cleared = app_mod::commands::disable_auto_poke(app); + app.set_status_notice("Poke: OFF"); + app.push_display_message(DisplayMessage::system( + app_mod::commands::poke_disabled_message(cleared), + )); + } else { + match app_mod::commands::activate_auto_poke(app) { + app_mod::commands::PokeActivation::EnabledNoIncomplete => { + app.push_display_message(DisplayMessage::system( + app_mod::commands::poke_enabled_without_incomplete_message(), + )); + } + app_mod::commands::PokeActivation::Queued => { + app.push_display_message(DisplayMessage::system( + app_mod::commands::poke_queued_display_message(), + )); + } + app_mod::commands::PokeActivation::SendNow { + incomplete_count, + poke_msg, + } => { + app.push_display_message(DisplayMessage::system( + app_mod::commands::poke_triggered_display_message(incomplete_count), + )); + + let _ = + begin_remote_send(app, remote, poke_msg, vec![], true, None, true, 0).await; + app.visible_turn_started = Some(Instant::now()); + } + } + } + return Ok(()); + } + if app.toggle_keys.side_panel.matches(code, modifiers) { app.toggle_side_panel(); return Ok(()); @@ -700,50 +736,6 @@ async fn handle_remote_key_internal( app.toggle_input_stash(); return Ok(()); } - KeyCode::Char('p') => { - if app.auto_poke_incomplete_todos { - let cleared = app_mod::commands::disable_auto_poke(app); - app.set_status_notice("Poke: OFF"); - app.push_display_message(DisplayMessage::system( - app_mod::commands::poke_disabled_message(cleared), - )); - } else { - match app_mod::commands::activate_auto_poke(app) { - app_mod::commands::PokeActivation::EnabledNoIncomplete => { - app.push_display_message(DisplayMessage::system( - app_mod::commands::poke_enabled_without_incomplete_message(), - )); - } - app_mod::commands::PokeActivation::Queued => { - app.push_display_message(DisplayMessage::system( - app_mod::commands::poke_queued_display_message(), - )); - } - app_mod::commands::PokeActivation::SendNow { - incomplete_count, - poke_msg, - } => { - app.push_display_message(DisplayMessage::system( - app_mod::commands::poke_triggered_display_message(incomplete_count), - )); - - let _ = begin_remote_send( - app, - remote, - poke_msg, - vec![], - true, - None, - true, - 0, - ) - .await; - app.visible_turn_started = Some(Instant::now()); - } - } - } - return Ok(()); - } KeyCode::Char('v') => { app.paste_from_clipboard(); return Ok(()); diff --git a/crates/jcode-tui/src/tui/keybind.rs b/crates/jcode-tui/src/tui/keybind.rs index 50f4e3b3b7..72248cd361 100644 --- a/crates/jcode-tui/src/tui/keybind.rs +++ b/crates/jcode-tui/src/tui/keybind.rs @@ -376,6 +376,7 @@ impl ToggleBinding { /// All configurable pane / mode toggle keybindings. #[derive(Clone, Debug)] pub struct ToggleKeys { + pub auto_poke: ToggleBinding, pub side_panel: ToggleBinding, pub copy_selection: ToggleBinding, pub diagram_pane: ToggleBinding, @@ -389,6 +390,13 @@ pub struct ToggleKeys { pub fn load_toggle_keys() -> ToggleKeys { let cfg = config(); ToggleKeys { + auto_poke: ToggleBinding::load_with_default( + &cfg.keybindings.auto_poke_toggle, + KeyBinding { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + }, + ), side_panel: ToggleBinding::load(&cfg.keybindings.side_panel_toggle, 'm'), copy_selection: ToggleBinding::load(&cfg.keybindings.copy_selection_toggle, 'y'), diagram_pane: ToggleBinding::load(&cfg.keybindings.diagram_pane_toggle, 't'), @@ -615,6 +623,21 @@ pub fn load_open_resume_key() -> OptionalBinding { mod tests { use super::*; + #[test] + fn auto_poke_toggle_can_be_remapped_or_disabled() { + let default = KeyBinding { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + }; + let remapped = ToggleBinding::load_with_default("alt+p", default.clone()); + assert!(remapped.matches(KeyCode::Char('p'), KeyModifiers::ALT)); + assert!(!remapped.matches(KeyCode::Char('p'), KeyModifiers::CONTROL)); + + let disabled = ToggleBinding::load_with_default("", default); + assert!(disabled.binding().is_none()); + assert!(!disabled.matches(KeyCode::Char('p'), KeyModifiers::CONTROL)); + } + #[test] fn new_terminal_alt_enter_binding_parses_and_matches() { let binding = parse_keybinding("alt+enter").expect("alt+enter should parse"); From c0dc6f192fe5d4ea8d8b48b32c2137e28e0858fa Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:15:44 -0700 Subject: [PATCH 022/107] test: include active skill in e2e requests --- tests/e2e/test_support/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/test_support/mod.rs b/tests/e2e/test_support/mod.rs index 1124301748..1b8461de76 100644 --- a/tests/e2e/test_support/mod.rs +++ b/tests/e2e/test_support/mod.rs @@ -387,6 +387,7 @@ impl WsTestClient { content: content.to_string(), images: vec![], system_reminder: None, + active_skill: None, no_reply: false, }) .await From 6b8b3781973b3fb780e0c475da63cd033518444c Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:38:51 -0700 Subject: [PATCH 023/107] test: cover auto-poke feedback binding --- crates/jcode-tui/src/tui/app/hotkey_feedback.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/jcode-tui/src/tui/app/hotkey_feedback.rs b/crates/jcode-tui/src/tui/app/hotkey_feedback.rs index 18be807f65..e4d10fbd97 100644 --- a/crates/jcode-tui/src/tui/app/hotkey_feedback.rs +++ b/crates/jcode-tui/src/tui/app/hotkey_feedback.rs @@ -1041,6 +1041,7 @@ mod tests { ("effort_increase", Some(&["effort_increase"])), ("effort_decrease", Some(&["effort_decrease"])), ("centered_toggle", Some(&["centered_toggle"])), + ("auto_poke_toggle", Some(&["auto_poke_toggle"])), ("scroll_prompt_up", Some(&["prompt_jump_up"])), ("scroll_prompt_down", Some(&["prompt_jump_down"])), ("scroll_bookmark", Some(&["scroll_bookmark"])), From 846534f17947855f42cde6aca6a6078992eadc72 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:29:36 +0000 Subject: [PATCH 024/107] docs: update star history chart --- docs/images/star-history.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg index fd72dc5faa..2d53d452b7 100644 --- a/docs/images/star-history.svg +++ b/docs/images/star-history.svg @@ -1,6 +1,6 @@ 1jehuang/jcode star history -GitHub stars over time, currently 17,501 +GitHub stars over time, currently 17,571 From 82cc886af8fd328c84987c2638bebfb3ee9f4679 Mon Sep 17 00:00:00 2001 From: sergey Date: Sat, 15 Aug 2026 11:30:09 +0300 Subject: [PATCH 025/107] test(ci): require fork-compatible workflow contracts AI-assisted: Jcode --- .github/workflows/ci.yml | 3 +++ scripts/test_fork_ci_workflow.py | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 scripts/test_fork_ci_workflow.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 633e26f115..fba4d6a74f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,9 @@ jobs: - name: Compile release automation scripts run: python3 -m py_compile scripts/post_discord_release.py scripts/test_post_discord_release.py + - name: Test fork-compatible workflow contracts + run: python3 -m unittest -v scripts/test_fork_ci_workflow.py + build: name: Build & Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/scripts/test_fork_ci_workflow.py b/scripts/test_fork_ci_workflow.py new file mode 100644 index 0000000000..bc9b1540cc --- /dev/null +++ b/scripts/test_fork_ci_workflow.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Regression tests for CI behavior in forks without optional repository features.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +ISSUE_WORKFLOW = ROOT / ".github" / "workflows" / "require-issue.yml" + + +class ForkCiWorkflowTests(unittest.TestCase): + def test_optional_deploy_key_gates_every_ssh_agent_step(self) -> None: + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + steps = re.findall( + r"(?ms)^\s+- name: Configure SSH for cargo git dependencies\n" + r"(?P(?:^\s{8,}.*\n){1,8})", + workflow, + ) + self.assertGreater(len(steps), 0) + for body in steps: + self.assertIn("if: ${{ secrets.DEPLOY_KEY != '' }}", body) + self.assertIn("uses: webfactory/ssh-agent@", body) + + def test_issue_policy_skips_when_repository_issues_are_disabled(self) -> None: + workflow = ISSUE_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("hasIssuesEnabled", workflow) + self.assertIn("Repository issues are disabled; skipping linked-issue requirement.", workflow) + self.assertLess( + workflow.index("hasIssuesEnabled"), + workflow.index("const candidates = new Set()"), + ) + + +if __name__ == "__main__": + unittest.main() From e9dbdb38b850a02ada865cbb164afe645a19bb9a Mon Sep 17 00:00:00 2001 From: sergey Date: Sat, 15 Aug 2026 11:31:12 +0300 Subject: [PATCH 026/107] fix(ci): support forks without optional repository features AI-assisted: Jcode --- .github/workflows/ci.yml | 4 ++++ .github/workflows/require-issue.yml | 14 ++++++++++++++ crates/jcode-base/src/mcp/pool.rs | 16 +++++----------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fba4d6a74f..8df41c79f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies + if: ${{ secrets.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.DEPLOY_KEY }} @@ -178,6 +179,7 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies + if: ${{ secrets.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.DEPLOY_KEY }} @@ -416,6 +418,7 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies + if: ${{ secrets.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.DEPLOY_KEY }} @@ -669,6 +672,7 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies + if: ${{ secrets.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.DEPLOY_KEY }} diff --git a/.github/workflows/require-issue.yml b/.github/workflows/require-issue.yml index e2a02fd0ce..84865e7b5f 100644 --- a/.github/workflows/require-issue.yml +++ b/.github/workflows/require-issue.yml @@ -39,6 +39,20 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; + + try { + const repositoryQuery = `query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { hasIssuesEnabled } + }`; + const repositoryResult = await github.graphql(repositoryQuery, { owner, repo }); + if (repositoryResult?.repository?.hasIssuesEnabled === false) { + core.info('Repository issues are disabled; skipping linked-issue requirement.'); + return; + } + } catch (err) { + core.warning(`Could not query repository issue capability: ${err}`); + } + const text = `${pr.title || ''}\n\n${pr.body || ''}`; // Collect candidate issue numbers that belong to THIS repo. diff --git a/crates/jcode-base/src/mcp/pool.rs b/crates/jcode-base/src/mcp/pool.rs index 13f0d6985f..3adeecceed 100644 --- a/crates/jcode-base/src/mcp/pool.rs +++ b/crates/jcode-base/src/mcp/pool.rs @@ -527,17 +527,11 @@ mod tests { // A handle with no backing client is treated as dead (crashed // process): begin_connect must clean it up and return Leader. - pool.handles - .write() - .await - .insert( - "stale".to_string(), - crate::mcp::McpHandle::new_dummy_for_tests("stale".to_string()), - ); - pool.ref_counts - .lock() - .await - .insert("stale".to_string(), 1); + pool.handles.write().await.insert( + "stale".to_string(), + crate::mcp::McpHandle::new_dummy_for_tests("stale".to_string()), + ); + pool.ref_counts.lock().await.insert("stale".to_string(), 1); let attempt = pool.begin_connect("stale").await; match attempt { From e35fbecc72e483c6a08ffa270b4e85a771155736 Mon Sep 17 00:00:00 2001 From: sergey Date: Sat, 15 Aug 2026 11:33:49 +0300 Subject: [PATCH 027/107] test(ci): require GitHub-valid optional secret gating AI-assisted: Jcode --- scripts/test_fork_ci_workflow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test_fork_ci_workflow.py b/scripts/test_fork_ci_workflow.py index bc9b1540cc..a9feebb5f9 100644 --- a/scripts/test_fork_ci_workflow.py +++ b/scripts/test_fork_ci_workflow.py @@ -21,9 +21,11 @@ def test_optional_deploy_key_gates_every_ssh_agent_step(self) -> None: workflow, ) self.assertGreater(len(steps), 0) + self.assertEqual(workflow.count("DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}"), 4) for body in steps: - self.assertIn("if: ${{ secrets.DEPLOY_KEY != '' }}", body) + self.assertIn("if: ${{ env.DEPLOY_KEY != '' }}", body) self.assertIn("uses: webfactory/ssh-agent@", body) + self.assertIn("ssh-private-key: ${{ env.DEPLOY_KEY }}", body) def test_issue_policy_skips_when_repository_issues_are_disabled(self) -> None: workflow = ISSUE_WORKFLOW.read_text(encoding="utf-8") From 4fe8a3a29a0fd1f6e25b7c8cff4191485be0636e Mon Sep 17 00:00:00 2001 From: sergey Date: Sat, 15 Aug 2026 11:34:53 +0300 Subject: [PATCH 028/107] fix(ci): gate optional deploy key through job environment AI-assisted: Jcode --- .github/workflows/ci.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8df41c79f4..a99581aa2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: name: Quality Guardrails runs-on: ubuntu-latest timeout-minutes: 45 + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} steps: - uses: actions/checkout@v4 with: @@ -26,10 +28,10 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies - if: ${{ secrets.DEPLOY_KEY != '' }} + if: ${{ env.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} + ssh-private-key: ${{ env.DEPLOY_KEY }} - uses: dtolnay/rust-toolchain@stable with: @@ -162,6 +164,7 @@ jobs: # pinning it here makes every step use this job's installed stable toolchain. env: RUSTUP_TOOLCHAIN: stable + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} strategy: fail-fast: false matrix: @@ -179,10 +182,10 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies - if: ${{ secrets.DEPLOY_KEY != '' }} + if: ${{ env.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} + ssh-private-key: ${{ env.DEPLOY_KEY }} - uses: dtolnay/rust-toolchain@stable with: @@ -411,6 +414,8 @@ jobs: name: Build & Test (windows-latest) runs-on: windows-latest timeout-minutes: 150 + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} steps: - uses: actions/checkout@v4 with: @@ -418,10 +423,10 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies - if: ${{ secrets.DEPLOY_KEY != '' }} + if: ${{ env.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} + ssh-private-key: ${{ env.DEPLOY_KEY }} - uses: ilammy/msvc-dev-cmd@v1 with: @@ -665,6 +670,8 @@ jobs: name: Windows Cross-Target Check (Linux) runs-on: ubuntu-latest timeout-minutes: 35 + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} steps: - uses: actions/checkout@v4 with: @@ -672,10 +679,10 @@ jobs: submodules: recursive - name: Configure SSH for cargo git dependencies - if: ${{ secrets.DEPLOY_KEY != '' }} + if: ${{ env.DEPLOY_KEY != '' }} uses: webfactory/ssh-agent@v0.9.0 with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} + ssh-private-key: ${{ env.DEPLOY_KEY }} - uses: dtolnay/rust-toolchain@stable with: From e7975bef148a3b15ca91000de56bafaff16ebf22 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:52:33 -0700 Subject: [PATCH 029/107] Add fresh-price token dashboard workflow --- telemetry-worker/README.md | 8 +++++++- telemetry-worker/package.json | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index 11f82ca4e6..71831ebb19 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -190,10 +190,16 @@ through jcode, priced per model rather than with one blended rate. Setup: ```bash npm run migrate:model-prices # creates model_prices (migration 0023) npm run sync:model-prices # fills it from https://models.dev/api.json -npm run token-value # daily / per-model / summary panels +npm run token-value:fresh # refresh prices, then run the dashboard (recommended) +npm run token-value # dashboard using prices already stored in D1 npm run token-value:daily # just the per-day series, in date order ``` +`npm run token-value:fresh` is the safe default before quoting dollar values: it +refreshes the remote D1 price mappings and then runs the daily / per-model / +summary panels. Use `npm run token-value` only when the prices were refreshed +recently. + `npm run token-value:daily` is the plain time series when all you want is "dollars per day": one row per day with the tokens, sessions, and distinct users behind it. There is deliberately no per-user dollar column, because it diff --git a/telemetry-worker/package.json b/telemetry-worker/package.json index 35eb8855fc..f99fcc04cd 100644 --- a/telemetry-worker/package.json +++ b/telemetry-worker/package.json @@ -24,6 +24,7 @@ "dau": "node scripts/run-dashboard.mjs dau.sql", "users": "node scripts/run-dashboard.mjs users.sql", "token-value": "node scripts/run-dashboard.mjs token-value.sql", + "token-value:fresh": "npm run sync:model-prices && npm run token-value", "token-value:daily": "node scripts/run-dashboard.mjs token-value-daily.sql", "sync:model-prices": "node scripts/sync-model-prices.mjs", "migrate:detail-fields": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0013_detail_table_turn_session_fields.sql", From 4e457143cb3ed901ae5e3c0082ce4a3a3d6ee566 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:57:26 -0700 Subject: [PATCH 030/107] Add prompt-based user dashboard --- telemetry-worker/README.md | 10 +++++ telemetry-worker/package.json | 1 + telemetry-worker/prompt-users.sql | 73 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 telemetry-worker/prompt-users.sql diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index 71831ebb19..7eb2255c3d 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -230,6 +230,16 @@ Three things to know before quoting the number: If coverage drops, re-run the sync before trusting the dollar figure. +## Prompt-user dashboard + +`npm run prompt-users` uses the strict product definition requested for user +metrics: one distinct non-CI machine that ran at least one prompt. It reports +rolling prompt DAU and WAU from the union of `turn_end` and prompted lifecycle +rows, including in-flight or unclosed sessions. Since raw `turn_end` rows have +30-day retention, monthly growth and the all-time lower bound use durable +`session_end` / `session_crash` rows with `had_user_prompt > 0` so both monthly +windows have equivalent coverage. + ## Reading DAU without fooling yourself `npm run dau` leads with `headline_users_24h` (= `meaningful_release_24h_noci`): diff --git a/telemetry-worker/package.json b/telemetry-worker/package.json index f99fcc04cd..33d7670c51 100644 --- a/telemetry-worker/package.json +++ b/telemetry-worker/package.json @@ -23,6 +23,7 @@ "health:size": "curl -s https://telemetry.jcode.sh/v1/health", "dau": "node scripts/run-dashboard.mjs dau.sql", "users": "node scripts/run-dashboard.mjs users.sql", + "prompt-users": "node scripts/run-dashboard.mjs prompt-users.sql", "token-value": "node scripts/run-dashboard.mjs token-value.sql", "token-value:fresh": "npm run sync:model-prices && npm run token-value", "token-value:daily": "node scripts/run-dashboard.mjs token-value-daily.sql", diff --git a/telemetry-worker/prompt-users.sql b/telemetry-worker/prompt-users.sql new file mode 100644 index 0000000000..ef18c69284 --- /dev/null +++ b/telemetry-worker/prompt-users.sql @@ -0,0 +1,73 @@ +-- Users who ran at least one prompt. +-- Usage: +-- npm run prompt-users +-- +-- A prompt user is a distinct non-CI telemetry_id with either: +-- * a turn_end row (fires only after a real user turn completes), or +-- * a session_end/session_crash row with had_user_prompt > 0. +-- +-- turn_end is retained in D1 for 30 days. It captures in-flight and unclosed +-- sessions, so it is used for DAU and WAU where both comparison windows have +-- equivalent coverage. MAU growth and all-time users use durable lifecycle rows +-- only, avoiding a current-period boost that the prior 30-day window cannot get. +-- The all-time number is therefore a durable lower bound. +-- +-- telemetry_id is per-machine, opt-outs are absent, and old rows written before +-- is_ci existed can be misclassified because they default to non-CI. +WITH recent AS ( + SELECT + COUNT(DISTINCT CASE WHEN created_at >= datetime('now', '-1 day') + THEN telemetry_id END) AS dau_24h, + COUNT(DISTINCT CASE + WHEN created_at >= datetime('now', '-8 days') + AND created_at < datetime('now', '-7 days') + THEN telemetry_id END) AS dau_24h_week_ago, + COUNT(DISTINCT CASE WHEN created_at >= datetime('now', 'start of day') + THEN telemetry_id END) AS today_utc_sofar, + COUNT(DISTINCT CASE + WHEN created_at >= datetime('now', '-1 day', 'start of day') + AND created_at < datetime('now', 'start of day') + THEN telemetry_id END) AS yesterday_utc, + COUNT(DISTINCT CASE WHEN created_at >= datetime('now', '-7 days') + THEN telemetry_id END) AS wau, + COUNT(DISTINCT CASE + WHEN created_at >= datetime('now', '-14 days') + AND created_at < datetime('now', '-7 days') + THEN telemetry_id END) AS wau_previous + FROM events + WHERE is_ci = 0 + AND created_at >= datetime('now', '-14 days') + AND ( + event = 'turn_end' + OR (event IN ('session_end', 'session_crash') AND had_user_prompt > 0) + ) +), durable AS ( + SELECT + COUNT(DISTINCT CASE WHEN created_at >= datetime('now', '-30 days') + THEN telemetry_id END) AS mau_durable, + COUNT(DISTINCT CASE + WHEN created_at >= datetime('now', '-60 days') + AND created_at < datetime('now', '-30 days') + THEN telemetry_id END) AS mau_durable_previous, + COUNT(DISTINCT telemetry_id) AS all_time_durable_lower_bound + FROM events + WHERE is_ci = 0 + AND event IN ('session_end', 'session_crash') + AND had_user_prompt > 0 +) +SELECT + dau_24h, + dau_24h_week_ago, + ROUND(100.0 * (dau_24h - dau_24h_week_ago) + / NULLIF(dau_24h_week_ago, 0), 1) AS dau_wow_pct, + today_utc_sofar, + yesterday_utc, + wau, + wau_previous, + ROUND(100.0 * (wau - wau_previous) / NULLIF(wau_previous, 0), 1) AS wau_wow_pct, + mau_durable, + mau_durable_previous, + ROUND(100.0 * (mau_durable - mau_durable_previous) + / NULLIF(mau_durable_previous, 0), 1) AS mau_mom_pct, + all_time_durable_lower_bound +FROM recent, durable; From c4cdc67680e30a957dc86c68c955ea0605a316f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:33:36 +0000 Subject: [PATCH 031/107] docs: update star history chart --- docs/images/star-history.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg index 2d53d452b7..84d59f6ee7 100644 --- a/docs/images/star-history.svg +++ b/docs/images/star-history.svg @@ -1,6 +1,6 @@ 1jehuang/jcode star history -GitHub stars over time, currently 17,571 +GitHub stars over time, currently 17,654 From 19b003ec0c9f7f18bac74c85f8abaac8d56a5bb0 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:36:55 -0700 Subject: [PATCH 032/107] refine todo intention visibility --- crates/jcode-tui/src/tui/app/todos_view.rs | 22 +++--- crates/jcode-tui/src/tui/ui_messages.rs | 71 ++++++++++++------- crates/jcode-tui/src/tui/ui_messages/tests.rs | 22 ++---- 3 files changed, 64 insertions(+), 51 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/todos_view.rs b/crates/jcode-tui/src/tui/app/todos_view.rs index 55e3b40fea..c93b66a118 100644 --- a/crates/jcode-tui/src/tui/app/todos_view.rs +++ b/crates/jcode-tui/src/tui/app/todos_view.rs @@ -577,16 +577,18 @@ fn format_goal_markdown(goals: &[crate::todo::TodoGoal], group: Option<&str>) -> line } -/// Plan-level intent lines, shown once for the whole todo list. +/// Plan-level assessment lines, shown once for the whole todo list. fn format_plan_markdown(plan: &crate::todo::TodoPlan) -> String { let mut markdown = String::new(); - if let Some(intention) = plan - .user_intention - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - markdown.push_str(&format!("- User intention: {}\n", intention)); + if !crate::todo::intent_understanding_passes(plan.understands_user_intent) { + if let Some(intention) = plan + .user_intention + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + markdown.push_str(&format!("- User intention: {}\n", intention)); + } } if let Some(state) = plan.understands_user_intent { markdown.push_str(&format!( @@ -918,9 +920,9 @@ mod tests { markdown.contains("## optimize rendering (1/2)"), "{markdown}" ); - // Plan-level intent renders once for the whole list. + // A clear intent keeps the intention narrative out of the todo display. assert!( - markdown.contains("- User intention: make navigation feel immediate"), + !markdown.contains("make navigation feel immediate"), "{markdown}" ); assert!( diff --git a/crates/jcode-tui/src/tui/ui_messages.rs b/crates/jcode-tui/src/tui/ui_messages.rs index f125dd8e5e..6c323e1f9b 100644 --- a/crates/jcode-tui/src/tui/ui_messages.rs +++ b/crates/jcode-tui/src/tui/ui_messages.rs @@ -1435,7 +1435,7 @@ fn wrap_todo_detail(value: &str, width: usize) -> Vec { chunks } -/// Plan-level intent lines shown once above the todo groups. +/// Plan-level assessment lines shown once above the todo groups. fn push_todo_plan_details( lines: &mut Vec>, plan: &crate::todo::TodoPlan, @@ -1459,20 +1459,22 @@ fn push_todo_plan_details( inner_width, )); } - if let Some(intention) = plan - .user_intention - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - push_todo_detail( - lines, - "User intention", - intention, - base_indent, - inner_width, - compact_details, - ); + if !crate::todo::intent_understanding_passes(plan.understands_user_intent) { + if let Some(intention) = plan + .user_intention + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + push_todo_detail( + lines, + "User intention", + intention, + base_indent, + inner_width, + compact_details, + ); + } } } @@ -1642,6 +1644,22 @@ fn render_todo_plan_update( let Some(update) = plan_update else { return Vec::new(); }; + let intent_is_unclear = !crate::todo::intent_understanding_passes( + update + .after + .as_ref() + .and_then(|plan| plan.understands_user_intent), + ); + if !update + .fields + .contains(&crate::todo::TodoPlanField::UnderstandsUserIntent) + && !(intent_is_unclear + && update + .fields + .contains(&crate::todo::TodoPlanField::UserIntention)) + { + return Vec::new(); + } let centered = markdown::center_code_blocks(); let card_width = if centered { (width.saturating_sub(4) as usize).min(120) @@ -1678,16 +1696,19 @@ fn render_todo_plan_update( base_indent, inner_width, ), - crate::todo::TodoPlanField::UserIntention => push_todo_text_update( - &mut lines, - "User intention", - update - .after - .as_ref() - .and_then(|plan| plan.user_intention.as_deref()), - base_indent, - inner_width, - ), + crate::todo::TodoPlanField::UserIntention if intent_is_unclear => { + push_todo_text_update( + &mut lines, + "User intention", + update + .after + .as_ref() + .and_then(|plan| plan.user_intention.as_deref()), + base_indent, + inner_width, + ) + } + crate::todo::TodoPlanField::UserIntention => {} } } diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index 34239cf3c4..4303b21966 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -588,12 +588,9 @@ fn render_todos_message_shows_goal_scores_without_verbose_feedback() { } assert!(!plain.contains("Relevance representative"), "{plain}"); assert!(!plain.contains("Coverage main_paths"), "{plain}"); - // Plan-level intent renders once, above the groups. + // Only the plan-level assessment renders above the groups. assert!(plain.contains("Understands user intent clear"), "{plain}"); - assert!( - plain.contains("User intention · Keep the agent aligned with the user's request"), - "{plain}" - ); + assert!(!plain.contains("Keep the agent aligned"), "{plain}"); assert!(!plain.contains("Feedback ·"), "{plain}"); assert!(!plain.contains("Inspect a debug frame"), "{plain}"); assert!(plain.contains("● Render the card · plausible"), "{plain}"); @@ -601,7 +598,7 @@ fn render_todos_message_shows_goal_scores_without_verbose_feedback() { } #[test] -fn render_todos_message_compacts_long_details_at_narrow_widths() { +fn render_todos_message_shows_user_intention_when_understanding_is_unclear() { let long_text = "This deliberately long assessment detail should not consume several rows in a narrow terminal window"; let todos = vec![crate::todo::TodoItem { id: "1".to_string(), @@ -617,6 +614,7 @@ fn render_todos_message_compacts_long_details_at_narrow_widths() { }]; let plan = crate::todo::TodoPlan { user_intention: Some(long_text.to_string()), + understands_user_intent: Some(crate::todo::IntentUnderstanding::Partial), ..Default::default() }; let goals = vec![crate::todo::TodoGoal { @@ -637,9 +635,7 @@ fn render_todos_message_compacts_long_details_at_narrow_widths() { .iter() .filter(|line| line.contains("User intention")) .count(), - 1, - "{}", - narrow.join("\n") + 1 ); assert_eq!( narrow @@ -650,11 +646,6 @@ fn render_todos_message_compacts_long_details_at_narrow_widths() { "verbose goal feedback should stay out of inline cards: {}", narrow.join("\n") ); - assert!( - narrow.iter().any(|line| line.contains('…')), - "{}", - narrow.join("\n") - ); assert!( narrow .iter() @@ -668,8 +659,7 @@ fn render_todos_message_compacts_long_details_at_narrow_widths() { .map(extract_line_text) .collect::>(); assert!( - wide.iter() - .any(|line| line.contains("narrow terminal window")), + wide.iter().any(|line| line.contains(long_text)), "wide={wide:?}" ); assert!( From f8e5fe1f6c05026c417dca2bf2f8d25fd78f79d0 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:06:51 -0700 Subject: [PATCH 033/107] Remove memory info widget --- crates/jcode-tui/src/tui/app/tui_state.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index c3a69f7875..ad81493f31 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -1334,8 +1334,6 @@ impl crate::tui::TuiState for App { } }); - let memory_info = gather_memory_info(self.memory_enabled, self.session.working_dir.clone()); - // Gather swarm info let swarm_info = if self.swarm_enabled { let subagent_status = self.subagent_status.clone(); @@ -1589,7 +1587,9 @@ impl crate::tui::TuiState for App { session_name, working_dir: self.session.working_dir.clone(), client_count, - memory_info, + // Memory remains available through commands and tools, but no longer + // occupies a dedicated info widget. + memory_info: None, swarm_info, background_info, usage_info, From 1bd235b5fd4613b3527a2a37f98b55732bd1daca Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:18:04 -0700 Subject: [PATCH 034/107] fix(todo): request concise final response after gates --- crates/jcode-base/src/todo.rs | 9 +++++++++ crates/jcode-tui/src/tui/app.rs | 3 +++ crates/jcode-tui/src/tui/app/input.rs | 9 +++++++++ .../src/tui/app/tests/state_model_poke_03.rs | 18 +++++++++++++++--- crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 2 ++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index fa8a29036e..113a0cd89f 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -284,6 +284,11 @@ pub const TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[automated follow-up - n /// why the private evaluator selected it. pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Independently recheck the work below. Keep the todo up to date; do not reply or wait for the user."; +/// Final synthetic turn after every todo completion check has passed. Gate +/// continuations tell the model not to reply, so without this handoff a cycle +/// can end on a bare tool call or an internal-looking validation response. +pub const TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] All work and quality checks are complete. Give the user the final response now. Default to fewer than 5 lines unless the user's request requires more detail. Summarize the outcome clearly; do not call the todo tool or perform more work."; + /// A completed todo is considered spike-finished when its final recorded /// confidence step jumps this many levels or more (e.g. speculative straight /// to validated) instead of climbing through evidence-backed states. @@ -738,6 +743,7 @@ pub fn is_auto_poke_message(message: &str) -> bool { || trimmed.starts_with(LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX) @@ -761,6 +767,9 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { { return Some("🔍 Double-checking a confidence jump for you..."); } + if trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) { + return Some("✅ Preparing the final response..."); + } if trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 94971dbaa0..74dc02d93a 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -950,6 +950,9 @@ pub struct App { /// has sent. Without a budget, a model that stops updating its todos gets /// nudged on every turn forever, silently burning an API call per tick. todo_completion_gate_attempts: u8, + /// Whether the clean completion handoff has already requested a user-facing + /// final response for the current todo cycle. + todo_final_response_requested: bool, /// Exact continuation sent for the last incomplete todo state. An unchanged /// list must not trigger another automatic turn: the agent may be parked on /// a worker, wake, or human decision, and repeated pokes cannot help. diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index f7c0bcce2b..2f870cdd69 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1637,6 +1637,7 @@ impl App { // leave incomplete todos would never be poked. Stay armed and // simply do nothing this turn. crate::logging::info("AUTO_POKE_DECISION action=idle reason=no_todos incomplete=0"); + self.todo_final_response_requested = false; return false; } // Deferred quality checks land here, once, instead of interrupting @@ -1731,11 +1732,19 @@ impl App { "✅ All todos done. Completion confidence: {}.", confidence_label ))); + if !self.todo_final_response_requested { + self.todo_final_response_requested = true; + self.queued_messages + .push(crate::todo::TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE.to_string()); + self.pending_queued_dispatch = true; + return true; + } self.pending_queued_dispatch = false; return false; } let poke_message = super::commands::build_poke_message(&incomplete); + self.todo_final_response_requested = false; let fingerprint = serde_json::to_string(&incomplete).unwrap_or_else(|_| poke_message.clone()); if self.last_auto_poke_fingerprint.as_ref() == Some(&fingerprint) { diff --git a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs index 4bea57ce6b..ce459ff803 100644 --- a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs +++ b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs @@ -2534,7 +2534,7 @@ fn test_finish_turn_auto_poke_queues_confidence_summary_when_todos_done() { assert_eq!(app.queued_messages.len(), 1); // Once the model records sufficient completion confidence through the - // todo tool, the next completion check passes and disarms auto-poke. + // todo tool, the next completion check requests one clean final answer. let mut validated = crate::todo::load_todos(&app.session.id).expect("load todos"); for todo in &mut validated { todo.completion_confidence = Some(crate::todo::ConfidenceState::from_legacy_score(100)); @@ -2559,13 +2559,25 @@ fn test_finish_turn_auto_poke_queues_confidence_summary_when_todos_done() { // Auto-poke is default-on, so a completed cycle re-arms for the next // batch of work rather than silently switching the feature off. assert_eq!(app.auto_poke_incomplete_todos, app.auto_poke_default_on); - assert!(!app.pending_queued_dispatch); - assert!(app.queued_messages.is_empty()); + assert!(app.pending_queued_dispatch); + assert_eq!( + app.queued_messages, + vec![crate::todo::TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE.to_string()] + ); assert!(app.hidden_queued_system_messages.is_empty()); assert!(app.display_messages().iter().any(|msg| { msg.content .contains("All todos done. Completion confidence: verified.") })); + + // The final-answer turn itself must not enqueue another final-answer + // turn, otherwise a successfully completed cycle loops forever. + app.queued_messages.clear(); + app.pending_queued_dispatch = false; + app.is_processing = true; + super::local::finish_turn(&mut app); + assert!(!app.pending_queued_dispatch); + assert!(app.queued_messages.is_empty()); }); } diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 9b25b1d4f3..d0f87da4a9 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -457,6 +457,7 @@ impl App { todo_confidence_spike_challenged: false, todo_gate_digest_delivered: false, todo_completion_gate_attempts: 0, + todo_final_response_requested: false, last_auto_poke_fingerprint: None, turn_guardrail_stopped: false, consecutive_guardrail_stops: 0, @@ -899,6 +900,7 @@ impl App { todo_confidence_spike_challenged: false, todo_gate_digest_delivered: false, todo_completion_gate_attempts: 0, + todo_final_response_requested: false, last_auto_poke_fingerprint: None, turn_guardrail_stopped: false, consecutive_guardrail_stops: 0, From a2622db13a4e2c87fb6f110ecffbecf67b2cb6e3 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:56:43 -0700 Subject: [PATCH 035/107] feat(provider): add OrcaRouter profile --- README.md | 5 ++-- crates/jcode-provider-metadata/src/catalog.rs | 30 +++++++++++++++++-- crates/jcode-provider-metadata/src/lib.rs | 13 ++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 56670f0f1c..a25e696591 100644 --- a/README.md +++ b/README.md @@ -365,13 +365,14 @@ There are two ways to set one up: jcode login --provider # for example: jcode login --provider openrouter + jcode login --provider orcarouter jcode login --provider deepseek jcode login --provider opencode # OpenCode Zen jcode login --provider moonshotai jcode login --provider meta-muse # Meta Model API / Muse Spark ``` - Built-in OpenAI-compatible profile ids include: `openrouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `meta-muse` (Meta Model API / Muse Spark), `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list. + Built-in OpenAI-compatible profile ids include: `openrouter`, `orcarouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `meta-muse` (Meta Model API / Muse Spark), `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list. - **Any other endpoint** — point jcode at an arbitrary OpenAI-compatible API (hosted or local) with `jcode login --provider openai-compatible` or the scriptable `jcode provider add` command described below. @@ -608,7 +609,7 @@ The above image is the first page of provider logins ### Supported provider - **Native / first-party style providers:** `claude`, `openai`, `copilot`, `gemini`, `azure`, `alibaba-coding-plan` -- **Aggregator / compatibility providers:** `openrouter`, `openai-compatible` +- **Aggregator / compatibility providers:** `openrouter`, `orcarouter`, `openai-compatible` - **Additional provider integrations:** `opencode`, `opencode-go`, `zai` / `kimi`, `302ai`, `baseten`, `cortecs`, `deepseek`, `firmware`, `huggingface`, `moonshotai`, `nebius`, `scaleway`, `stackit`, `groq`, `mistral`, `perplexity`, `togetherai`, `deepinfra`, `fireworks`, `minimax`, `xai`, `lmstudio`, `ollama`, `chutes`, `cerebras`, `cursor`, `antigravity`, `google` Jcode also supports easy multi-account switching. Ran out of tokens on your first ChatGPT Pro subscription? /account and quickly switch to your second. diff --git a/crates/jcode-provider-metadata/src/catalog.rs b/crates/jcode-provider-metadata/src/catalog.rs index a361e1ed99..ea8acf3512 100644 --- a/crates/jcode-provider-metadata/src/catalog.rs +++ b/crates/jcode-provider-metadata/src/catalog.rs @@ -96,6 +96,17 @@ pub const OPENROUTER_OPENAI_COMPAT_PROFILE: OpenAiCompatibleProfile = OpenAiComp requires_api_key: true, }; +pub const ORCAROUTER_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile { + id: "orcarouter", + display_name: "OrcaRouter", + api_base: "https://api.orcarouter.ai/v1", + api_key_env: "ORCAROUTER_API_KEY", + env_file: "orcarouter.env", + setup_url: "https://www.orcarouter.ai", + default_model: None, + requires_api_key: true, +}; + // Anthropic and OpenAI also expose OpenAI-compatible `/v1/chat/completions` // endpoints, so they can be driven by `provider-doctor` / // `provider-test-coverage` as OpenAI-compatible profiles. These profile ids @@ -443,7 +454,7 @@ pub const OPENAI_COMPAT_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfi requires_api_key: true, }; -pub(crate) const OPENAI_COMPAT_PROFILES: [OpenAiCompatibleProfile; 38] = [ +pub(crate) const OPENAI_COMPAT_PROFILES: [OpenAiCompatibleProfile; 39] = [ OPENCODE_PROFILE, OPENCODE_GO_PROFILE, ZAI_PROFILE, @@ -455,6 +466,7 @@ pub(crate) const OPENAI_COMPAT_PROFILES: [OpenAiCompatibleProfile; 38] = [ BASETEN_PROFILE, CORTECS_PROFILE, OPENROUTER_OPENAI_COMPAT_PROFILE, + ORCAROUTER_PROFILE, ANTHROPIC_OPENAI_COMPAT_PROFILE, OPENAI_NATIVE_OPENAI_COMPAT_PROFILE, GEMINI_OPENAI_COMPAT_PROFILE, @@ -580,6 +592,19 @@ pub const OPENROUTER_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDesc order: LoginProviderSurfaceOrder::new(Some(4), Some(3), Some(4), Some(3), Some(3)), }; +pub const ORCAROUTER_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor { + id: "orcarouter", + display_name: "OrcaRouter", + auth_kind: LoginProviderAuthKind::ApiKey, + auth_state_key: LoginProviderAuthStateKey::OpenRouterLike, + auth_status_method: "API key", + aliases: &["orca-router"], + menu_detail: "API key, OpenAI-compatible gateway", + recommended: false, + target: LoginProviderTarget::OpenAiCompatible(ORCAROUTER_PROFILE), + order: LoginProviderSurfaceOrder::new(Some(39), Some(39), Some(39), Some(39), Some(39)), +}; + pub const BEDROCK_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor { id: "bedrock", display_name: "AWS Bedrock", @@ -1152,7 +1177,7 @@ pub const GOOGLE_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescript order: LoginProviderSurfaceOrder::new(Some(13), None, None, None, None), }; -pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 50] = [ +pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 51] = [ AUTO_IMPORT_LOGIN_PROVIDER, CLAUDE_LOGIN_PROVIDER, ANTHROPIC_API_LOGIN_PROVIDER, @@ -1160,6 +1185,7 @@ pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 50] = [ OPENAI_API_LOGIN_PROVIDER, JCODE_LOGIN_PROVIDER, OPENROUTER_LOGIN_PROVIDER, + ORCAROUTER_LOGIN_PROVIDER, BEDROCK_LOGIN_PROVIDER, AZURE_LOGIN_PROVIDER, OPENCODE_LOGIN_PROVIDER, diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index e03ae58925..93799d3b15 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -345,6 +345,19 @@ mod tests { assert_eq!(profile.setup_url, "https://docs.z.ai/devpack/quick-start"); } + #[test] + fn orcarouter_login_identifies_openai_compatible_endpoint() { + let provider = resolve_login_provider("orcarouter").expect("OrcaRouter provider"); + let LoginProviderTarget::OpenAiCompatible(profile) = provider.target else { + panic!("OrcaRouter should use the OpenAI-compatible runtime"); + }; + + assert_eq!(profile.id, "orcarouter"); + assert_eq!(profile.api_base, "https://api.orcarouter.ai/v1"); + assert_eq!(profile.api_key_env, "ORCAROUTER_API_KEY"); + assert!(profile.requires_api_key); + } + #[test] fn normalize_api_base_accepts_private_http_hosts() { assert_eq!( From b1ac1fe90822b785bab63ae27bc7720ddb111a62 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:06:46 -0700 Subject: [PATCH 036/107] fix(provider): honor explicit CLI provider over defaults --- crates/jcode-base/src/provider/startup.rs | 12 +++- src/cli/provider_init_tests.rs | 70 +++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/jcode-base/src/provider/startup.rs b/crates/jcode-base/src/provider/startup.rs index 51272d9d1c..a8fdd3653d 100644 --- a/crates/jcode-base/src/provider/startup.rs +++ b/crates/jcode-base/src/provider/startup.rs @@ -86,8 +86,10 @@ impl MultiProvider { let provider_init_start = std::time::Instant::now(); let cfg = crate::config::config(); let provider_state = ProviderState::from_parts(cfg, &auth_status); + let initial_provider = Self::initial_provider_from_env(); let mut default_named_provider_profile: Option = None; - if std::env::var_os("JCODE_PROVIDER_PROFILE_ACTIVE").is_none() + if initial_provider.is_none() + && std::env::var_os("JCODE_PROVIDER_PROFILE_ACTIVE").is_none() && std::env::var_os("JCODE_NAMED_PROVIDER_PROFILE").is_none() && let Some(pref) = provider_state.default_provider_key() { @@ -271,7 +273,6 @@ impl MultiProvider { ); } - let initial_provider = Self::initial_provider_from_env(); if let Some(initial) = initial_provider { active = initial; let is_configured = availability.is_configured(initial); @@ -341,7 +342,12 @@ impl MultiProvider { post_auth_refreshes_pending: Arc::new(std::sync::atomic::AtomicUsize::new(0)), }; - if let Some(model) = provider_state.default_model() { + // An explicit CLI/environment provider selection owns startup routing. + // Applying the configured default model here can reactivate its configured + // provider/profile before the caller pins a dual-auth credential mode. + if result.initial_provider.is_none() + && let Some(model) = provider_state.default_model() + { if let Err(e) = result.set_config_default_model(model, provider_state.default_provider_key()) { diff --git a/src/cli/provider_init_tests.rs b/src/cli/provider_init_tests.rs index e0bc534ea3..50c857d1f4 100644 --- a/src/cli/provider_init_tests.rs +++ b/src/cli/provider_init_tests.rs @@ -141,6 +141,76 @@ async fn explicit_anthropic_api_choice_pins_api_key_over_available_oauth() { crate::auth::AuthStatus::invalidate_cache(); } +#[tokio::test(flavor = "multi_thread")] +#[expect( + clippy::await_holding_lock, + reason = "test env locks intentionally stay held across provider init to isolate process-global auth env" +)] +async fn explicit_openai_api_choice_overrides_configured_compatible_default() { + let _guard = lock_env(); + let _env_guard = crate::storage::lock_test_env(); + let dir = TempDir::new().expect("temp dir"); + let keys = [ + "JCODE_HOME", + "OPENAI_API_KEY", + "JCODE_PROVIDER_PROFILE_ACTIVE", + "JCODE_PROVIDER_PROFILE_NAME", + "JCODE_NAMED_PROVIDER_PROFILE", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", + ]; + let saved: Vec<(&str, Option)> = keys + .iter() + .map(|key| (*key, std::env::var(key).ok())) + .collect(); + + crate::env::set_var("JCODE_HOME", dir.path()); + crate::env::set_var("OPENAI_API_KEY", "sk-openai-api-test"); + for key in keys.iter().skip(2) { + crate::env::remove_var(key); + } + std::fs::write( + dir.path().join("config.toml"), + r#" +[provider] +default_provider = "local-gateway" +default_model = "gateway-default" + +[providers.local-gateway] +type = "openai-compatible" +base_url = "http://localhost:1234/v1" +auth = "none" +default_model = "gateway-default" +requires_api_key = false +"#, + ) + .expect("write competing configured provider default"); + crate::config::invalidate_config_cache(); + crate::auth::AuthStatus::invalidate_cache(); + + let provider = init_provider_for_validation(&ProviderChoice::OpenaiApi, Some("gpt-5.6-luna")) + .await + .expect("explicit OpenAI API provider should override configured defaults"); + + assert_eq!(provider.active_auth_method_label(), Some("API key")); + assert_eq!(provider.model(), "gpt-5.6-luna"); + assert_eq!( + std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(), + Some("openai-api") + ); + + for (key, value) in saved { + if let Some(value) = value { + crate::env::set_var(key, value); + } else { + crate::env::remove_var(key); + } + } + crate::config::invalidate_config_cache(); + crate::auth::AuthStatus::invalidate_cache(); +} + #[test] fn test_server_bootstrap_login_selection_preserves_order() { let providers = provider_catalog::server_bootstrap_login_providers(); From 0371784067bb6aa496173bf8d64f3dd5f5bdc15e Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:19:37 -0700 Subject: [PATCH 037/107] test(provider): cover OrcaRouter CLI login selection --- crates/jcode-provider-metadata/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index 93799d3b15..4008c6206c 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -347,7 +347,8 @@ mod tests { #[test] fn orcarouter_login_identifies_openai_compatible_endpoint() { - let provider = resolve_login_provider("orcarouter").expect("OrcaRouter provider"); + let provider = resolve_login_selection("orcarouter", &cli_login_providers()) + .expect("OrcaRouter CLI login provider"); let LoginProviderTarget::OpenAiCompatible(profile) = provider.target else { panic!("OrcaRouter should use the OpenAI-compatible runtime"); }; From 1cb565022f070998156b104c26e616e3a719ee41 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:41:29 -0700 Subject: [PATCH 038/107] Remove unused memory widget data loader --- crates/jcode-tui/src/tui/app/helpers.rs | 162 +----------------------- 1 file changed, 1 insertion(+), 161 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/helpers.rs b/crates/jcode-tui/src/tui/app/helpers.rs index 8137056466..748c0c790e 100644 --- a/crates/jcode-tui/src/tui/app/helpers.rs +++ b/crates/jcode-tui/src/tui/app/helpers.rs @@ -4,7 +4,7 @@ mod clipboard_helper; pub(crate) mod model_names; use crate::todo::TodoItem; -use crate::tui::info_widget::{AmbientWidgetData, GitInfo, MemoryInfo}; +use crate::tui::info_widget::{AmbientWidgetData, GitInfo}; use crate::tui::session_picker::ResumeTarget; use crossterm::event::{KeyCode, KeyModifiers}; use std::path::{Path, PathBuf}; @@ -1116,166 +1116,6 @@ pub(super) fn gather_todos_and_goals_for_session( (Vec::new(), Vec::new()) } -pub(super) fn gather_memory_info( - memory_enabled: bool, - working_dir: Option, -) -> Option { - use std::sync::Mutex; - use std::time::Instant; - - static CACHE: Mutex, bool)>> = Mutex::new(None); - const TTL: Duration = Duration::from_secs(2); - - // When memory is disabled we still surface the stored counts (with a - // DISABLED badge) so the user can see they have memories but recall is off. - // Live activity and the sidecar model are suppressed in that case. - let activity = if memory_enabled { - crate::memory::get_activity() - } else { - None - }; - let sidecar_model = if memory_enabled && crate::memory::memory_sidecar_enabled() { - let sidecar = crate::sidecar::Sidecar::new(); - Some(format!( - "{} · {}", - sidecar.backend_name(), - sidecar.model_name() - )) - } else { - None - }; - - let finalize = |mut info: MemoryInfo| { - info.activity = activity.clone(); - info.sidecar_model = sidecar_model.clone(); - info.disabled = !memory_enabled; - info - }; - - if let Ok(mut guard) = CACHE.lock() { - if let Some((ts, cached, refreshing)) = guard.as_mut() { - if ts.elapsed() < TTL || *refreshing { - return match cached.clone() { - Some(info) => Some(finalize(info)), - None => fallback_memory_info(memory_enabled, &activity, &sidecar_model), - }; - } - let stale = match cached.clone() { - Some(info) => Some(finalize(info)), - None => fallback_memory_info(memory_enabled, &activity, &sidecar_model), - }; - *refreshing = true; - let working_dir = working_dir.clone(); - std::thread::spawn(move || { - let result = gather_memory_info_inner(working_dir); - if let Ok(mut guard) = CACHE.lock() { - *guard = Some((Instant::now(), result, false)); - } - }); - return stale; - } - - *guard = Some((backdated_now(TTL + Duration::from_secs(1)), None, true)); - std::thread::spawn(move || { - let result = gather_memory_info_inner(working_dir); - if let Ok(mut guard) = CACHE.lock() { - *guard = Some((Instant::now(), result, false)); - } - }); - } - - fallback_memory_info(memory_enabled, &activity, &sidecar_model) -} - -fn fallback_memory_info( - memory_enabled: bool, - activity: &Option, - sidecar_model: &Option, -) -> Option { - // No cached counts yet. Show whatever live signal we have. - if activity.is_none() && sidecar_model.is_none() && memory_enabled { - return None; - } - Some(MemoryInfo { - sidecar_available: crate::memory::memory_sidecar_enabled(), - sidecar_model: sidecar_model.clone(), - activity: activity.clone(), - disabled: !memory_enabled, - ..Default::default() - }) -} - -fn gather_memory_info_inner(working_dir: Option) -> Option { - let activity = crate::memory::get_activity(); - let sidecar_model = if crate::memory::memory_sidecar_enabled() { - let sidecar = crate::sidecar::Sidecar::new(); - Some(format!( - "{} · {}", - sidecar.backend_name(), - sidecar.model_name() - )) - } else { - None - }; - - use crate::memory::MemoryManager; - - // Scope the manager to the session working dir so the project count reads - // the same projects/.json store the memory tool writes (issue #491). - let manager = match working_dir.as_deref() { - Some(dir) if !dir.trim().is_empty() => MemoryManager::new().with_project_dir(dir), - _ => MemoryManager::new(), - }; - let project_graph = manager.load_project_graph().ok(); - let global_graph = manager.load_global_graph().ok(); - - let (project_count, global_count, by_category) = { - let mut by_category = std::collections::HashMap::new(); - let project_count = project_graph - .as_ref() - .map(|p| { - for entry in p.memories.values() { - *by_category.entry(entry.category.to_string()).or_insert(0) += 1; - } - p.memory_count() - }) - .unwrap_or(0); - let global_count = global_graph - .as_ref() - .map(|g| { - for entry in g.memories.values() { - *by_category.entry(entry.category.to_string()).or_insert(0) += 1; - } - g.memory_count() - }) - .unwrap_or(0); - (project_count, global_count, by_category) - }; - - let total_count = project_count + global_count; - let (graph_nodes, graph_edges) = crate::tui::info_widget::build_graph_topology( - project_graph.as_ref(), - global_graph.as_ref(), - ); - - if total_count > 0 || activity.is_some() || sidecar_model.is_some() { - Some(MemoryInfo { - total_count, - project_count, - global_count, - by_category, - sidecar_available: crate::memory::memory_sidecar_enabled(), - sidecar_model, - activity, - disabled: false, - graph_nodes, - graph_edges, - }) - } else { - None - } -} - pub(super) fn gather_ambient_info(ambient_enabled: bool) -> Option { use std::time::Instant; const TTL: Duration = Duration::from_secs(2); From e42bad3f7b8c4a580aeca8b9cd5dd9df963d94b1 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:44:13 -0700 Subject: [PATCH 039/107] fix(tui): support macOS swarm Option shortcuts --- crates/jcode-tui-core/src/keybind.rs | 18 ++++++++++++++++++ crates/jcode-tui/src/tui/app/tui_state.rs | 5 +++++ crates/jcode-tui/src/tui/keybind.rs | 9 +++++++++ scripts/dev_cargo.sh | 10 ++++++++-- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/jcode-tui-core/src/keybind.rs b/crates/jcode-tui-core/src/keybind.rs index ca6ef45541..84cbdfc6da 100644 --- a/crates/jcode-tui-core/src/keybind.rs +++ b/crates/jcode-tui-core/src/keybind.rs @@ -74,17 +74,26 @@ pub fn macos_option_char_to_ascii_key(code: KeyCode) -> Option { '∂' => Some('d'), '´' => Some('e'), 'ƒ' => Some('f'), + '©' => Some('g'), '˙' => Some('h'), 'ˆ' => Some('i'), '∆' => Some('j'), '˚' => Some('k'), '¬' => Some('l'), 'µ' => Some('m'), + '˜' => Some('n'), + 'ø' => Some('o'), + 'π' => Some('p'), + 'œ' => Some('q'), + '®' => Some('r'), 'ß' => Some('s'), '†' => Some('t'), '¨' => Some('u'), '√' => Some('v'), + '∑' => Some('w'), + '≈' => Some('x'), '¥' => Some('y'), + 'Ω' => Some('z'), _ => None, } } @@ -956,17 +965,26 @@ mod tests { ('∂', 'd'), ('´', 'e'), ('ƒ', 'f'), + ('©', 'g'), ('˙', 'h'), ('ˆ', 'i'), ('∆', 'j'), ('˚', 'k'), ('¬', 'l'), ('µ', 'm'), + ('˜', 'n'), + ('ø', 'o'), + ('π', 'p'), + ('œ', 'q'), + ('®', 'r'), ('ß', 's'), ('†', 't'), ('¨', 'u'), ('√', 'v'), + ('∑', 'w'), + ('≈', 'x'), ('¥', 'y'), + ('Ω', 'z'), ] { assert_eq!( macos_option_char_to_ascii_key(KeyCode::Char(option_char)), diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index ad81493f31..34a723ca27 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -2159,6 +2159,11 @@ pub(crate) fn swarm_panel_action_for_key( // macOS Option+letter often arrives as a transformed glyph with no ALT // modifier; normalize through the shared shortcut helper. let macos_letter = crate::tui::keybind::shortcut_char_for_macos_option_key(code, modifiers); + let macos_shift_letter = + crate::tui::keybind::shortcut_char_for_macos_option_shift_key(code, modifiers); + if macos_shift_letter == Some('p') { + return Some(SwarmPanelAction::OpenPrompt); + } match code { KeyCode::Down | KeyCode::Char('j') if alt => Some(SwarmPanelAction::SelectNext), KeyCode::Up | KeyCode::Char('k') if alt => Some(SwarmPanelAction::SelectPrev), diff --git a/crates/jcode-tui/src/tui/keybind.rs b/crates/jcode-tui/src/tui/keybind.rs index 72248cd361..896ebe4015 100644 --- a/crates/jcode-tui/src/tui/keybind.rs +++ b/crates/jcode-tui/src/tui/keybind.rs @@ -535,17 +535,26 @@ fn macos_option_shift_char_to_ascii_key(code: KeyCode) -> Option { 'Î' => Some('d'), '´' => Some('e'), 'Ï' => Some('f'), + '˝' => Some('g'), 'Ó' => Some('h'), 'ˆ' => Some('i'), 'Ô' => Some('j'), '' => Some('k'), 'Ò' => Some('l'), 'Â' => Some('m'), + '˜' => Some('n'), + 'Ø' => Some('o'), + '∏' => Some('p'), + 'Œ' => Some('q'), + '‰' => Some('r'), 'Í' => Some('s'), 'ˇ' => Some('t'), '¨' => Some('u'), '◊' => Some('v'), + '„' => Some('w'), + '˛' => Some('x'), 'Á' => Some('y'), + '¸' => Some('z'), _ => None, } } diff --git a/scripts/dev_cargo.sh b/scripts/dev_cargo.sh index 0954491c2b..77af0038e5 100755 --- a/scripts/dev_cargo.sh +++ b/scripts/dev_cargo.sh @@ -1065,7 +1065,7 @@ acquire_cargo_gate() { return 0 fi - local gate_dir gate_path wait_started_ns wait_finished_ns + local gate_dir gate_path wait_started_ns wait_finished_ns waited_seconds gate_dir="${JCODE_CARGO_GATE_DIR:-${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}}" mkdir -p "$gate_dir" gate_path="${JCODE_CARGO_GATE_PATH:-$gate_dir/jcode-cargo-build.lock}" @@ -1073,7 +1073,13 @@ acquire_cargo_gate() { if ! flock -n "$cargo_gate_fd"; then log "waiting for the host-wide Cargo gate ($gate_path)" wait_started_ns=$(date +%s%N) - flock "$cargo_gate_fd" + waited_seconds=0 + # Avoid one silent, unbounded flock call. Periodic notes make it clear that + # the process is alive and blocked behind another compiler rather than hung. + while ! flock -w 30 "$cargo_gate_fd"; do + waited_seconds=$((waited_seconds + 30)) + log "still waiting for the host-wide Cargo gate (${waited_seconds}s elapsed)" + done wait_finished_ns=$(date +%s%N) cargo_gate_wait_ms=$(( (wait_finished_ns - wait_started_ns) / 1000000 )) fi From bc0e25de668724ee90f45d6934ca0256d8310f6e Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:47:01 -0700 Subject: [PATCH 040/107] feat(bg): stall watchdog wakes the agent when a background task goes quiet Adds an opt-in stall watchdog for background tasks: arm it with stall_wake_seconds on bash (run_in_background) or bg delivery/watch, and the agent is woken after N seconds with no new output bytes and no progress events. Activity resets the countdown; the watchdog fires at most once per silence episode and re-arms when output resumes. - BackgroundTaskStalled bus event + BackgroundTaskWaitReason::Stalled - bg wait returns immediately when the watchdog fires - server dispatch mirrors completion delivery (notify + wake/soft-interrupt) - TUI local mode renders the stall notification and wakes idle agents - min window 30s; watchdog exits when the task leaves Running --- crates/jcode-app-core/src/server.rs | 14 ++ .../src/server/background_tasks.rs | 80 ++++++++ crates/jcode-app-core/src/tool/bash.rs | 24 ++- .../src/tool/bash_destructive_gate.rs | 4 + crates/jcode-app-core/src/tool/bg.rs | 54 ++++-- .../jcode-app-core/src/tool/selfdev/tests.rs | 3 + crates/jcode-background-types/src/lib.rs | 23 +++ crates/jcode-base/Cargo.toml | 2 + crates/jcode-base/src/background.rs | 180 ++++++++++++++++++ crates/jcode-base/src/background/model.rs | 6 + crates/jcode-base/src/background/tests.rs | 125 ++++++++++++ crates/jcode-base/src/bus.rs | 5 +- crates/jcode-base/src/message.rs | 5 +- .../jcode-base/src/message/notifications.rs | 25 +++ crates/jcode-tui/src/tui/app/local.rs | 52 +++++ 15 files changed, 583 insertions(+), 19 deletions(-) diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs index 36fec332ba..e8b976310e 100644 --- a/crates/jcode-app-core/src/server.rs +++ b/crates/jcode-app-core/src/server.rs @@ -52,6 +52,7 @@ mod util; pub(super) use self::await_members_state::AwaitMembersRuntime; use self::background_tasks::{ dispatch_background_task_completion, dispatch_background_task_progress, + dispatch_background_task_stalled, dispatch_swarm_await_completion, dispatch_swarm_batch_progress, dispatch_swarm_output_tail, dispatch_swarm_runtime_status, dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, dispatch_ui_activity, @@ -2178,6 +2179,19 @@ impl Server { Ok(BusEvent::BackgroundTaskProgress(task)) => { dispatch_background_task_progress(&task, &swarm_members).await; } + Ok(BusEvent::BackgroundTaskStalled(task)) => { + dispatch_background_task_stalled( + &task, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } Ok(BusEvent::SwarmAwaitCompleted(event)) => { dispatch_swarm_await_completion( &event, diff --git a/crates/jcode-app-core/src/server/background_tasks.rs b/crates/jcode-app-core/src/server/background_tasks.rs index 6b68c8a86a..b39bb03e7f 100644 --- a/crates/jcode-app-core/src/server/background_tasks.rs +++ b/crates/jcode-app-core/src/server/background_tasks.rs @@ -89,6 +89,86 @@ pub(super) async fn dispatch_background_task_completion( } } +/// Deliver a stall-watchdog wake for a background task that has gone quiet. +/// +/// Mirrors completion delivery: optionally notify attached clients, then wake +/// an idle agent or queue a soft interrupt for a busy one. The task is still +/// running; the message tells the agent to inspect and decide. +#[expect( + clippy::too_many_arguments, + reason = "background task stall delivery needs session, interrupt, and swarm status state" +)] +pub(super) async fn dispatch_background_task_stalled( + task: &crate::bus::BackgroundTaskStalled, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_members: &Arc>>, + swarms_by_id: &Arc>>>, + event_history: &Arc>>, + event_counter: &Arc, + swarm_event_tx: &broadcast::Sender, +) { + let notification = crate::message::format_background_task_stalled_markdown(task); + + if task.notify + && fanout_session_event( + swarm_members, + &task.session_id, + ServerEvent::Notification { + from_session: "background_task".to_string(), + from_name: Some("background task".to_string()), + notification_type: NotificationType::Message { + scope: Some("background_task".to_string()), + channel: None, + tldr: None, + }, + message: notification.clone(), + }, + ) + .await + == 0 + { + crate::logging::warn(&format!( + "Failed to notify attached clients for background task stall on session {}", + task.session_id + )); + } + + if task.wake + && !run_live_turn_if_idle( + &task.session_id, + ¬ification, + Some( + "A background task for this session has produced no output or progress for its stall window. Inspect it and decide whether to keep waiting, fix it, or cancel it." + .to_string(), + ), + sessions, + LiveTurnSwarmContext::new( + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ), + ) + .await + && !queue_soft_interrupt_for_session( + &task.session_id, + notification.clone(), + false, + SoftInterruptSource::BackgroundTask, + soft_interrupt_queues, + sessions, + ) + .await + { + crate::logging::warn(&format!( + "Failed to deliver background task stall to session {}", + task.session_id + )); + } +} + /// Deliver the result of a backgrounded `swarm await_members` watcher to the /// requesting session. Mirrors background-task completion delivery: optionally /// notify attached clients, then wake an idle agent or queue a soft interrupt diff --git a/crates/jcode-app-core/src/tool/bash.rs b/crates/jcode-app-core/src/tool/bash.rs index 3cdbd2b5fc..c12688d6d2 100644 --- a/crates/jcode-app-core/src/tool/bash.rs +++ b/crates/jcode-app-core/src/tool/bash.rs @@ -696,6 +696,10 @@ struct BashInput { notify: bool, #[serde(default)] wake: bool, + /// For background runs: wake the agent after this many seconds with no + /// new output and no progress events. Resets on activity. + #[serde(default)] + stall_wake_seconds: Option, /// Set only when re-issuing a call the gate refused (#604). #[serde(default)] justification: Option, @@ -1289,6 +1293,23 @@ impl BashTool { } else { "Notifications disabled. Use `bg` tool to check status." }; + + let stall_msg = match params.stall_wake_seconds { + Some(requested) => { + match crate::background::global() + .arm_stall_watchdog(&info.task_id, requested) + .await + { + Some(effective) => format!( + "Stall watchdog armed: you will be woken after {}s with no output or progress (resets on activity).\n", + effective + ), + None => String::new(), + } + } + None => String::new(), + }; + let output = format!( "Command started in background.\n\n\ Task ID: {}\n\ @@ -1296,7 +1317,7 @@ impl BashTool { Output file: {}\n\ Status file: {}\n\n\ {}\n\ - To wait for completion/checkpoints: use the `bg` tool with action=\"wait\" and task_id=\"{}\"\n\ + {}To wait for completion/checkpoints: use the `bg` tool with action=\"wait\" and task_id=\"{}\"\n\ To check progress immediately: use the `bg` tool with action=\"status\" and task_id=\"{}\"\n\ To see output: use the `read` tool on the output file, or `bg` with action=\"output\"\n\n\ {}", @@ -1305,6 +1326,7 @@ impl BashTool { info.output_file.display(), info.status_file.display(), notify_msg, + stall_msg, info.task_id, info.task_id, BACKGROUND_PROGRESS_GUIDANCE, diff --git a/crates/jcode-app-core/src/tool/bash_destructive_gate.rs b/crates/jcode-app-core/src/tool/bash_destructive_gate.rs index 699be85b1a..b9c0f551e1 100644 --- a/crates/jcode-app-core/src/tool/bash_destructive_gate.rs +++ b/crates/jcode-app-core/src/tool/bash_destructive_gate.rs @@ -75,6 +75,10 @@ pub(super) fn bash_parameters_schema() -> serde_json::Value { "type": "boolean", "description": "Wake on completion." }, + "stall_wake_seconds": { + "type": "integer", + "description": "With run_in_background: wake the agent after this many seconds of no output/progress (min 30, resets on activity). Use for long jobs that may hang silently." + }, "justification": { "type": "string", "description": "Only when re-issuing a command the destructive gate refused; explain which user request it serves." diff --git a/crates/jcode-app-core/src/tool/bg.rs b/crates/jcode-app-core/src/tool/bg.rs index 22a519f4c2..db0e7ea615 100644 --- a/crates/jcode-app-core/src/tool/bg.rs +++ b/crates/jcode-app-core/src/tool/bg.rs @@ -74,6 +74,10 @@ struct BgInput { /// Whether to wake on completion when using watch/delivery (default: true) #[serde(default)] wake: Option, + /// For watch/delivery: also arm a stall watchdog that wakes the agent after + /// this many seconds with no new output or progress (resets on activity) + #[serde(default)] + stall_wake_seconds: Option, /// Max seconds to block when using wait (default: 60, capped at 3600) #[serde(default)] max_wait_seconds: Option, @@ -333,6 +337,7 @@ fn wait_reason_label(reason: background::BackgroundTaskWaitReason) -> &'static s background::BackgroundTaskWaitReason::Finished => "finished", background::BackgroundTaskWaitReason::Progress => "progress", background::BackgroundTaskWaitReason::Checkpoint => "checkpoint", + background::BackgroundTaskWaitReason::Stalled => "stalled", background::BackgroundTaskWaitReason::Timeout => "timeout", } } @@ -491,6 +496,7 @@ impl Tool for BgTool { "dry_run": { "type": "boolean", "description": "For cleanup, report what would be removed without deleting." }, "notify": { "type": "boolean", "description": "When using delivery/watch/subscribe, whether to notify on completion. Defaults to true." }, "wake": { "type": "boolean", "description": "When using delivery/watch/subscribe, whether to wake on completion. Defaults to true." }, + "stall_wake_seconds": { "type": "integer", "description": "For delivery/watch: also wake the agent after this many seconds of no output/progress (min 30, resets on activity). Use for long jobs that may hang silently." }, "max_wait_seconds": { "type": "integer", "description": "For wait: max seconds to block. Default 60, cap 3600, 0 = immediate check." }, "return_on_progress": { "type": "boolean", "description": "For wait: return on the first progress/checkpoint event too. Defaults to true." }, "wait_mode": { "type": "string", "enum": ["any", "all", "first_failure"], "description": "For multi-task wait, return on any completion, all completions, or first failure. Defaults to any." }, @@ -663,22 +669,37 @@ impl Tool for BgTool { .remove(0); let notify = params.notify.unwrap_or_else(default_watch_notify); let wake = params.wake.unwrap_or_else(default_watch_wake); + let stall_armed = match params.stall_wake_seconds { + Some(requested) => manager.arm_stall_watchdog(&task_id, requested).await, + None => None, + }; match manager.update_delivery(&task_id, notify, wake).await? { - Some(task) => Ok(ToolOutput::new(format!( - "Updated background task delivery for {}.\nStatus: {}\nNotify: {}\nWake: {}", - task_id, - status_label(&task.status), - task.notify, - task.wake - )) - .with_title(format!("bg delivery {}", task_id)) - .with_metadata(json!({ - "task_id": task.task_id, - "task": task_metadata(manager, &task), - "status": status_label(&task.status), - "notify": task.notify, - "wake": task.wake, - }))), + Some(task) => { + let stall_line = match stall_armed { + Some(effective) => format!( + "\nStall watchdog: wake after {}s of no output/progress (resets on activity)", + effective + ), + None => String::new(), + }; + Ok(ToolOutput::new(format!( + "Updated background task delivery for {}.\nStatus: {}\nNotify: {}\nWake: {}{}", + task_id, + status_label(&task.status), + task.notify, + task.wake, + stall_line + )) + .with_title(format!("bg delivery {}", task_id)) + .with_metadata(json!({ + "task_id": task.task_id, + "task": task_metadata(manager, &task), + "status": status_label(&task.status), + "notify": task.notify, + "wake": task.wake, + "stall_wake_seconds": stall_armed, + }))) + } None => Err(anyhow::anyhow!("Task not found: {}", task_id)), } } @@ -760,6 +781,9 @@ impl Tool for BgTool { background::BackgroundTaskWaitReason::Checkpoint => { "Background task emitted a checkpoint event.\n\n".to_string() } + background::BackgroundTaskWaitReason::Stalled => { + "Background task stall watchdog fired: no output or progress for its stall window. The task is still running; inspect it and decide whether to keep waiting or cancel.\n\n".to_string() + } background::BackgroundTaskWaitReason::Timeout => format!( "No terminal event before max wait of {}s. Check again with `bg action=\"wait\" task_id=\"{}\"` or inspect status/output.\n\n", capped_wait, task_id diff --git a/crates/jcode-app-core/src/tool/selfdev/tests.rs b/crates/jcode-app-core/src/tool/selfdev/tests.rs index 3b014b9be0..bee0ec475b 100644 --- a/crates/jcode-app-core/src/tool/selfdev/tests.rs +++ b/crates/jcode-app-core/src/tool/selfdev/tests.rs @@ -1200,6 +1200,7 @@ async fn build_ignores_stale_pending_requests_when_computing_queue_position() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write stale status file"); @@ -1300,6 +1301,7 @@ fn reconcile_pending_state_maps_superseded_background_status() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write superseded status file"); @@ -1389,6 +1391,7 @@ fn reconcile_keeps_running_request_not_yet_registered_in_live_task_map() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write running status file"); diff --git a/crates/jcode-background-types/src/lib.rs b/crates/jcode-background-types/src/lib.rs index 4971e0bc06..3af060ca27 100644 --- a/crates/jcode-background-types/src/lib.rs +++ b/crates/jcode-background-types/src/lib.rs @@ -89,6 +89,29 @@ pub struct BackgroundTaskCompleted { pub wake: bool, } +/// Event sent when a stall watchdog notices a background task has produced no +/// output bytes and no progress events for its configured stall window. +/// +/// This is a "check on me" signal, not a terminal event: the task is still +/// running as far as the manager can tell. The watchdog fires at most once per +/// silence episode; new output or progress re-arms it. +#[derive(Debug, Clone)] +pub struct BackgroundTaskStalled { + pub task_id: String, + pub tool_name: String, + pub display_name: Option, + pub session_id: String, + /// Configured stall window in seconds. + pub stall_wake_seconds: u64, + /// How long the task has been running when the watchdog fired. + pub running_secs: f64, + /// Tail of the output file at fire time (already truncated). + pub output_tail: String, + pub output_file: PathBuf, + pub notify: bool, + pub wake: bool, +} + /// Render a one-line human-readable progress display: an optional progress bar, /// a textual summary, and the source label (for example `[##--] 50% (reported)`). pub fn format_progress_display(progress: &BackgroundTaskProgress, width: usize) -> String { diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index c66d20fced..d8acd70993 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -151,3 +151,5 @@ global-hotkey = "0.7" # MultiProvider routing tests exercise the real OpenRouter/OpenAI-compatible # runtime through the composition-root registry, exactly like the binary. jcode-provider-openrouter-runtime = { path = "../jcode-provider-openrouter-runtime" } +# Paused-time (auto-advancing virtual clock) tests for the background stall watchdog. +tokio = { version = "1", features = ["test-util"] } diff --git a/crates/jcode-base/src/background.rs b/crates/jcode-base/src/background.rs index e0ed1cf181..d6b2a981fe 100644 --- a/crates/jcode-base/src/background.rs +++ b/crates/jcode-base/src/background.rs @@ -35,6 +35,10 @@ use model::{ /// Manages background task execution pub struct BackgroundTaskManager { tasks: Arc>>, + /// Live stall watchdogs by task id. Each entry is a spawned monitor that + /// fires a [`BusEvent::BackgroundTaskStalled`] after its task produces no + /// output bytes and no progress events for the configured window. + stall_watchdogs: Arc>>>, output_dir: PathBuf, } @@ -46,6 +50,7 @@ impl BackgroundTaskManager { std::fs::create_dir_all(&output_dir).ok(); Self { tasks: Arc::new(RwLock::new(HashMap::new())), + stall_watchdogs: Arc::new(RwLock::new(HashMap::new())), output_dir, } } @@ -387,6 +392,7 @@ impl BackgroundTaskManager { wake, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }; self.write_status_file(&info.status_file, &status).await; Self::publish_task_started_activity( @@ -456,6 +462,7 @@ impl BackgroundTaskManager { wake, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }; if let Ok(json) = serde_json::to_string_pretty(&initial_status) { let _ = std::fs::write(&status_path, json); @@ -531,6 +538,7 @@ impl BackgroundTaskManager { wake: wake_flag, progress: prior_progress, event_history: prior_event_history, + stall_wake_seconds: None, }; push_task_event( &mut final_status, @@ -658,6 +666,7 @@ impl BackgroundTaskManager { wake, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }; if let Ok(json) = serde_json::to_string_pretty(&initial_status) { let _ = std::fs::write(&status_path, json); @@ -742,6 +751,7 @@ impl BackgroundTaskManager { wake: wake_flag, progress: prior_progress, event_history: prior_event_history, + stall_wake_seconds: None, }; push_task_event( &mut final_status, @@ -958,6 +968,17 @@ impl BackgroundTaskManager { }); } } + Ok(BusEvent::BackgroundTaskStalled(event)) if event.task_id == task_id => { + // A stall is always worth returning: the caller + // should decide whether to keep waiting or act. + let task = self.status(task_id).await?; + return Some(BackgroundTaskWaitResult { + reason: BackgroundTaskWaitReason::Stalled, + task, + progress_event: None, + event_record: None, + }); + } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { let task = self.status(task_id).await?; if task.status != BackgroundTaskStatus::Running { @@ -1069,6 +1090,163 @@ impl BackgroundTaskManager { Ok(Some(status)) } + /// How often a stall watchdog samples output size and progress state. + const STALL_POLL_INTERVAL: Duration = Duration::from_secs(5); + /// Minimum accepted stall window, to keep watchdogs from spamming wakes. + pub const MIN_STALL_WAKE_SECONDS: u64 = 30; + + /// Arm (or re-arm) a stall watchdog for a running task. + /// + /// The watchdog fires a [`BusEvent::BackgroundTaskStalled`] after the task + /// produces no new output bytes and no new progress/checkpoint events for + /// `stall_wake_seconds`. Any activity resets the countdown. It fires at + /// most once per silence episode and re-arms itself when activity resumes, + /// so a task that stalls, recovers, and stalls again produces one wake per + /// stall. The watchdog exits when the task leaves `Running`. + /// + /// Returns the effective (clamped) window, or `None` when the task does + /// not exist or is no longer running. + pub async fn arm_stall_watchdog(&self, task_id: &str, stall_wake_seconds: u64) -> Option { + let stall_wake_seconds = stall_wake_seconds.max(Self::MIN_STALL_WAKE_SECONDS); + let status_path = self.status_path_for(task_id); + let mut status = self.read_status_file(&status_path).await?; + if status.status != BackgroundTaskStatus::Running { + return None; + } + status.stall_wake_seconds = Some(stall_wake_seconds); + self.write_status_file(&status_path, &status).await; + + let task_id_owned = task_id.to_string(); + let output_path = self.output_path_for(task_id); + let status_path_clone = status_path.clone(); + let started_at = status.started_at.clone(); + let watchdogs = Arc::clone(&self.stall_watchdogs); + + let handle = tokio::spawn(async move { + let mut last_len = fs::metadata(&output_path) + .await + .map(|meta| meta.len()) + .unwrap_or(0); + let mut last_event_count: Option = None; + let mut quiet_since = TokioInstant::now(); + let mut fired_this_episode = false; + let window = Duration::from_secs(stall_wake_seconds); + let mut poll = tokio::time::interval(Self::STALL_POLL_INTERVAL); + poll.set_missed_tick_behavior(MissedTickBehavior::Skip); + // interval fires immediately on first tick; consume it. + poll.tick().await; + + loop { + poll.tick().await; + + let Ok(content) = fs::read_to_string(&status_path_clone).await else { + break; + }; + let Ok(status) = serde_json::from_str::(&content) else { + break; + }; + if status.status != BackgroundTaskStatus::Running { + break; + } + // Disarmed externally (e.g. re-armed with a new window by a + // newer watchdog that overwrote this one's registry entry and + // cleared the field first): keep it simple, exit if cleared. + if status.stall_wake_seconds != Some(stall_wake_seconds) { + break; + } + + let output_len = fs::metadata(&output_path) + .await + .map(|meta| meta.len()) + .unwrap_or(0); + let event_count = status.event_history.len(); + let activity = output_len != last_len + || last_event_count.is_some_and(|prior| prior != event_count); + last_len = output_len; + last_event_count = Some(event_count); + + if activity { + quiet_since = TokioInstant::now(); + fired_this_episode = false; + continue; + } + + if fired_this_episode || quiet_since.elapsed() < window { + continue; + } + fired_this_episode = true; + + let running_secs = DateTime::parse_from_rfc3339(&started_at) + .ok() + .and_then(|started| (Utc::now() - started.with_timezone(&Utc)).to_std().ok()) + .map(|duration| duration.as_secs_f64()) + .unwrap_or_default(); + let output_tail = fs::read_to_string(&output_path) + .await + .map(|output| { + let tail: Vec<&str> = output.lines().rev().take(20).collect(); + tail.into_iter().rev().collect::>().join("\n") + }) + .unwrap_or_default(); + let output_tail = if output_tail.len() > 2000 { + crate::util::truncate_str(&output_tail, 2000).to_string() + } else { + output_tail + }; + + Bus::global().publish(BusEvent::BackgroundTaskStalled( + crate::bus::BackgroundTaskStalled { + task_id: status.task_id.clone(), + tool_name: status.tool_name.clone(), + display_name: status.display_name.clone(), + session_id: status.session_id.clone(), + stall_wake_seconds, + running_secs, + output_tail, + output_file: output_path.clone(), + // Arming a stall watchdog is an explicit "wake me if + // this goes quiet" request, independent of the task's + // completion-delivery flags. + notify: true, + wake: true, + }, + )); + } + + watchdogs.write().await.remove(&task_id_owned); + }); + + // Replace any existing watchdog for this task. + if let Some(prior) = self + .stall_watchdogs + .write() + .await + .insert(task_id.to_string(), handle) + { + prior.abort(); + } + + Some(stall_wake_seconds) + } + + /// Disarm the stall watchdog for a task, if one is armed. + pub async fn disarm_stall_watchdog(&self, task_id: &str) -> bool { + let removed = self.stall_watchdogs.write().await.remove(task_id); + let disarmed = removed.is_some(); + if let Some(handle) = removed { + handle.abort(); + } + let status_path = self.status_path_for(task_id); + if let Some(mut status) = self.read_status_file(&status_path).await + && status.stall_wake_seconds.is_some() + { + status.stall_wake_seconds = None; + self.write_status_file(&status_path, &status).await; + return true; + } + disarmed + } + /// Update delivery behavior for an existing background task. /// /// This supports retroactively enabling notify/wake after the task was already started. @@ -1146,6 +1324,7 @@ impl BackgroundTaskManager { wake: wake_flag, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }; let event_status = final_status.status.clone(); let event_exit_code = final_status.exit_code; @@ -1270,6 +1449,7 @@ impl BackgroundTaskManager { event_history: prior_status .map(|status| status.event_history) .unwrap_or_default(), + stall_wake_seconds: None, }; push_task_event( &mut final_status, diff --git a/crates/jcode-base/src/background/model.rs b/crates/jcode-base/src/background/model.rs index 2633b1d839..61252bf187 100644 --- a/crates/jcode-base/src/background/model.rs +++ b/crates/jcode-base/src/background/model.rs @@ -80,6 +80,10 @@ pub struct TaskStatusFile { pub progress: Option, #[serde(default)] pub event_history: Vec, + /// Stall watchdog window in seconds, when armed: the agent is woken after + /// this long with no new output bytes and no progress events. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stall_wake_seconds: Option, } fn default_true() -> bool { @@ -201,6 +205,8 @@ pub enum BackgroundTaskWaitReason { Finished, Progress, Checkpoint, + /// The task's stall watchdog fired while this wait was blocked. + Stalled, Timeout, } diff --git a/crates/jcode-base/src/background/tests.rs b/crates/jcode-base/src/background/tests.rs index 34934679d1..79f06f2d2b 100644 --- a/crates/jcode-base/src/background/tests.rs +++ b/crates/jcode-base/src/background/tests.rs @@ -287,6 +287,7 @@ fn running_status_fixture(task_id: &str, session_id: &str) -> TaskStatusFile { wake: false, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, } } @@ -562,3 +563,127 @@ async fn abort_live_tasks_for_reload_keeps_naturally_finished_status() -> Result ); Ok(()) } + +#[tokio::test(start_paused = true)] +async fn stall_watchdog_fires_and_wait_returns_stalled() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + + let info = manager + .spawn_with_notify( + "bash", + Some("quiet task".to_string()), + "session-stall-fire", + false, + false, + |_output_path| async move { + // Hang well past the stall window without producing output. + sleep(Duration::from_secs(3600)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + // Requested window below the minimum is clamped up. + let effective = manager + .arm_stall_watchdog(&info.task_id, 1) + .await + .ok_or_else(|| anyhow!("watchdog should arm on a running task"))?; + assert_eq!(effective, BackgroundTaskManager::MIN_STALL_WAKE_SECONDS); + + let status = manager + .status(&info.task_id) + .await + .ok_or_else(|| anyhow!("status should exist"))?; + assert_eq!(status.stall_wake_seconds, Some(effective)); + + let wait_result = manager + .wait(&info.task_id, Duration::from_secs(600), false) + .await + .ok_or_else(|| anyhow!("task should exist"))?; + assert_eq!(wait_result.reason, BackgroundTaskWaitReason::Stalled); + assert_eq!(wait_result.task.status, BackgroundTaskStatus::Running); + + manager.cancel(&info.task_id).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn stall_watchdog_does_not_fire_for_task_that_finishes() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + let mut bus_rx = Bus::global().subscribe(); + + let info = manager + .spawn_with_notify( + "bash", + None, + "session-stall-no-fire", + false, + false, + |output_path| async move { + sleep(Duration::from_secs(10)).await; + tokio::fs::write(&output_path, "done").await?; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + manager + .arm_stall_watchdog(&info.task_id, 30) + .await + .ok_or_else(|| anyhow!("watchdog should arm on a running task"))?; + + let wait_result = manager + .wait(&info.task_id, Duration::from_secs(600), false) + .await + .ok_or_else(|| anyhow!("task should exist"))?; + assert_eq!(wait_result.reason, BackgroundTaskWaitReason::Finished); + + // Give the watchdog time to notice the terminal status and exit. + sleep(Duration::from_secs(30)).await; + while let Ok(event) = bus_rx.try_recv() { + if let BusEvent::BackgroundTaskStalled(event) = event { + assert_ne!( + event.task_id, info.task_id, + "watchdog must not fire for a task that finished within its window" + ); + } + } + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn disarm_stall_watchdog_clears_state() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + + let info = manager + .spawn_with_notify( + "bash", + None, + "session-stall-disarm", + false, + false, + |_output_path| async move { + sleep(Duration::from_secs(3600)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + manager + .arm_stall_watchdog(&info.task_id, 45) + .await + .ok_or_else(|| anyhow!("watchdog should arm"))?; + assert!(manager.disarm_stall_watchdog(&info.task_id).await); + + let status = manager + .status(&info.task_id) + .await + .ok_or_else(|| anyhow!("status should exist"))?; + assert_eq!(status.stall_wake_seconds, None); + + manager.cancel(&info.task_id).await?; + Ok(()) +} diff --git a/crates/jcode-base/src/bus.rs b/crates/jcode-base/src/bus.rs index 66bda7ed9f..c02a8efef0 100644 --- a/crates/jcode-base/src/bus.rs +++ b/crates/jcode-base/src/bus.rs @@ -3,7 +3,8 @@ use crate::side_panel::SidePanelSnapshot; use crate::todo::TodoItem; pub use jcode_background_types::{ BackgroundTaskCompleted, BackgroundTaskProgress, BackgroundTaskProgressEvent, - BackgroundTaskProgressKind, BackgroundTaskProgressSource, BackgroundTaskStatus, + BackgroundTaskProgressKind, BackgroundTaskProgressSource, BackgroundTaskStalled, + BackgroundTaskStatus, }; pub use jcode_batch_types::{BatchProgress, BatchSubcallProgress, BatchSubcallState}; use serde::{Deserialize, Serialize}; @@ -405,6 +406,8 @@ pub enum BusEvent { BackgroundTaskCompleted(BackgroundTaskCompleted), /// Background task reported progress BackgroundTaskProgress(BackgroundTaskProgressEvent), + /// Background task stall watchdog fired: no output/progress for its window + BackgroundTaskStalled(BackgroundTaskStalled), /// A backgrounded `swarm await_members` watcher reached a terminal result. SwarmAwaitCompleted(SwarmAwaitCompleted), /// Usage report fetched from providers diff --git a/crates/jcode-base/src/message.rs b/crates/jcode-base/src/message.rs index a4d563dd47..f98c232b75 100644 --- a/crates/jcode-base/src/message.rs +++ b/crates/jcode-base/src/message.rs @@ -22,8 +22,9 @@ pub use notifications::{ ParsedBackgroundTaskNotification, ParsedBackgroundTaskProgressNotification, background_task_display_label, background_task_status_notice, format_background_task_notification_markdown, format_background_task_progress_markdown, - format_input_shell_result_markdown, format_model_refresh_progress_markdown, - input_shell_status_notice, parse_background_task_notification_markdown, + format_background_task_stalled_markdown, format_input_shell_result_markdown, + format_model_refresh_progress_markdown, input_shell_status_notice, + parse_background_task_notification_markdown, parse_background_task_progress_notification_markdown, strip_ansi_escape_sequences, }; diff --git a/crates/jcode-base/src/message/notifications.rs b/crates/jcode-base/src/message/notifications.rs index 4dd1135410..7b3a503e43 100644 --- a/crates/jcode-base/src/message/notifications.rs +++ b/crates/jcode-base/src/message/notifications.rs @@ -286,6 +286,31 @@ pub fn format_background_task_progress_markdown(task: &BackgroundTaskProgressEve ) } +pub fn format_background_task_stalled_markdown( + task: &jcode_background_types::BackgroundTaskStalled, +) -> String { + let mut message = format!( + "**Background task stalled** `{}` · {} · no output or progress for {}s (running {:.0}s total)", + task.task_id, + background_task_header_label(&task.tool_name, task.display_name.as_deref()), + task.stall_wake_seconds, + task.running_secs, + ); + + if let Some(tail) = normalize_background_task_preview(&task.output_tail) { + message.push_str(&format!("\n\nLast output:\n```text\n{}\n```", tail)); + } else { + message.push_str("\n\n_No output captured yet._"); + } + + message.push_str(&format!( + "\n\nThe task is still running. Inspect it (`bg action=\"status\" task_id=\"{}\"`, `bg action=\"output\" task_id=\"{}\"`) and decide whether to keep waiting or cancel it (`bg action=\"cancel\" task_id=\"{}\"`). The watchdog re-arms if output resumes.", + task.task_id, task.task_id, task.task_id + )); + + message +} + pub fn format_model_refresh_progress_markdown(detail: &str, percent: Option) -> String { let detail = detail.trim(); let progress = percent diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index c8c645bc15..2e65156494 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -165,6 +165,10 @@ pub(super) fn handle_bus_event( handle_background_task_progress(app, progress); true } + Ok(BusEvent::BackgroundTaskStalled(task)) => { + handle_background_task_stalled(app, task); + true + } Ok(BusEvent::InputShellCompleted(shell)) => { handle_input_shell_completed(app, shell); true @@ -463,6 +467,54 @@ fn handle_background_task_completed(app: &mut App, task: BackgroundTaskCompleted } } +fn handle_background_task_stalled(app: &mut App, task: crate::bus::BackgroundTaskStalled) { + if task.session_id != app.session.id { + return; + } + + let notification = crate::message::format_background_task_stalled_markdown(&task); + app.push_display_message(DisplayMessage::background_task(notification.clone())); + app.set_status_notice(format!( + "Background task stalled · {} · no output for {}s", + crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref() + ), + task.stall_wake_seconds + )); + + if !app.is_processing { + app.add_provider_message(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: notification.clone(), + cache_control: None, + }], + timestamp: Some(chrono::Utc::now()), + tool_duration_ms: None, + }); + app.session.add_message_with_display_role( + Role::User, + vec![ContentBlock::Text { + text: notification, + cache_control: None, + }], + Some(StoredDisplayRole::BackgroundTask), + ); + let _ = app.session.save(); + + if task.wake { + app.pending_turn = true; + app.is_processing = true; + app.status = ProcessingStatus::Sending; + if app.processing_started.is_none() { + app.processing_started = Some(std::time::Instant::now()); + } + app.visible_turn_started = Some(std::time::Instant::now()); + } + } +} + fn handle_background_task_progress(app: &mut App, event: BackgroundTaskProgressEvent) { if event.session_id != app.session.id { return; From 46915ebb1b6f2c8b79f9f7b26925e5edb6af9192 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:52:24 -0700 Subject: [PATCH 041/107] test(todo): cover final response continuation --- crates/jcode-base/src/todo.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index 113a0cd89f..3296abbae3 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -1352,6 +1352,13 @@ mod tests { assert!(is_auto_poke_message( TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE )); + assert!(is_auto_poke_message( + TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE + )); + assert_eq!( + auto_poke_display_summary(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE), + Some("✅ Preparing the final response...") + ); assert!(is_auto_poke_message(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX)); } From 1615782dfe69f61403c219a9eb0b69fef4e48781 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:53:33 -0700 Subject: [PATCH 042/107] test: cover stalled background-task notification formatter --- crates/jcode-base/src/message/tests.rs | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/jcode-base/src/message/tests.rs b/crates/jcode-base/src/message/tests.rs index f9b3741abf..2ea77cbbf6 100644 --- a/crates/jcode-base/src/message/tests.rs +++ b/crates/jcode-base/src/message/tests.rs @@ -853,3 +853,50 @@ fn reasoning_trace_serde_round_trip() { other => panic!("expected ReasoningTrace, got {other:?}"), } } + +#[test] +fn format_background_task_stalled_markdown_renders_tail_and_guidance() { + let rendered = format_background_task_stalled_markdown( + &jcode_background_types::BackgroundTaskStalled { + task_id: "stall01".to_string(), + tool_name: "bash".to_string(), + display_name: Some("long training run".to_string()), + session_id: "session".to_string(), + stall_wake_seconds: 300, + running_secs: 1234.5, + output_tail: "epoch 3/10\nloss 0.42\n".to_string(), + output_file: std::path::PathBuf::from("/tmp/output.log"), + notify: true, + wake: true, + }, + ); + + assert!(rendered.contains( + "**Background task stalled** `stall01` · `long training run` (`bash`) · no output or progress for 300s (running 1234s total)" + )); + assert!(rendered.contains("```text\nepoch 3/10\nloss 0.42\n```")); + assert!(rendered.contains("bg action=\"status\" task_id=\"stall01\"")); + assert!(rendered.contains("bg action=\"cancel\" task_id=\"stall01\"")); + assert!(rendered.contains("re-arms if output resumes")); +} + +#[test] +fn format_background_task_stalled_markdown_handles_empty_tail() { + let rendered = format_background_task_stalled_markdown( + &jcode_background_types::BackgroundTaskStalled { + task_id: "stall02".to_string(), + tool_name: "bash".to_string(), + display_name: None, + session_id: "session".to_string(), + stall_wake_seconds: 60, + running_secs: 61.0, + output_tail: String::new(), + output_file: std::path::PathBuf::from("/tmp/output.log"), + notify: true, + wake: true, + }, + ); + + assert!(rendered.contains("**Background task stalled** `stall02` · `bash` · no output or progress for 60s")); + assert!(rendered.contains("_No output captured yet._")); +} From 6f743f264aafb16281782ea5480438cc52ff05e8 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:50:05 -0700 Subject: [PATCH 043/107] tui: activate copy badges on click --- crates/jcode-tui/src/tui/app/navigation.rs | 23 ++++++ .../tui/app/tests/scroll_copy_02/part_01.rs | 40 +++++++++- crates/jcode-tui/src/tui/ui.rs | 27 ++++++- crates/jcode-tui/src/tui/ui_frame_metrics.rs | 1 + crates/jcode-tui/src/tui/ui_viewport.rs | 75 ++++++++++++++++--- 5 files changed, 155 insertions(+), 11 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/navigation.rs b/crates/jcode-tui/src/tui/app/navigation.rs index d84b1a0ee3..d78cb35dcc 100644 --- a/crates/jcode-tui/src/tui/app/navigation.rs +++ b/crates/jcode-tui/src/tui/app/navigation.rs @@ -1484,6 +1484,15 @@ impl App { self.set_diff_pane_focus(false); } + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && crate::tui::ui::viewport::pinned_todo_more_area().is_some_and(|area| { + super::super::layout_utils::point_in_rect(mouse.column, mouse.row, area) + }) + { + self.pinned_todos_expanded = true; + finish_mouse_event!(false, "pinned_todos_expand"); + } + // A left press in the composer moves the caret first (native text-field // behavior), then falls through so the shared copy-selection machinery // can arm a drag anchor: click repositions the cursor, drag selects the @@ -1648,6 +1657,20 @@ impl App { finish_mouse_event!(false, "toggle_swarm_expand"); } + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && let Some(target) = crate::tui::ui::visible_copy_target_at(mouse.column, mouse.row) + { + let success = super::helpers::copy_to_clipboard(&target.content); + self.record_copy_badge_key_press(target.key); + self.record_copy_badge_feedback(target.key, success); + if success { + self.set_status_notice(target.copied_notice); + } else { + self.set_status_notice(format!("Failed to copy {}", target.kind_label)); + } + finish_mouse_event!(false, "copy_badge_click"); + } + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) && self.try_open_link_at(mouse.column, mouse.row) { diff --git a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs index b81c7b51b1..9b5af9f16f 100644 --- a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs @@ -30,6 +30,45 @@ fn test_local_error_copy_badge_shortcut_supported() { ); } +#[test] +fn test_clicking_copy_badge_copies_its_target() { + let _render_lock = scroll_render_test_lock(); + let clipboard = CapturedClipboard::new(); + let (mut app, mut terminal) = create_error_copy_test_app(); + render_and_snap(&app, &mut terminal); + + let buf = terminal.backend().buffer(); + let area = *buf.area(); + let mut badge = None; + 'rows: for row in 0..area.height { + let line = (0..area.width) + .map(|col| buf[(col, row)].symbol()) + .collect::(); + if let Some(byte) = line.find("[S]") { + badge = Some((line[..byte].chars().count() as u16, row)); + break 'rows; + } + } + let (column, row) = badge.expect("copy badge must be visible"); + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + app.handle_mouse_event(MouseEvent { + kind, + column: column + 1, + row, + modifiers: KeyModifiers::empty(), + }); + } + + assert_eq!(app.status_notice(), Some("Copied error".to_string())); + assert_eq!( + clipboard.text().as_deref(), + Some("permission denied while opening ~/.jcode/config.toml") + ); +} + #[test] fn test_local_tool_error_copy_badge_shortcut_supported() { let _render_lock = scroll_render_test_lock(); @@ -1439,4 +1478,3 @@ fn test_changelog_overlay_mouse_drag_release_copies_text() { Some("Copied selection") | Some("Failed to copy selection") | Some("Selection is empty") )); } - diff --git a/crates/jcode-tui/src/tui/ui.rs b/crates/jcode-tui/src/tui/ui.rs index 9a18ae2164..d2fbf0e8be 100644 --- a/crates/jcode-tui/src/tui/ui.rs +++ b/crates/jcode-tui/src/tui/ui.rs @@ -87,7 +87,7 @@ pub(crate) mod tools_ui; #[path = "ui_transitions.rs"] mod transitions; #[path = "ui_viewport.rs"] -mod viewport; +pub(crate) mod viewport; use crate::tui::mermaid; #[cfg(test)] pub(crate) use box_utils::truncate_line_to_width; @@ -588,6 +588,8 @@ pub(crate) struct VisibleCopyTarget { pub kind_label: String, pub copied_notice: String, pub content: String, + /// Screen cells occupied by the rendered shortcut badge in the latest frame. + pub badge_rect: Option, } // Copy badges intentionally avoid h/j/k/l so they never shadow vi-style @@ -713,6 +715,29 @@ pub(crate) fn visible_copy_target_for_key(key: char) -> Option Option { + let contains = |target: &&VisibleCopyTarget| { + target.badge_rect.is_some_and(|rect| { + column >= rect.x + && column < rect.x.saturating_add(rect.width) + && row >= rect.y + && row < rect.y.saturating_add(rect.height) + }) + }; + #[cfg(test)] + { + TEST_VISIBLE_COPY_TARGETS.with(|state| state.borrow().iter().find(contains).cloned()) + } + #[cfg(not(test))] + { + let state = match visible_copy_targets_state().lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + state.iter().find(contains).cloned() + } +} + #[derive(Clone, Copy)] struct PromptViewportAnimation { line_idx: usize, diff --git a/crates/jcode-tui/src/tui/ui_frame_metrics.rs b/crates/jcode-tui/src/tui/ui_frame_metrics.rs index bb4f4a4882..55554b1db4 100644 --- a/crates/jcode-tui/src/tui/ui_frame_metrics.rs +++ b/crates/jcode-tui/src/tui/ui_frame_metrics.rs @@ -1261,6 +1261,7 @@ pub(crate) fn recent_flicker_copy_target_for_key(key: char) -> Option content_area + .x + .saturating_add(content_area.width.saturating_sub(final_width as u16) / 2), + Alignment::Right => content_area + .x + .saturating_add(content_area.width.saturating_sub(final_width as u16)), + Alignment::Left => content_area.x, + }; + if let Some(target) = visible_copy_targets + .iter_mut() + .find(|target| target.key.eq_ignore_ascii_case(&key)) + { + target.badge_rect = Some(Rect { + x: aligned_x.saturating_add(shortcut_start as u16), + y: content_area.y.saturating_add(rel_idx as u16), + width: final_width.saturating_sub(shortcut_start) as u16, + height: 1, + }); + } } } + set_visible_copy_targets(visible_copy_targets); if let Some(range) = app.copy_selection_range().filter(|range| { range.start.pane == crate::tui::CopySelectionPane::Chat @@ -1314,15 +1353,15 @@ fn pinned_todo_band_lines( app: &dyn TuiState, width: u16, viewport_height: u16, -) -> Vec> { +) -> (Vec>, bool) { if !crate::config::config().display.pin_todos { - return Vec::new(); + return (Vec::new(), false); } let Some(payload) = app.pinned_todos_payload() else { - return Vec::new(); + return (Vec::new(), false); }; if width < 8 || viewport_height < 9 { - return Vec::new(); + return (Vec::new(), false); } let msg = crate::tui::DisplayMessage::todos(payload.to_string()); let card_lines = super::messages::get_cached_message_lines( @@ -1332,13 +1371,14 @@ fn pinned_todo_band_lines( super::messages::render_todos_message, ); if card_lines.is_empty() { - return Vec::new(); + return (Vec::new(), false); } // Band budget: about a third of the viewport. let budget = ((viewport_height as usize) / 3).clamp(2, 12); let content_budget = budget; let mut lines: Vec> = Vec::new(); - if card_lines.len() > content_budget { + let has_more = card_lines.len() > content_budget && !app.pinned_todos_expanded(); + if has_more { let shown = content_budget.saturating_sub(1); let hidden = card_lines.len() - shown; lines.extend(card_lines.into_iter().take(shown)); @@ -1349,7 +1389,24 @@ fn pinned_todo_band_lines( } else { lines.extend(card_lines); } - lines + (lines, has_more) +} + +static PINNED_TODO_MORE_AREA: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn set_pinned_todo_more_area(area: Option) { + if let Ok(mut current) = PINNED_TODO_MORE_AREA.lock() { + *current = area; + } +} + +#[cfg(test)] +pub(crate) fn set_pinned_todo_more_area_for_test(area: Option) { + set_pinned_todo_more_area(area); +} + +pub(crate) fn pinned_todo_more_area() -> Option { + PINNED_TODO_MORE_AREA.lock().ok().and_then(|area| *area) } fn compute_prompt_preview_line_count( From 9afc59c2042bf467f94dee2cb71e9935685fd71c Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:50:57 -0700 Subject: [PATCH 044/107] tui: expand pinned todos on click --- crates/jcode-tui/src/tui/app.rs | 2 ++ .../jcode-tui/src/tui/app/tests/todo_card.rs | 23 +++++++++++++++++++ crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 2 ++ crates/jcode-tui/src/tui/app/tui_state.rs | 4 ++++ crates/jcode-tui/src/tui/mod.rs | 4 ++++ 5 files changed, 35 insertions(+) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 74dc02d93a..e9605d9139 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -1347,6 +1347,8 @@ pub struct App { /// Last time the pinned todo band re-read todos from disk (1s throttle). #[allow(dead_code)] pinned_todos_checked_at: Option, + /// User-expanded state for the pinned todo band's `+N more` row. + pinned_todos_expanded: bool, last_side_panel_refresh: Option, // Most recently persisted focus target for dictation routing. last_client_focus_recorded_at: Option, diff --git a/crates/jcode-tui/src/tui/app/tests/todo_card.rs b/crates/jcode-tui/src/tui/app/tests/todo_card.rs index 5ebb68ebb5..00cca834bc 100644 --- a/crates/jcode-tui/src/tui/app/tests/todo_card.rs +++ b/crates/jcode-tui/src/tui/app/tests/todo_card.rs @@ -405,3 +405,26 @@ fn pinned_todo_band_renders_below_sticky_prompt_without_separator() { let _ = crate::todo::save_todos(&session_id, &[]); } + +#[test] +fn clicking_pinned_todo_more_row_expands_the_band() { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + + let mut app = create_test_app(); + app.pinned_todos_expanded = false; + crate::tui::ui::viewport::set_pinned_todo_more_area_for_test(Some(ratatui::layout::Rect { + x: 2, + y: 4, + width: 20, + height: 1, + })); + + app.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 8, + row: 4, + modifiers: KeyModifiers::NONE, + }); + + assert!(app.pinned_todos_expanded); +} diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index d0f87da4a9..9acad2af3b 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -623,6 +623,7 @@ impl App { pinned_todos_payload: None, pinned_todos_rendered_hash: 0, pinned_todos_checked_at: None, + pinned_todos_expanded: false, last_side_panel_refresh: None, last_client_focus_recorded_at: None, last_client_focus_session_id: None, @@ -1066,6 +1067,7 @@ impl App { pinned_todos_payload: None, pinned_todos_rendered_hash: 0, pinned_todos_checked_at: None, + pinned_todos_expanded: false, last_side_panel_refresh: None, last_client_focus_recorded_at: None, last_client_focus_session_id: None, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 34a723ca27..8ab3c50e4b 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -589,6 +589,10 @@ impl crate::tui::TuiState for App { self.pinned_todos_payload_ref() } + fn pinned_todos_expanded(&self) -> bool { + self.pinned_todos_expanded + } + fn input(&self) -> &str { &self.input } diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index 1152da7118..b6ff4a63dd 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -208,6 +208,10 @@ pub trait TuiState { fn pinned_todos_payload(&self) -> Option<&str> { None } + /// Whether the pinned todo band is temporarily expanded to show every row. + fn pinned_todos_expanded(&self) -> bool { + false + } // ---- Input ---- fn input(&self) -> &str; From f6d5da78004e866df8dce7cbae2f74747325e7af Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:27:45 -0700 Subject: [PATCH 045/107] Expose persisted session titles through SDK --- .../jcode-harness-api-server/src/translate.rs | 74 ++++++++++++++++--- .../src/translate_tests.rs | 46 +++++++++++- crates/jcode-harness-api/src/events.rs | 2 + 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/crates/jcode-harness-api-server/src/translate.rs b/crates/jcode-harness-api-server/src/translate.rs index af513c8502..1caa2a5672 100644 --- a/crates/jcode-harness-api-server/src/translate.rs +++ b/crates/jcode-harness-api-server/src/translate.rs @@ -5,6 +5,7 @@ use crate::background_progress::parse_background_notification; use jcode_harness_api::{ ApiEvent, ErrorCode, HistoryMessage, ModelRouteInfo, ServerFrame, SessionInfo, TextMatch, }; +use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Write}; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -130,6 +131,35 @@ struct ArchiveState { archive_after_days: Option, } +/// The small, canonical subset of a persisted `Session` needed by list and +/// attach responses. Serde skips the heavyweight transcript fields without +/// materializing them. +#[derive(Debug, Default, Deserialize)] +struct PersistedSessionMetadata { + #[serde(default)] + working_dir: Option, + /// Generated or imported title. + #[serde(default)] + title: Option, + /// User-provided rename, which is what `Session::display_title` prefers. + #[serde(default)] + custom_title: Option, +} + +impl PersistedSessionMetadata { + fn display_title(&self) -> Option { + self.custom_title + .as_deref() + .and_then(Self::normalized_title) + .or_else(|| self.title.as_deref().and_then(Self::normalized_title)) + } + + fn normalized_title(title: &str) -> Option { + let title = title.trim(); + (!title.is_empty()).then(|| title.to_string()) + } +} + #[derive(Debug, Clone, PartialEq)] enum SimpleKind { Ping, @@ -415,9 +445,21 @@ impl BridgeState { if let Some(attached) = self.session_id.clone() { ids.insert(attached); } + // Titles are deliberately not cached. A rename is persisted + // before `SessionRenamed` is broadcast, and every list call + // should reflect that newest canonical value even on another + // API connection. + let metadata: BTreeMap = ids + .iter() + .filter_map(|id| { + Self::resolve_session_metadata(id).map(|metadata| (id.clone(), metadata)) + }) + .collect(); for id in &ids { if !self.session_dirs.contains_key(id) - && let Some(dir) = Self::resolve_working_dir(id) + && let Some(dir) = metadata + .get(id) + .and_then(|metadata| metadata.working_dir.clone()) { self.session_dirs.insert(id.clone(), dir); } @@ -449,7 +491,9 @@ impl BridgeState { }) .map(|session_id| SessionInfo { working_dir: self.session_dirs.get(&session_id).cloned(), - title: None, + title: metadata + .get(&session_id) + .and_then(PersistedSessionMetadata::display_title), status: if self.session_id.as_ref() == Some(&session_id) { "attached".into() } else { @@ -743,14 +787,19 @@ impl BridgeState { && state_id == id { self.pending_attach_id = None; + let metadata = Self::resolve_session_metadata(&session_id); return vec![ServerFrame::reply( api_id, ApiEvent::Attached { session: SessionInfo { transcript_bytes: Self::transcript_bytes(&session_id), session_id, - working_dir: None, - title: None, + working_dir: metadata + .as_ref() + .and_then(|metadata| metadata.working_dir.clone()), + title: metadata + .as_ref() + .and_then(PersistedSessionMetadata::display_title), status: if event["is_processing"].as_bool().unwrap_or(false) { "processing".into() } else { @@ -1169,21 +1218,24 @@ impl BridgeState { Some(home.join("sessions").join(format!("{session_id}.json"))) } - /// Working directory of a session, read from its persisted record. + /// Metadata of a session, read from its persisted record. /// /// The legacy `history` event lists session *ids* only, but the strip /// groups by directory, so the bridge resolves them from the same files /// the daemon persists. Best-effort by design: an unreadable or missing /// record simply leaves the session ungrouped rather than failing the /// list, and results are cached because this is on a poll path. - fn resolve_working_dir(session_id: &str) -> Option { + fn resolve_session_metadata(session_id: &str) -> Option { let path = Self::session_record_path(session_id)?; // A missing or malformed record is expected (a session may predate the - // field, or be mid-write), and the only cost is an ungrouped bar, so - // this degrades rather than failing the whole session list. - let text = std::fs::read_to_string(path).ok()?; - let value: Value = serde_json::from_str(&text).ok()?; - value["working_dir"].as_str().map(str::to_string) + // fields, or be mid-write), and the only cost is missing metadata, so + // this degrades rather than failing the whole session list or attach. + let reader = std::io::BufReader::new(std::fs::File::open(path).ok()?); + serde_json::from_reader(reader).ok() + } + + fn resolve_working_dir(session_id: &str) -> Option { + Self::resolve_session_metadata(session_id)?.working_dir } /// Size of a session's stored record, in bytes. diff --git a/crates/jcode-harness-api-server/src/translate_tests.rs b/crates/jcode-harness-api-server/src/translate_tests.rs index dc722b90b3..dc70c7db23 100644 --- a/crates/jcode-harness-api-server/src/translate_tests.rs +++ b/crates/jcode-harness-api-server/src/translate_tests.rs @@ -52,6 +52,16 @@ impl Drop for ScopedJcodeHome { } fn write_session_record(home: &Path, session_id: &str, working_dir: &Path) -> PathBuf { + write_session_record_with_titles(home, session_id, working_dir, None, None) +} + +fn write_session_record_with_titles( + home: &Path, + session_id: &str, + working_dir: &Path, + title: Option<&str>, + custom_title: Option<&str>, +) -> PathBuf { let sessions = home.join("sessions"); std::fs::create_dir_all(&sessions).expect("create sessions directory"); let path = sessions.join(format!("{session_id}.json")); @@ -59,6 +69,8 @@ fn write_session_record(home: &Path, session_id: &str, working_dir: &Path) -> Pa &path, json!({ "working_dir": working_dir, + "title": title, + "custom_title": custom_title, "messages": [{"role": "user", "content": "hello"}], }) .to_string(), @@ -114,6 +126,16 @@ fn create_session_maps_to_subscribe() { #[test] fn state_event_answers_pending_attach() { + let home = ScopedJcodeHome::new("attach-title"); + let project = home.path.join("project"); + std::fs::create_dir_all(&project).unwrap(); + write_session_record_with_titles( + &home.path, + "abc", + &project, + Some("Generated attach title"), + Some("Persisted attach rename"), + ); let mut state = BridgeState::default(); let out = state.api_request_to_legacy(&json!({"req": "create_session", "id": 5})); assert_eq!( @@ -138,7 +160,11 @@ fn state_event_answers_pending_attach() { assert_eq!(frames.len(), 1); assert_eq!(frames[0].reply_to, Some(5)); match &frames[0].event { - ApiEvent::Attached { session } => assert_eq!(session.session_id, "abc"), + ApiEvent::Attached { session } => { + assert_eq!(session.session_id, "abc"); + assert_eq!(session.title.as_deref(), Some("Persisted attach rename")); + assert_eq!(session.working_dir.as_deref(), project.to_str()); + } other => panic!("unexpected: {other:?}"), } assert_eq!(state.session_id.as_deref(), Some("abc")); @@ -1032,8 +1058,20 @@ fn unattached_list_sessions_discovers_all_persisted_records() { let second_root = home.path.join("second-project"); std::fs::create_dir_all(&first_root).unwrap(); std::fs::create_dir_all(&second_root).unwrap(); - write_session_record(&home.path, "persisted_one", &first_root); - write_session_record(&home.path, "persisted_two", &second_root); + write_session_record_with_titles( + &home.path, + "persisted_one", + &first_root, + Some(" Generated first title "), + None, + ); + write_session_record_with_titles( + &home.path, + "persisted_two", + &second_root, + Some("Generated second title"), + Some(" Custom second title "), + ); std::fs::write(home.path.join("sessions/not-a-session.txt"), "ignored").unwrap(); let event = only_reply_event( @@ -1051,6 +1089,8 @@ fn unattached_list_sessions_discovers_all_persisted_records() { ); assert_eq!(sessions[0].working_dir.as_deref(), first_root.to_str()); assert_eq!(sessions[1].working_dir.as_deref(), second_root.to_str()); + assert_eq!(sessions[0].title.as_deref(), Some("Generated first title")); + assert_eq!(sessions[1].title.as_deref(), Some("Custom second title")); } #[test] diff --git a/crates/jcode-harness-api/src/events.rs b/crates/jcode-harness-api/src/events.rs index ee9b2a9adc..a4118258e0 100644 --- a/crates/jcode-harness-api/src/events.rs +++ b/crates/jcode-harness-api/src/events.rs @@ -254,6 +254,8 @@ pub struct SessionInfo { pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub working_dir: Option, + /// The effective persisted display title. A custom rename takes precedence + /// over the generated or imported title. #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, pub status: String, From 7d490d93df9e1150e781eb61619ab76d281b0474 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:29:10 -0700 Subject: [PATCH 046/107] Test session titles through public SDK client --- crates/jcode-sdk/tests/lifecycle_events.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/jcode-sdk/tests/lifecycle_events.rs b/crates/jcode-sdk/tests/lifecycle_events.rs index d4020cabe6..2b08095816 100644 --- a/crates/jcode-sdk/tests/lifecycle_events.rs +++ b/crates/jcode-sdk/tests/lifecycle_events.rs @@ -19,7 +19,7 @@ fn session(id: &str) -> SessionInfo { SessionInfo { session_id: id.to_string(), working_dir: None, - title: None, + title: Some(format!("Title for {id}")), status: "idle".to_string(), transcript_bytes: None, archived: false, @@ -27,6 +27,22 @@ fn session(id: &str) -> SessionInfo { } } +#[test] +fn public_client_exposes_titles_from_list_and_attach() { + let server = UnixHarness::start(0); + let client = server.connect(); + + let sessions = client.list_sessions().expect("list sessions"); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].title.as_deref(), Some("Title for persisted-1")); + assert_eq!(sessions[1].title.as_deref(), Some("Title for persisted-2")); + + let attached = client + .attach_session("persisted-1") + .expect("attach session"); + assert_eq!(attached.title.as_deref(), Some("Title for persisted-1")); +} + struct UnixHarness { _temp: tempfile::TempDir, socket_path: PathBuf, From 84df2ced74442e82a137ac15352f8be941aaccd3 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:46:57 -0700 Subject: [PATCH 047/107] Remove bash output gutter --- crates/jcode-tui/src/tui/ui_messages.rs | 2 +- crates/jcode-tui/src/tui/ui_messages/tests.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/ui_messages.rs b/crates/jcode-tui/src/tui/ui_messages.rs index 6c323e1f9b..137bb74303 100644 --- a/crates/jcode-tui/src/tui/ui_messages.rs +++ b/crates/jcode-tui/src/tui/ui_messages.rs @@ -4181,7 +4181,7 @@ pub(crate) fn render_tool_message( let total = output_lines.clone().count(); for output in output_lines.skip(total.saturating_sub(MAX_COLLAPSED_OUTPUT_LINES)) { let output_line = Line::from(vec![ - Span::styled(" │ ", Style::default().fg(dim_color())), + Span::raw(" "), Span::styled(output.to_string(), Style::default().fg(dim_color())), ]); lines.push(super::truncate_line_with_ellipsis_to_width( diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index 4303b21966..a538e5e1c5 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -2066,6 +2066,13 @@ fn render_tool_message_shows_trimmed_bash_output() { assert!(rendered.iter().any(|line| line.contains(""))); assert!(rendered.iter().any(|line| line.contains("[('p', 'b')"))); + for output_line in &rendered[rendered.len() - 2..] { + assert!(output_line.starts_with(" "), "{output_line:?}"); + assert!( + !output_line.contains('│'), + "bash output should not have a vertical gutter: {output_line:?}" + ); + } } fn gmail_draft_message(content: &str, input: serde_json::Value) -> DisplayMessage { From 5da90ec9722b72c567966e486ac7df65627b8fb1 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:49:21 -0700 Subject: [PATCH 048/107] tui: activate edit expand badge on click --- crates/jcode-tui/src/tui/app/input.rs | 2 +- crates/jcode-tui/src/tui/app/navigation.rs | 7 ++++ .../tui/app/tests/scroll_copy_02/part_02.rs | 38 ++++++++++++++++++ crates/jcode-tui/src/tui/ui.rs | 39 +++++++++++++++++++ crates/jcode-tui/src/tui/ui_viewport.rs | 19 +++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 2f870cdd69..9ac4131d70 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -2488,7 +2488,7 @@ fn handle_inline_image_toggle_shortcut(app: &mut App, key: char) -> bool { true } -fn handle_expand_edit_badge_shortcut(app: &mut App, key: char) -> bool { +pub(super) fn handle_expand_edit_badge_shortcut(app: &mut App, key: char) -> bool { if !key.eq_ignore_ascii_case(&'e') { return false; } diff --git a/crates/jcode-tui/src/tui/app/navigation.rs b/crates/jcode-tui/src/tui/app/navigation.rs index d78cb35dcc..1c71f5b743 100644 --- a/crates/jcode-tui/src/tui/app/navigation.rs +++ b/crates/jcode-tui/src/tui/app/navigation.rs @@ -1657,6 +1657,13 @@ impl App { finish_mouse_event!(false, "toggle_swarm_expand"); } + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && crate::tui::ui::visible_expand_edit_badge_at(mouse.column, mouse.row) + && super::input::handle_expand_edit_badge_shortcut(self, 'e') + { + finish_mouse_event!(false, "expand_edit_badge_click"); + } + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) && let Some(target) = crate::tui::ui::visible_copy_target_at(mouse.column, mouse.row) { diff --git a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_02.rs b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_02.rs index 671031c4ec..68fd8d2456 100644 --- a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_02.rs @@ -370,6 +370,44 @@ fn test_expand_badge_rendered_shortcut_expands_with_alt_lowercase_event() { ); } +#[test] +fn test_clicking_expand_edit_badge_expands_to_full_diff() { + let _render_lock = scroll_render_test_lock(); + let (mut app, mut terminal) = make_edit_badge_test_app(20); + render_and_snap(&app, &mut terminal); + + let buf = terminal.backend().buffer(); + let area = *buf.area(); + let mut badge = None; + 'rows: for row in 0..area.height { + let line = (0..area.width) + .map(|col| buf[(col, row)].symbol()) + .collect::(); + if let Some(byte) = line.find("[E] expand") { + badge = Some((line[..byte].chars().count() as u16, row)); + break 'rows; + } + } + let (column, row) = badge.expect("expand edit badge must be visible"); + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + app.handle_mouse_event(MouseEvent { + kind, + column: column + 1, + row, + modifiers: KeyModifiers::empty(), + }); + } + + assert_eq!(app.diff_mode, crate::config::DiffDisplayMode::FullInline); + assert_eq!( + app.status_notice(), + Some("Expanded edit diffs · Diffs: Inline Full".to_string()) + ); +} + #[test] fn test_expand_badge_shortcut_works_while_diff_pane_focused() { use crossterm::event::{KeyCode, KeyModifiers}; diff --git a/crates/jcode-tui/src/tui/ui.rs b/crates/jcode-tui/src/tui/ui.rs index d2fbf0e8be..30c483bf54 100644 --- a/crates/jcode-tui/src/tui/ui.rs +++ b/crates/jcode-tui/src/tui/ui.rs @@ -240,6 +240,7 @@ thread_local! { static TEST_VISIBLE_COPY_TARGETS: RefCell> = RefCell::new(Vec::new()); static TEST_VISIBLE_EXPAND_EDIT_BADGE: Cell = const { Cell::new(false) }; static TEST_VISIBLE_EXPAND_EDIT_BADGE_LINE: Cell> = const { Cell::new(None) }; + static TEST_VISIBLE_EXPAND_EDIT_BADGE_RECT: Cell> = const { Cell::new(None) }; static TEST_PROMPT_VIEWPORT_STATE: RefCell = RefCell::new(PromptViewportState::default()); static TEST_COPY_VIEWPORT: RefCell = RefCell::new(CopyViewportSnapshots::default()); } @@ -605,6 +606,9 @@ static VISIBLE_EXPAND_EDIT_BADGE: OnceLock> = OnceLock::new(); #[cfg(not(test))] static VISIBLE_EXPAND_EDIT_BADGE_LINE: OnceLock>> = OnceLock::new(); +#[cfg(not(test))] +static VISIBLE_EXPAND_EDIT_BADGE_RECT: OnceLock>> = OnceLock::new(); + #[cfg(not(test))] fn visible_copy_targets_state() -> &'static Mutex> { VISIBLE_COPY_TARGETS.get_or_init(|| Mutex::new(Vec::new())) @@ -620,6 +624,41 @@ fn visible_expand_edit_badge_line_state() -> &'static Mutex> { VISIBLE_EXPAND_EDIT_BADGE_LINE.get_or_init(|| Mutex::new(None)) } +#[cfg(not(test))] +fn visible_expand_edit_badge_rect_state() -> &'static Mutex> { + VISIBLE_EXPAND_EDIT_BADGE_RECT.get_or_init(|| Mutex::new(None)) +} + +pub(crate) fn set_visible_expand_edit_badge_rect(rect: Option) { + #[cfg(test)] + { + TEST_VISIBLE_EXPAND_EDIT_BADGE_RECT.with(|state| state.set(rect)); + return; + } + #[cfg(not(test))] + { + let mut state = visible_expand_edit_badge_rect_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *state = rect; + } +} + +pub(crate) fn visible_expand_edit_badge_at(column: u16, row: u16) -> bool { + #[cfg(test)] + let rect = TEST_VISIBLE_EXPAND_EDIT_BADGE_RECT.with(Cell::get); + #[cfg(not(test))] + let rect = *visible_expand_edit_badge_rect_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + rect.is_some_and(|rect| { + column >= rect.x + && column < rect.x.saturating_add(rect.width) + && row >= rect.y + && row < rect.y.saturating_add(rect.height) + }) +} + pub(crate) fn set_visible_expand_edit_badge(visible: bool, line: Option) { #[cfg(test)] { diff --git a/crates/jcode-tui/src/tui/ui_viewport.rs b/crates/jcode-tui/src/tui/ui_viewport.rs index 5d17ac674a..acfefaa3cc 100644 --- a/crates/jcode-tui/src/tui/ui_viewport.rs +++ b/crates/jcode-tui/src/tui/ui_viewport.rs @@ -730,6 +730,7 @@ pub(super) fn draw_messages( }) .flatten(); super::set_visible_expand_edit_badge(expand_edit_badge_visible, visible_expand_badge_line); + super::set_visible_expand_edit_badge_rect(None); let expand_badge_line = if expand_feedback_active { copy_badge_ui.expand_feedback_line.or_else(|| { @@ -788,6 +789,7 @@ pub(super) fn draw_messages( Style::default().fg(dim_color()) }; + let badge_start = line.width().saturating_add(1); line.spans.push(Span::raw(" ")); line.spans .push(Span::styled(copy_badge_alt_badge(), alt_style)); @@ -801,6 +803,23 @@ pub(super) fn draw_messages( Style::default().fg(dim_color()) }; line.spans.push(Span::styled(badge_text, badge_text_style)); + + let final_width = line.width(); + let aligned_x = match line.alignment.unwrap_or(Alignment::Left) { + Alignment::Center => content_area + .x + .saturating_add(content_area.width.saturating_sub(final_width as u16) / 2), + Alignment::Right => content_area + .x + .saturating_add(content_area.width.saturating_sub(final_width as u16)), + Alignment::Left => content_area.x, + }; + super::set_visible_expand_edit_badge_rect(Some(Rect { + x: aligned_x.saturating_add(badge_start as u16), + y: content_area.y.saturating_add(rel_idx as u16), + width: final_width.saturating_sub(badge_start) as u16, + height: 1, + })); } } } From 376b12d64e1d63868bc10fccf6e98fb7205f8194 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:07:29 -0700 Subject: [PATCH 049/107] Hide bash output by default --- crates/jcode-base/src/config/default_file.rs | 4 ++ crates/jcode-base/src/config/env_overrides.rs | 5 ++ crates/jcode-config-types/src/display.rs | 5 ++ crates/jcode-tui-messages/src/cache.rs | 3 ++ crates/jcode-tui/src/tui/ui_messages.rs | 1 + crates/jcode-tui/src/tui/ui_messages/tests.rs | 49 +++++++++++++------ crates/jcode-tui/src/tui/ui_messages_cache.rs | 1 + crates/jcode-tui/src/tui/ui_tests/tools.rs | 5 +- crates/jcode-tui/src/tui/ui_tools.rs | 27 ++++++++++ 9 files changed, 83 insertions(+), 17 deletions(-) diff --git a/crates/jcode-base/src/config/default_file.rs b/crates/jcode-base/src/config/default_file.rs index 011ccdf1df..0e66952ff8 100644 --- a/crates/jcode-base/src/config/default_file.rs +++ b/crates/jcode-base/src/config/default_file.rs @@ -194,6 +194,10 @@ prompt_entry_animation = true # results directly in the chat. # show_agentgrep_output = false +# Show up to the last three non-empty lines of bash output beneath the tool +# summary (default: false). +# show_bash_output = false + # Show the dimmed technical detail (command, file path, args) next to the # model-provided intent on tool rows (default: false). When false, tool rows # with an intent show just the intent; rows without an intent still show the diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index 3817023bfa..c010439326 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -282,6 +282,11 @@ impl Config { self.display.show_agentgrep_output = parsed; } } + if let Ok(v) = std::env::var("JCODE_SHOW_BASH_OUTPUT") { + if let Some(parsed) = parse_env_bool(&v) { + self.display.show_bash_output = parsed; + } + } if let Ok(v) = std::env::var("JCODE_TOOL_CALL_DETAILS") { if let Some(parsed) = parse_env_bool(&v) { self.display.tool_call_details = parsed; diff --git a/crates/jcode-config-types/src/display.rs b/crates/jcode-config-types/src/display.rs index 5a41e65cf3..0440dc0013 100644 --- a/crates/jcode-config-types/src/display.rs +++ b/crates/jcode-config-types/src/display.rs @@ -79,6 +79,10 @@ pub struct DisplayConfig { /// just the one-line summary (default: false) #[serde(default)] pub show_agentgrep_output: bool, + /// Show up to the last three non-empty bash output lines beneath the tool + /// summary (default: false). + #[serde(default)] + pub show_bash_output: bool, /// Show the dimmed technical detail (command, path, args) after the /// model-provided intent on tool rows (default: false). When off, rows /// that have an intent show only the intent; rows without an intent @@ -152,6 +156,7 @@ impl Default for DisplayConfig { compact_notifications: false, copy_badge_alt_label: String::new(), show_agentgrep_output: false, + show_bash_output: false, tool_call_details: false, native_scrollbars: NativeScrollbarConfig::default(), keybinding_hints: true, diff --git a/crates/jcode-tui-messages/src/cache.rs b/crates/jcode-tui-messages/src/cache.rs index 72e40da2d5..250bb2c446 100644 --- a/crates/jcode-tui-messages/src/cache.rs +++ b/crates/jcode-tui-messages/src/cache.rs @@ -16,6 +16,7 @@ struct MessageCacheKey { mermaid_epoch: u64, mermaid_aspect_bucket: Option, show_agentgrep_output: bool, + show_bash_output: bool, tool_call_details: bool, } @@ -66,6 +67,7 @@ pub struct MessageCacheContext { pub mermaid_epoch: u64, pub mermaid_aspect_bucket: Option, pub show_agentgrep_output: bool, + pub show_bash_output: bool, pub tool_call_details: bool, } @@ -116,6 +118,7 @@ where mermaid_epoch: context.mermaid_epoch, mermaid_aspect_bucket: context.mermaid_aspect_bucket, show_agentgrep_output: context.show_agentgrep_output, + show_bash_output: context.show_bash_output, tool_call_details: context.tool_call_details, }; diff --git a/crates/jcode-tui/src/tui/ui_messages.rs b/crates/jcode-tui/src/tui/ui_messages.rs index 137bb74303..67db1914e3 100644 --- a/crates/jcode-tui/src/tui/ui_messages.rs +++ b/crates/jcode-tui/src/tui/ui_messages.rs @@ -4174,6 +4174,7 @@ pub(crate) fn render_tool_message( } if tools_ui::canonical_tool_name(&tc.name) == "bash" + && tools_ui::show_bash_output() && msg.content.trim() != "Command completed successfully (no output)" { const MAX_COLLAPSED_OUTPUT_LINES: usize = 3; diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index a538e5e1c5..ee293e51dd 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -1941,8 +1941,7 @@ fn render_tool_message_shows_intent_and_technical_preview_on_one_line() { let rendered = extract_line_text(&lines[0]); assert!(rendered.contains("bash · Verify compact progress card · $ cargo test")); - assert_eq!(lines.len(), 2, "only Bash output should add a detail line"); - assert!(extract_line_text(&lines[1]).contains("ok")); + assert_eq!(lines.len(), 1, "Bash output is hidden by default"); crate::tui::ui::tools_ui::tests_tool_call_details_override::set(false); } @@ -1980,8 +1979,7 @@ fn render_tool_message_hides_technical_preview_by_default() { !rendered.contains("cargo test"), "technical detail should be hidden by default: {rendered}" ); - assert_eq!(lines.len(), 2, "only Bash output should add a detail line"); - assert!(extract_line_text(&lines[1]).contains("ok")); + assert_eq!(lines.len(), 1, "Bash output is hidden by default"); } /// Even with details off, a failed tool row keeps its error summary so @@ -2043,7 +2041,7 @@ fn render_tool_message_shows_token_badge() { } #[test] -fn render_tool_message_shows_trimmed_bash_output() { +fn render_tool_message_hides_bash_output() { let msg = DisplayMessage { role: "tool".to_string(), content: "\n[('p', 'b'), ('a', 'a'), ('l', 'l'), ('e', 'e')]".to_string(), @@ -2064,15 +2062,38 @@ fn render_tool_message_shows_trimmed_bash_output() { let lines = render_tool_message(&msg, 120, crate::config::DiffDisplayMode::Off); let rendered = lines.iter().map(extract_line_text).collect::>(); - assert!(rendered.iter().any(|line| line.contains(""))); - assert!(rendered.iter().any(|line| line.contains("[('p', 'b')"))); - for output_line in &rendered[rendered.len() - 2..] { - assert!(output_line.starts_with(" "), "{output_line:?}"); - assert!( - !output_line.contains('│'), - "bash output should not have a vertical gutter: {output_line:?}" - ); - } + assert!(!rendered.iter().any(|line| line.contains(""))); + assert!(!rendered.iter().any(|line| line.contains("[('p', 'b')"))); +} + +#[test] +fn render_tool_message_shows_bash_output_when_enabled() { + crate::tui::ui::tools_ui::tests_show_bash_output_override::set(true); + let msg = DisplayMessage { + role: "tool".to_string(), + content: "one\ntwo\nthree\nfour".to_string(), + tool_calls: Vec::new(), + duration_secs: None, + title: None, + tool_data: Some(crate::message::ToolCall { + id: "call_bash_output_enabled".to_string(), + name: "bash".to_string(), + input: serde_json::json!({"command": "printf output"}), + intent: Some("Print output".to_string()), + thought_signature: None, + }), + }; + + let rendered = render_tool_message(&msg, 120, crate::config::DiffDisplayMode::Off) + .iter() + .map(extract_line_text) + .collect::>(); + + assert_eq!(rendered.len(), 4); + assert!(!rendered.iter().any(|line| line.trim() == "one")); + assert!(rendered.iter().any(|line| line.trim() == "two")); + assert!(rendered.iter().any(|line| line.trim() == "four")); + crate::tui::ui::tools_ui::tests_show_bash_output_override::set(false); } fn gmail_draft_message(content: &str, input: serde_json::Value) -> DisplayMessage { diff --git a/crates/jcode-tui/src/tui/ui_messages_cache.rs b/crates/jcode-tui/src/tui/ui_messages_cache.rs index 0f9565243a..f396b2a432 100644 --- a/crates/jcode-tui/src/tui/ui_messages_cache.rs +++ b/crates/jcode-tui/src/tui/ui_messages_cache.rs @@ -21,6 +21,7 @@ where mermaid_epoch: crate::tui::mermaid::deferred_render_epoch(), mermaid_aspect_bucket: crate::tui::mermaid::current_preferred_aspect_ratio_bucket(), show_agentgrep_output: crate::config::config().display.show_agentgrep_output, + show_bash_output: crate::config::config().display.show_bash_output, tool_call_details: crate::config::config().display.tool_call_details, }, render, diff --git a/crates/jcode-tui/src/tui/ui_tests/tools.rs b/crates/jcode-tui/src/tui/ui_tests/tools.rs index d955033d71..a3f7cf7301 100644 --- a/crates/jcode-tui/src/tui/ui_tests/tools.rs +++ b/crates/jcode-tui/src/tui/ui_tests/tools.rs @@ -1171,8 +1171,8 @@ fn test_render_tool_message_with_intent_never_adds_second_command_line() { assert!(!rendered.is_empty(), "rendered={rendered:?}"); assert_eq!( rendered.len(), - 2, - "Bash output should add one line: {rendered:?}" + 1, + "Bash output is hidden by default: {rendered:?}" ); assert!( rendered @@ -1180,7 +1180,6 @@ fn test_render_tool_message_with_intent_never_adds_second_command_line() { .all(|line| !line.trim_start().starts_with('$')), "rendered={rendered:?}" ); - assert!(rendered[1].contains("ok"), "rendered={rendered:?}"); } #[test] diff --git a/crates/jcode-tui/src/tui/ui_tools.rs b/crates/jcode-tui/src/tui/ui_tools.rs index 868ad41e00..5239e8eb80 100644 --- a/crates/jcode-tui/src/tui/ui_tools.rs +++ b/crates/jcode-tui/src/tui/ui_tools.rs @@ -39,6 +39,33 @@ pub(crate) mod tests_tool_call_details_override { } } +#[cfg(not(test))] +pub(crate) fn show_bash_output() -> bool { + crate::config::config().display.show_bash_output +} + +#[cfg(test)] +pub(crate) fn show_bash_output() -> bool { + tests_show_bash_output_override::get() +} + +#[cfg(test)] +pub(crate) mod tests_show_bash_output_override { + use std::cell::Cell; + + thread_local! { + static SHOW_OUTPUT: Cell = const { Cell::new(false) }; + } + + pub(crate) fn get() -> bool { + SHOW_OUTPUT.with(Cell::get) + } + + pub(crate) fn set(value: bool) { + SHOW_OUTPUT.with(|cell| cell.set(value)); + } +} + fn infer_bg_action_from_intent_for_display(intent: Option<&str>) -> Option<&'static str> { let intent = intent?.trim().to_ascii_lowercase(); if intent.is_empty() { From 7015b21b332ba93afbc849fa22852ceabb5895c7 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:10:21 -0700 Subject: [PATCH 050/107] Keep AGENTS.md prompt stable within sessions --- crates/jcode-app-core/src/agent.rs | 15 ++++++ crates/jcode-app-core/src/agent/prompting.rs | 3 +- crates/jcode-app-core/src/agent/provider.rs | 1 + .../src/agent/turn_execution.rs | 2 + crates/jcode-base/src/prompt.rs | 47 ++++++++++++++++++- crates/jcode-base/src/prompt_tests.rs | 34 ++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index 0f7ea142a7..b872bdecd9 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -233,6 +233,9 @@ pub struct Agent { mcp_late_register_resolved: bool, /// Override system prompt (used by ambient mode to inject a custom prompt) system_prompt_override: Option, + /// AGENTS.md is session bootstrap input. Keep the captured text stable so + /// tool writes do not mutate the provider's cacheable prefix mid-session. + agents_md_snapshot: (Option, crate::prompt::ContextInfo), /// Whether memory features are enabled for this session memory_enabled: bool, /// One-step undo snapshot captured before the most recent rewind. @@ -255,6 +258,15 @@ pub struct Agent { } impl Agent { + fn refresh_agents_md_snapshot(&mut self) { + let working_dir = self + .session + .working_dir + .as_deref() + .map(std::path::Path::new); + self.agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir); + } + fn should_track_client_cache(&self) -> bool { match std::env::var("JCODE_TRACK_CLIENT_CACHE") { Ok(value) => { @@ -273,6 +285,8 @@ impl Agent { disabled_tools: HashSet, ) -> Self { let skills = SkillRegistry::shared_snapshot(); + let working_dir = session.working_dir.as_deref().map(std::path::Path::new); + let agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir); let initial_provider_model = provider.model(); let agent = Self { provider, @@ -299,6 +313,7 @@ impl Agent { locked_tools: None, mcp_late_register_resolved: false, system_prompt_override: None, + agents_md_snapshot, memory_enabled: crate::config::config().features.memory, rewind_undo_snapshot: None, stdin_request_tx: None, diff --git a/crates/jcode-app-core/src/agent/prompting.rs b/crates/jcode-app-core/src/agent/prompting.rs index c96536450c..f4b0f91639 100644 --- a/crates/jcode-app-core/src/agent/prompting.rs +++ b/crates/jcode-app-core/src/agent/prompting.rs @@ -107,12 +107,13 @@ impl Agent { .as_ref() .map(std::path::PathBuf::from); - let (mut split, _context_info) = crate::prompt::build_system_prompt_split( + let (mut split, _context_info) = crate::prompt::build_system_prompt_split_with_agents_md( skill_prompt.as_deref(), &available_skills, self.session.is_canary, memory_prompt, working_dir.as_deref(), + self.agents_md_snapshot.clone(), ); self.append_current_turn_system_reminder(&mut split); diff --git a/crates/jcode-app-core/src/agent/provider.rs b/crates/jcode-app-core/src/agent/provider.rs index 7f5aea34b8..d2c54a6447 100644 --- a/crates/jcode-app-core/src/agent/provider.rs +++ b/crates/jcode-app-core/src/agent/provider.rs @@ -242,6 +242,7 @@ impl Agent { return; } self.session.working_dir = Some(dir.to_string()); + self.refresh_agents_md_snapshot(); self.session.refresh_initial_session_context_message(); self.log_env_snapshot("working_dir"); } diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs index 0c3168a32f..1f0dd756c7 100644 --- a/crates/jcode-app-core/src/agent/turn_execution.rs +++ b/crates/jcode-app-core/src/agent/turn_execution.rs @@ -210,6 +210,7 @@ impl Agent { new_session.ensure_initial_session_context_message(); self.session = new_session; + self.refresh_agents_md_snapshot(); self.reconcile_explicit_provider_pin_route(); self.reset_runtime_state_for_session_change(); self.provider_session_id = None; @@ -627,6 +628,7 @@ impl Agent { // Restore provider_session_id for Claude CLI session resume self.provider_session_id = session.provider_session_id.clone(); self.session = session; + self.refresh_agents_md_snapshot(); crate::tool::clear_session_tool_policy(&previous_session_id); crate::tool::set_session_tool_policy( &self.session.id, diff --git a/crates/jcode-base/src/prompt.rs b/crates/jcode-base/src/prompt.rs index 9a390751ab..a032b4418a 100644 --- a/crates/jcode-base/src/prompt.rs +++ b/crates/jcode-base/src/prompt.rs @@ -530,6 +530,51 @@ pub fn build_system_prompt_split_with_capabilities( memory_prompt: Option<&str>, working_dir: Option<&Path>, capabilities: PromptCapabilities, +) -> (SplitSystemPrompt, ContextInfo) { + let agents_md = load_agents_md_files_from_dir(working_dir); + build_system_prompt_split_with_capabilities_and_agents_md( + skill_prompt, + available_skills, + is_selfdev, + memory_prompt, + working_dir, + capabilities, + agents_md, + ) +} + +/// Build a split prompt using an already captured AGENTS.md snapshot. +/// +/// Long-lived agents use this to keep their provider-cache prefix stable when a +/// tool edits AGENTS.md during the session. New sessions still capture the +/// latest instructions. +pub fn build_system_prompt_split_with_agents_md( + skill_prompt: Option<&str>, + available_skills: &[SkillInfo], + is_selfdev: bool, + memory_prompt: Option<&str>, + working_dir: Option<&Path>, + agents_md: (Option, ContextInfo), +) -> (SplitSystemPrompt, ContextInfo) { + build_system_prompt_split_with_capabilities_and_agents_md( + skill_prompt, + available_skills, + is_selfdev, + memory_prompt, + working_dir, + PromptCapabilities::current(), + agents_md, + ) +} + +fn build_system_prompt_split_with_capabilities_and_agents_md( + skill_prompt: Option<&str>, + available_skills: &[SkillInfo], + is_selfdev: bool, + memory_prompt: Option<&str>, + working_dir: Option<&Path>, + capabilities: PromptCapabilities, + agents_md: (Option, ContextInfo), ) -> (SplitSystemPrompt, ContextInfo) { let mut static_parts = base_system_prompt_parts(capabilities, working_dir); let mut dynamic_parts = Vec::new(); @@ -549,7 +594,7 @@ pub fn build_system_prompt_split_with_capabilities( } // Add AGENTS.md instructions (static per project) - let (md_content, md_info) = load_agents_md_files_from_dir(working_dir); + let (md_content, md_info) = agents_md; if let Some(content) = md_content { static_parts.push(content); } diff --git a/crates/jcode-base/src/prompt_tests.rs b/crates/jcode-base/src/prompt_tests.rs index 3e962a6fcc..32b2a9b971 100644 --- a/crates/jcode-base/src/prompt_tests.rs +++ b/crates/jcode-base/src/prompt_tests.rs @@ -196,6 +196,40 @@ fn agents_md_distinct_project_and_global_files_are_both_loaded() { assert!(content.contains("global instructions")); } +#[test] +fn captured_agents_md_keeps_split_prompt_stable_after_file_write() { + let project_dir = tempfile::TempDir::new().unwrap(); + let agents_md = project_dir.path().join("AGENTS.md"); + std::fs::write(&agents_md, "original session instructions").unwrap(); + let snapshot = load_agents_md_files_from_dirs(project_dir.path(), None); + + let (before, _) = build_system_prompt_split_with_agents_md( + None, + &[], + false, + None, + Some(project_dir.path()), + snapshot.clone(), + ); + std::fs::write(&agents_md, "instructions written during the session").unwrap(); + let (after, _) = build_system_prompt_split_with_agents_md( + None, + &[], + false, + None, + Some(project_dir.path()), + snapshot, + ); + + assert_eq!(before.static_part, after.static_part); + assert!(after.static_part.contains("original session instructions")); + assert!( + !after + .static_part + .contains("instructions written during the session") + ); +} + #[test] fn agents_md_missing_global_file_keeps_project_instructions() { let project_dir = tempfile::TempDir::new().unwrap(); From 468efb56bd67f0270de69fed3268e5a739d741ce Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:20:15 -0700 Subject: [PATCH 051/107] fix(tui): search late session transcript content --- .../src/tui/session_picker/loading.rs | 86 ++++++++++++++----- .../src/tui/session_picker/loading_tests.rs | 59 +++++++++++++ 2 files changed, 123 insertions(+), 22 deletions(-) diff --git a/crates/jcode-tui/src/tui/session_picker/loading.rs b/crates/jcode-tui/src/tui/session_picker/loading.rs index 4b11c8fe70..93c41c2e7b 100644 --- a/crates/jcode-tui/src/tui/session_picker/loading.rs +++ b/crates/jcode-tui/src/tui/session_picker/loading.rs @@ -58,7 +58,7 @@ fn include_old_saved_sessions_on_initial_load() -> bool { } const SESSION_LIST_CACHE_TTL: Duration = Duration::from_secs(5); -const SESSION_LIST_DISK_CACHE_VERSION: u32 = 1; +const SESSION_LIST_DISK_CACHE_VERSION: u32 = 2; const SESSION_LIST_DISK_CACHE_MAX_AGE_SECONDS: i64 = 7 * 24 * 60 * 60; const SAVED_METADATA_TAIL_SCAN_BYTES: u64 = 64 * 1024; const INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES: usize = 64 * 1024; @@ -178,7 +178,7 @@ pub fn invalidate_session_list_cache() { } fn session_list_disk_cache_path() -> Result { - Ok(storage::jcode_dir()?.join("cache/session-picker-list-v1.json")) + Ok(storage::jcode_dir()?.join("cache/session-picker-list-v2.json")) } fn session_list_disk_cache_is_usable( @@ -259,6 +259,53 @@ fn push_with_byte_budget(dst: &mut String, src: &str, budget: &mut usize) { *budget = budget.saturating_sub(end); } +fn suffix_at_most(value: &str, max_bytes: usize) -> &str { + let mut start = value.len().saturating_sub(max_bytes); + while start < value.len() && !value.is_char_boundary(start) { + start += 1; + } + &value[start..] +} + +/// Keep a bounded sample from both ends of a growing transcript. Keeping only +/// the first 64 KiB made `/resume` search silently blind to every later turn in +/// a long session. The first half retains titles and early prompts while the +/// second half continuously follows the newest transcript content. +fn push_sampled_search_text(dst: &mut String, src: &str, limit: usize) { + if src.is_empty() || limit == 0 { + return; + } + if dst.len().saturating_add(1).saturating_add(src.len()) <= limit { + dst.push(' '); + dst.push_str(src); + return; + } + + let head_budget = limit / 2; + let mut head_end = dst.len().min(head_budget); + while head_end > 0 && !dst.is_char_boundary(head_end) { + head_end -= 1; + } + let head = dst[..head_end].to_string(); + + let tail_budget = limit.saturating_sub(head.len()); + let tail = if src.len().saturating_add(1) >= tail_budget { + suffix_at_most(src, tail_budget).to_string() + } else { + let old_budget = tail_budget.saturating_sub(src.len() + 1); + let old_tail = suffix_at_most(&dst[head_end..], old_budget); + let mut tail = String::with_capacity(old_tail.len() + 1 + src.len()); + tail.push_str(old_tail); + tail.push(' '); + tail.push_str(src); + tail + }; + + dst.clear(); + dst.push_str(&head); + dst.push_str(&tail); +} + pub(super) fn build_search_index( id: &str, short_name: &str, @@ -300,22 +347,14 @@ pub(super) fn build_search_index( combined.to_lowercase() } -fn push_raw_search_excerpt(dst: &mut String, raw: &str, budget: &mut usize) { - if *budget == 0 || raw.is_empty() { - return; - } - dst.push(' '); - push_with_byte_budget(dst, raw, budget); -} - fn raw_value_search_excerpt(raw: &RawValue, budget: usize) -> Option { if budget == 0 { return None; } let raw = raw.get(); - let mut budget = budget.min(MESSAGE_SEARCH_EXCERPT_BYTES); + let budget = budget.min(MESSAGE_SEARCH_EXCERPT_BYTES); let mut excerpt = String::new(); - push_with_byte_budget(&mut excerpt, raw, &mut budget); + push_sampled_search_text(&mut excerpt, raw, budget); (!excerpt.is_empty()).then_some(excerpt) } @@ -1225,10 +1264,12 @@ impl SessionMessageSummaryData { .estimated_tokens .saturating_add(usage.total_tokens() as usize); } - let mut remaining = - INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES.saturating_sub(self.search_text.len()); if let Some(raw_content) = message.content_raw.as_deref() { - push_raw_search_excerpt(&mut self.search_text, raw_content, &mut remaining); + push_sampled_search_text( + &mut self.search_text, + raw_content, + INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES, + ); } } @@ -1246,9 +1287,11 @@ impl SessionMessageSummaryData { if self.first_user_prompt.is_none() { self.first_user_prompt = other.first_user_prompt; } - let mut remaining = - INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES.saturating_sub(self.search_text.len()); - push_raw_search_excerpt(&mut self.search_text, &other.search_text, &mut remaining); + push_sampled_search_text( + &mut self.search_text, + &other.search_text, + INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES, + ); } } @@ -1276,11 +1319,10 @@ impl<'de> Visitor<'de> for SessionMessageSummaryDataVisitor { { let mut counts = SessionMessageSummaryData::default(); loop { - let remaining = INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES - .saturating_sub(counts.search_text.len()) - .min(MESSAGE_SEARCH_EXCERPT_BYTES); let Some(message) = seq.next_element_seed(SessionMessageSummarySeed { - content_excerpt_budget: remaining, + // Keep sampling each turn even after the session-wide index is + // full. `add_message` retains a bounded head + moving tail. + content_excerpt_budget: MESSAGE_SEARCH_EXCERPT_BYTES, })? else { break; diff --git a/crates/jcode-tui/src/tui/session_picker/loading_tests.rs b/crates/jcode-tui/src/tui/session_picker/loading_tests.rs index 791a83d9ab..a77215532c 100644 --- a/crates/jcode-tui/src/tui/session_picker/loading_tests.rs +++ b/crates/jcode-tui/src/tui/session_picker/loading_tests.rs @@ -815,6 +815,65 @@ fn session_matches_query_searches_jcode_transcript_contents() { assert!(!session_matches_query(loaded, "missing transcript phrase")); } +#[test] +fn jcode_search_index_keeps_late_turns_after_reaching_its_budget() { + let _env_lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("temp dir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + invalidate_session_list_cache(); + + let mut session = Session::create_with_id( + "session_late_transcript_search".to_string(), + Some("/tmp/transcript-search".to_string()), + Some("Late Transcript Search".to_string()), + ); + for index in 0..12 { + let text = if index == 11 { + format!("{} final-turn-platypus", "x".repeat(7_500)) + } else { + format!("turn-{index} {}", "x".repeat(7_500)) + }; + session.append_stored_message(crate::session::StoredMessage { + id: format!("msg{index}"), + role: crate::message::Role::User, + content: vec![crate::message::ContentBlock::Text { + text, + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }); + } + session.save().expect("save session"); + + let sessions = load_sessions().expect("load sessions"); + let loaded = sessions + .iter() + .find(|candidate| candidate.id == "session_late_transcript_search") + .expect("session present"); + assert!(loaded.search_index.len() <= INITIAL_TRANSCRIPT_SEARCH_BUDGET_BYTES + 256); + assert!(session_matches_picker_query(loaded, "final-turn-platypus")); + invalidate_session_list_cache(); +} + +#[test] +fn raw_search_excerpt_samples_suffix_of_one_long_message_without_splitting_utf8() { + let suffix = "晚い-message-needle"; + let raw: Box = serde_json::from_str(&format!( + "{}", + serde_json::to_string(&format!("{} {suffix}", "─".repeat(6_000))) + .expect("serialize content") + )) + .expect("raw value"); + + let excerpt = + raw_value_search_excerpt(&raw, MESSAGE_SEARCH_EXCERPT_BYTES).expect("search excerpt"); + assert!(excerpt.contains(suffix)); + assert!(excerpt.len() <= MESSAGE_SEARCH_EXCERPT_BYTES); +} + #[test] fn session_matches_query_searches_external_codex_transcript_contents() { let _env_lock = crate::storage::lock_test_env(); From ebb9e1fddc184522ab88459db278b0fa9a0b65cc Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:20:52 -0700 Subject: [PATCH 052/107] Test fresh AGENTS.md session snapshots --- crates/jcode-base/src/prompt_tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/jcode-base/src/prompt_tests.rs b/crates/jcode-base/src/prompt_tests.rs index 32b2a9b971..f628a5432f 100644 --- a/crates/jcode-base/src/prompt_tests.rs +++ b/crates/jcode-base/src/prompt_tests.rs @@ -228,6 +228,24 @@ fn captured_agents_md_keeps_split_prompt_stable_after_file_write() { .static_part .contains("instructions written during the session") ); + + // A new session/workspace boundary captures a fresh snapshot rather than + // pinning the old instructions forever. + let fresh_snapshot = load_agents_md_files_from_dirs(project_dir.path(), None); + let (next_session, _) = build_system_prompt_split_with_agents_md( + None, + &[], + false, + None, + Some(project_dir.path()), + fresh_snapshot, + ); + assert!( + next_session + .static_part + .contains("instructions written during the session") + ); + assert_ne!(before.static_part, next_session.static_part); } #[test] From 502739ca81f5757bcc3985b0629398e8cd8c8bb8 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:24:21 -0700 Subject: [PATCH 053/107] fix(tui): preserve long message search prefix --- crates/jcode-tui/src/tui/session_picker/loading.rs | 13 +++++++++++++ .../src/tui/session_picker/loading_tests.rs | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/session_picker/loading.rs b/crates/jcode-tui/src/tui/session_picker/loading.rs index 93c41c2e7b..2e6993ce01 100644 --- a/crates/jcode-tui/src/tui/session_picker/loading.rs +++ b/crates/jcode-tui/src/tui/session_picker/loading.rs @@ -281,6 +281,19 @@ fn push_sampled_search_text(dst: &mut String, src: &str, limit: usize) { return; } + // An oversized first message has no existing session head to preserve. Keep + // both ends of that message instead of retaining only its suffix. + if dst.is_empty() { + let head_budget = limit / 2; + let mut head_end = src.len().min(head_budget); + while head_end > 0 && !src.is_char_boundary(head_end) { + head_end -= 1; + } + dst.push_str(&src[..head_end]); + dst.push_str(suffix_at_most(src, limit.saturating_sub(head_end))); + return; + } + let head_budget = limit / 2; let mut head_end = dst.len().min(head_budget); while head_end > 0 && !dst.is_char_boundary(head_end) { diff --git a/crates/jcode-tui/src/tui/session_picker/loading_tests.rs b/crates/jcode-tui/src/tui/session_picker/loading_tests.rs index a77215532c..96b7a98516 100644 --- a/crates/jcode-tui/src/tui/session_picker/loading_tests.rs +++ b/crates/jcode-tui/src/tui/session_picker/loading_tests.rs @@ -860,16 +860,18 @@ fn jcode_search_index_keeps_late_turns_after_reaching_its_budget() { #[test] fn raw_search_excerpt_samples_suffix_of_one_long_message_without_splitting_utf8() { + let prefix = "opening-message-needle"; let suffix = "晚い-message-needle"; let raw: Box = serde_json::from_str(&format!( "{}", - serde_json::to_string(&format!("{} {suffix}", "─".repeat(6_000))) + serde_json::to_string(&format!("{prefix} {} {suffix}", "─".repeat(6_000))) .expect("serialize content") )) .expect("raw value"); let excerpt = raw_value_search_excerpt(&raw, MESSAGE_SEARCH_EXCERPT_BYTES).expect("search excerpt"); + assert!(excerpt.contains(prefix)); assert!(excerpt.contains(suffix)); assert!(excerpt.len() <= MESSAGE_SEARCH_EXCERPT_BYTES); } From bc1d99536a755b2dfab730d671353948c3379a04 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:35:45 -0700 Subject: [PATCH 054/107] feat(api): expose reasoning effort on ModelInfo and RuntimeInfo The daemon already tracks reasoning effort per session but never broadcast it over the harness API, so clients could set it yet not show it. The translator now captures effort from the legacy history/status payload and reasoning_effort_changed events, and relays it as an optional field on model_info and runtime_info. Rust and TypeScript SDKs carry the field through; serde defaults keep old clients compatible. --- .../jcode-harness-api-server/src/translate.rs | 19 +++++++++++++++++++ .../src/translate_tests.rs | 3 +++ crates/jcode-harness-api/src/events.rs | 6 ++++++ crates/jcode-sdk/src/client.rs | 4 ++++ crates/jcode-sdk/tests/client_behavior.rs | 1 + sdk/typescript/src/protocol.ts | 9 ++++++++- 6 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/jcode-harness-api-server/src/translate.rs b/crates/jcode-harness-api-server/src/translate.rs index 1caa2a5672..38e2958014 100644 --- a/crates/jcode-harness-api-server/src/translate.rs +++ b/crates/jcode-harness-api-server/src/translate.rs @@ -122,6 +122,9 @@ pub struct BridgeState { /// a picker can mark the active entry. current_model: Option, current_provider: Option, + /// Reasoning effort last reported by the daemon, so identity events can + /// carry it without a round trip. + current_effort: Option, available_routes: Vec, } @@ -540,6 +543,7 @@ impl BridgeState { session_id: self.session_id.clone().unwrap_or_default(), provider: self.current_provider.clone(), model: self.current_model.clone(), + reasoning_effort: self.current_effort.clone(), routes: self.available_routes.clone(), }, ))], @@ -964,6 +968,7 @@ impl BridgeState { session_id: session(self), provider: event["provider_name"].as_str().map(str::to_string), model: event["model"].as_str().map(str::to_string), + reasoning_effort: self.current_effort.clone(), }; // Both a reply and a broadcast: the caller needs its request // resolved, and every other client watching the session needs @@ -978,6 +983,13 @@ impl BridgeState { } "reasoning_effort_changed" => { let id = event["id"].as_u64().unwrap_or(0); + // Remember the new effort even when the change was requested by + // another client, so later identity events stay truthful. + if event["error"].as_str().is_none() + && let Some(effort) = event["effort"].as_str() + { + self.current_effort = Some(effort.to_string()); + } let Some(api_id) = self.take_simple(id, SimpleKind::ReasoningEffort) else { return vec![]; }; @@ -1149,6 +1161,9 @@ impl BridgeState { if let Some(provider) = event["provider_name"].as_str() { self.current_provider = Some(provider.to_string()); } + if let Some(effort) = event["reasoning_effort"].as_str() { + self.current_effort = Some(effort.to_string()); + } if let Some(routes) = event["available_model_routes"].as_array() { self.available_routes = routes .iter() @@ -1170,6 +1185,10 @@ impl BridgeState { session_id, provider: event["provider_name"].as_str().map(str::to_string), model: event["provider_model"].as_str().map(str::to_string), + reasoning_effort: event["reasoning_effort"] + .as_str() + .map(str::to_string) + .or_else(|| self.current_effort.clone()), } } diff --git a/crates/jcode-harness-api-server/src/translate_tests.rs b/crates/jcode-harness-api-server/src/translate_tests.rs index dc70c7db23..adef5cfe28 100644 --- a/crates/jcode-harness-api-server/src/translate_tests.rs +++ b/crates/jcode-harness-api-server/src/translate_tests.rs @@ -1100,6 +1100,7 @@ fn runtime_info_reports_the_active_provider_and_complete_route_catalog() { "type": "available_models_updated", "provider_name": "anthropic", "provider_model": "claude-sonnet", + "reasoning_effort": "high", "available_models": ["claude-sonnet", "gemini-pro"], "available_model_routes": [ { @@ -1128,6 +1129,7 @@ fn runtime_info_reports_the_active_provider_and_complete_route_catalog() { session_id, provider, model, + reasoning_effort, routes, } = event else { @@ -1136,6 +1138,7 @@ fn runtime_info_reports_the_active_provider_and_complete_route_catalog() { assert_eq!(session_id, "s1"); assert_eq!(provider.as_deref(), Some("anthropic")); assert_eq!(model.as_deref(), Some("claude-sonnet")); + assert_eq!(reasoning_effort.as_deref(), Some("high")); assert_eq!(routes.len(), 2); assert_eq!(routes[1].provider, "gemini"); assert!(!routes[1].available); diff --git a/crates/jcode-harness-api/src/events.rs b/crates/jcode-harness-api/src/events.rs index a4118258e0..fe43a4dba3 100644 --- a/crates/jcode-harness-api/src/events.rs +++ b/crates/jcode-harness-api/src/events.rs @@ -154,6 +154,9 @@ pub enum ApiEvent { /// Model id, e.g. `claude-sonnet-4-20250514`. #[serde(default, skip_serializing_if = "Option::is_none")] model: Option, + /// Reasoning effort, e.g. `high`, for providers that expose it. + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, }, /// Reply to `ListModels`: the models this session can switch to. @@ -173,6 +176,9 @@ pub enum ApiEvent { provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] model: Option, + /// Reasoning effort, e.g. `high`, for providers that expose it. + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, routes: Vec, }, diff --git a/crates/jcode-sdk/src/client.rs b/crates/jcode-sdk/src/client.rs index e1ca5d278b..1f6d7afe0b 100644 --- a/crates/jcode-sdk/src/client.rs +++ b/crates/jcode-sdk/src/client.rs @@ -780,6 +780,7 @@ impl JcodeClient { session_id, provider, model, + reasoning_effort, routes, } => { let mut providers = Vec::new(); @@ -799,6 +800,7 @@ impl JcodeClient { session_id, provider, model, + reasoning_effort, providers, routes, }) @@ -1084,6 +1086,8 @@ pub struct RuntimeInfo { pub session_id: String, pub provider: Option, pub model: Option, + /// Reasoning effort, e.g. `high`, when the provider exposes it. + pub reasoning_effort: Option, pub providers: Vec, pub routes: Vec, } diff --git a/crates/jcode-sdk/tests/client_behavior.rs b/crates/jcode-sdk/tests/client_behavior.rs index 0f34509316..b88ab8229d 100644 --- a/crates/jcode-sdk/tests/client_behavior.rs +++ b/crates/jcode-sdk/tests/client_behavior.rs @@ -148,6 +148,7 @@ fn ga_runtime_and_file_methods_map_requests_and_typed_replies() { session_id: "s1".to_string(), provider: Some("anthropic".to_string()), model: Some("claude".to_string()), + reasoning_effort: Some("high".to_string()), routes: reply_routes.clone(), }, ApiRequest::SetApiKey { provider, .. } => ApiEvent::CredentialUpdated { diff --git a/sdk/typescript/src/protocol.ts b/sdk/typescript/src/protocol.ts index 7358b72761..830daf473d 100644 --- a/sdk/typescript/src/protocol.ts +++ b/sdk/typescript/src/protocol.ts @@ -152,13 +152,20 @@ export type ApiEvent = } | { ev: "session_status"; session_id: string; status: string } | { ev: "connection_phase"; session_id: string; phase: string } - | { ev: "model_info"; session_id: string; provider?: string; model?: string } + | { + ev: "model_info"; + session_id: string; + provider?: string; + model?: string; + reasoning_effort?: string; + } | { ev: "models"; session_id: string; models: string[]; current?: string } | { ev: "runtime_info"; session_id: string; provider?: string; model?: string; + reasoning_effort?: string; routes: ModelRouteInfo[]; } | { ev: "credential_updated"; provider: string; configured: boolean } From f76c9a573861112b2ebe25cd9cecd3c2f2659182 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:39:00 -0700 Subject: [PATCH 055/107] feat(api): broadcast effort changes as model_info A reasoning effort change made by one client (or the TUI) now reaches every attached API client as a model_info broadcast, mirroring model_changed, so identity footers cannot go stale. Unchanged effort is not re-broadcast. --- .../jcode-harness-api-server/src/translate.rs | 29 ++++++++--- .../src/translate_tests.rs | 48 +++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/jcode-harness-api-server/src/translate.rs b/crates/jcode-harness-api-server/src/translate.rs index 38e2958014..6f1295b6b6 100644 --- a/crates/jcode-harness-api-server/src/translate.rs +++ b/crates/jcode-harness-api-server/src/translate.rs @@ -985,13 +985,26 @@ impl BridgeState { let id = event["id"].as_u64().unwrap_or(0); // Remember the new effort even when the change was requested by // another client, so later identity events stay truthful. - if event["error"].as_str().is_none() - && let Some(effort) = event["effort"].as_str() - { - self.current_effort = Some(effort.to_string()); - } + let changed = event["error"].as_str().is_none() + && event["effort"].as_str().is_some_and(|effort| { + let effort = Some(effort.to_string()); + let moved = self.current_effort != effort; + self.current_effort = effort; + moved + }); + // A successful change is also broadcast as identity, mirroring + // model_changed: every attached client needs to know the + // effort moved under it, not only the one that asked. + let info = changed.then(|| { + ServerFrame::event(ApiEvent::ModelInfo { + session_id: session(self), + provider: self.current_provider.clone(), + model: self.current_model.clone(), + reasoning_effort: self.current_effort.clone(), + }) + }); let Some(api_id) = self.take_simple(id, SimpleKind::ReasoningEffort) else { - return vec![]; + return info.into_iter().collect(); }; match event["error"].as_str() { Some(error) => vec![ServerFrame::reply( @@ -1001,7 +1014,9 @@ impl BridgeState { message: error.to_string(), }, )], - None => vec![ServerFrame::reply(api_id, ApiEvent::Ok)], + None => std::iter::once(ServerFrame::reply(api_id, ApiEvent::Ok)) + .chain(info) + .collect(), } } // Compaction is scheduled, not performed inline, and the daemon diff --git a/crates/jcode-harness-api-server/src/translate_tests.rs b/crates/jcode-harness-api-server/src/translate_tests.rs index adef5cfe28..f6abf8f57e 100644 --- a/crates/jcode-harness-api-server/src/translate_tests.rs +++ b/crates/jcode-harness-api-server/src/translate_tests.rs @@ -881,6 +881,54 @@ fn reasoning_effort_reports_provider_refusal() { assert!(matches!(frames[0].event, ApiEvent::Error { .. })); } +/// An effort change is identity, like a model change: every attached client +/// needs to hear it, not only the requester. A change made by another client +/// (no pending request here) must still arrive as a `model_info` broadcast, +/// and the requester's own change gets the broadcast after its `Ok`. +#[test] +fn reasoning_effort_changes_are_broadcast_as_model_info() { + let mut state = state_with_session(); + + // Unsolicited change (another client's request id): broadcast only. + let frames = state.legacy_event_to_api(&json!({ + "type": "reasoning_effort_changed", "id": 999, "effort": "high", + })); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].reply_to, None); + match &frames[0].event { + ApiEvent::ModelInfo { + reasoning_effort, .. + } => assert_eq!(reasoning_effort.as_deref(), Some("high")), + other => panic!("expected model_info, got {other:?}"), + } + + // The same effort again is not news: no broadcast. + let frames = state.legacy_event_to_api(&json!({ + "type": "reasoning_effort_changed", "id": 999, "effort": "high", + })); + assert!(frames.is_empty(), "unchanged effort must not re-broadcast"); + + // This client's own change: Ok reply first, then the broadcast. + let out = state.api_request_to_legacy(&json!({ + "id": 7, "req": "set_reasoning_effort", "effort": "low", + })); + let legacy_id = match &out[0] { + Outbound::Legacy(value) => value["id"].as_u64().unwrap(), + _ => unreachable!(), + }; + let frames = state.legacy_event_to_api(&json!({ + "type": "reasoning_effort_changed", "id": legacy_id, "effort": "low", + })); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].reply_to, Some(7)); + assert!(matches!(frames[0].event, ApiEvent::Ok)); + assert!(matches!( + &frames[1].event, + ApiEvent::ModelInfo { reasoning_effort, .. } + if reasoning_effort.as_deref() == Some("low") + )); +} + /// Compaction can be refused (nothing to compact, a turn in flight) and the /// daemon says so with `success: false`, not an error frame. Telling the /// client "done" would claim work that never happened. From 3a0745de14876bb1b47189039a29bf67cf51337b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:12:48 -0700 Subject: [PATCH 056/107] Implement native Cursor and Grok Build auth --- Cargo.lock | 56 +++++++ crates/jcode-base/Cargo.toml | 1 + crates/jcode-base/src/auth/cursor.rs | 108 ++----------- crates/jcode-base/src/auth/cursor_tests.rs | 58 +++---- crates/jcode-base/src/auth/grok_build.rs | 177 +++++++++++++++++++++ crates/jcode-base/src/auth/mod.rs | 6 +- crates/jcode-tui/src/tui/app/auth.rs | 83 +++------- 7 files changed, 296 insertions(+), 193 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a74806e917..5fcde07878 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2099,6 +2099,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -2753,6 +2765,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2786,6 +2807,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -3568,6 +3598,7 @@ dependencies = [ "rand 0.9.3", "regex", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -4869,6 +4900,17 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -7280,6 +7322,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index d8acd70993..128247ee4f 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -43,6 +43,7 @@ anyhow = "1" libc = "0.2" # Unix system calls (flock) chrono = { version = "0.4", features = ["serde"] } regex = "1" +rusqlite = { version = "0.32", features = ["bundled"] } urlencoding = "2" # URL encoding for web search uuid = { version = "1", features = ["v4", "v5"] } proctitle = "0.1" diff --git a/crates/jcode-base/src/auth/cursor.rs b/crates/jcode-base/src/auth/cursor.rs index e21fb65eed..c58438d669 100644 --- a/crates/jcode-base/src/auth/cursor.rs +++ b/crates/jcode-base/src/auth/cursor.rs @@ -4,8 +4,6 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::PathBuf; -use std::process::{Command, Output, Stdio}; -use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; const CURSOR_API_BASE: &str = "https://api2.cursor.sh"; @@ -15,7 +13,6 @@ const CURSOR_API_BASE: &str = "https://api2.cursor.sh"; // `JCODE_CURSOR_CLIENT_VERSION` if Cursor moves the floor again. const CURSOR_DIRECT_CLIENT_VERSION_DEFAULT: &str = "3.8.24"; const CURSOR_OAUTH_CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"; -const CURSOR_EXTERNAL_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); pub const CURSOR_AUTH_FILE_SOURCE_ID: &str = "cursor_auth_json"; pub const CURSOR_VSCDB_SOURCE_ID: &str = "cursor_vscdb"; @@ -101,29 +98,6 @@ pub fn has_cursor_native_auth() -> bool { load_access_token_from_env_or_file().is_ok() || has_cursor_vscdb_token() || has_cursor_api_key() } -/// Check whether the local Cursor Agent CLI reports an authenticated session. -/// -/// Full auth status may spend a little time probing external commands, while -/// `AuthStatus::check_fast()` intentionally skips this path for UI responsiveness. -pub fn has_authenticated_cli_session() -> bool { - let command = std::env::var_os("JCODE_CURSOR_CLI_PATH") - .unwrap_or_else(|| std::ffi::OsString::from("cursor-agent")); - let command_label = command.to_string_lossy(); - if !super::command_exists(&command_label) { - return false; - } - - let mut status_command = Command::new(&command); - status_command.arg("status"); - let Ok(Some(output)) = - command_output_with_timeout(&mut status_command, CURSOR_EXTERNAL_COMMAND_TIMEOUT) - else { - return false; - }; - - status_output_indicates_authenticated(output.status.success(), &output.stdout, &output.stderr) -} - /// Check whether a trusted Cursor auth.json contains a usable direct access token. pub fn has_cursor_auth_file_token() -> bool { let Ok(file_path) = cursor_auth_file_path() else { @@ -262,49 +236,28 @@ fn cursor_vscdb_paths() -> Vec { .collect() } -/// Read a key from a vscdb file using the sqlite3 CLI. +/// Read a key from Cursor's SQLite state directly. Authentication must not +/// depend on an external `sqlite3` executable being installed. fn read_vscdb_key(db_path: &PathBuf, key: &str) -> Result { - let mut command = Command::new("sqlite3"); - command.arg(db_path).arg(format!( - "SELECT value FROM ItemTable WHERE key = '{}';", - key - )); - let output = command_output_with_timeout(&mut command, CURSOR_EXTERNAL_COMMAND_TIMEOUT) - .context("Failed to run sqlite3 (is it installed?)")? - .ok_or_else(|| anyhow::anyhow!("sqlite3 timed out reading {}", db_path.display()))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("sqlite3 failed: {}", stderr.trim()); - } - - let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let connection = rusqlite::Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY + | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX + | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .with_context(|| format!("Failed to open Cursor state at {}", db_path.display()))?; + let value: String = connection + .query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |row| { + row.get(0) + }) + .with_context(|| format!("Key '{key}' not found in {}", db_path.display()))?; + let value = value.trim().to_string(); if value.is_empty() { anyhow::bail!("Key '{}' not found or empty in {}", key, db_path.display()); } Ok(value) } -fn command_output_with_timeout(command: &mut Command, timeout: Duration) -> Result> { - let mut child = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let start = std::time::Instant::now(); - - loop { - if child.try_wait()?.is_some() { - return child.wait_with_output().map(Some).map_err(Into::into); - } - if start.elapsed() >= timeout { - let _ = child.kill(); - let _ = child.wait(); - return Ok(None); - } - std::thread::sleep(Duration::from_millis(25)); - } -} - /// Load Cursor API key. Checks in order: /// 1. `CURSOR_API_KEY` env var /// 2. Saved key in `~/.config/jcode/cursor.env` @@ -700,37 +653,6 @@ fn timestamp_header_now() -> String { URL_SAFE_NO_PAD.encode(bytes) } -fn status_output_indicates_authenticated(success: bool, stdout: &[u8], stderr: &[u8]) -> bool { - let combined = format!( - "{}\n{}", - String::from_utf8_lossy(stdout), - String::from_utf8_lossy(stderr) - ) - .to_ascii_lowercase(); - - if combined.contains("not authenticated") - || combined.contains("login required") - || combined.contains("not logged in") - || combined.contains("unauthenticated") - { - return false; - } - - if !success { - return false; - } - - if combined.contains("authenticated") - || combined.contains("account") - || combined.contains("email") - || combined.contains("endpoint") - { - return true; - } - - success -} - #[cfg(test)] #[path = "cursor_tests.rs"] mod tests; diff --git a/crates/jcode-base/src/auth/cursor_tests.rs b/crates/jcode-base/src/auth/cursor_tests.rs index 41ba79bfdc..3f3accc0ab 100644 --- a/crates/jcode-base/src/auth/cursor_tests.rs +++ b/crates/jcode-base/src/auth/cursor_tests.rs @@ -233,44 +233,28 @@ fn load_access_token_from_auth_file_does_not_change_external_permissions() { } #[test] -fn status_output_detects_authenticated_session() { - assert!(status_output_indicates_authenticated( - true, - b"Authenticated\nAccount: user@example.com\nEndpoint: production", - b"" - )); -} - -#[test] -fn status_output_detects_missing_authentication() { - assert!(!status_output_indicates_authenticated( - true, - b"Not authenticated. Run cursor-agent login.", - b"" - )); -} - -#[test] -fn status_output_requires_successful_exit_for_authentication_keywords() { - assert!(!status_output_indicates_authenticated( - false, - b"Account: user@example.com\nEndpoint: production", - b"cursor-agent status failed" - )); -} - -#[cfg(unix)] -#[test] -fn external_auth_command_timeout_returns_none() { - let mut command = std::process::Command::new("sh"); - command.arg("-c").arg("sleep 2; echo late"); - - let start = std::time::Instant::now(); - let output = command_output_with_timeout(&mut command, std::time::Duration::from_millis(50)) - .expect("timeout helper should not error"); +fn reads_cursor_state_with_embedded_sqlite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.vscdb"); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection + .execute( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + ("cursorAuth/accessToken", "native-token"), + ) + .unwrap(); + drop(connection); - assert!(output.is_none()); - assert!(start.elapsed() < std::time::Duration::from_secs(1)); + assert_eq!( + read_vscdb_key(&path, "cursorAuth/accessToken").unwrap(), + "native-token" + ); } fn load_key_from_file(path: &PathBuf) -> Result { diff --git a/crates/jcode-base/src/auth/grok_build.rs b/crates/jcode-base/src/auth/grok_build.rs index 71e39b4df9..ef2dcaa373 100644 --- a/crates/jcode-base/src/auth/grok_build.rs +++ b/crates/jcode-base/src/auth/grok_build.rs @@ -6,11 +6,188 @@ //! not need to install the `grok` CLI or put it on `PATH`. use anyhow::{Context, Result, bail}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; pub const CLI_PATH_ENV: &str = "JCODE_GROK_CLI_PATH"; const PRIMARY_BASE_URL: &str = "https://x.ai/cli"; const FALLBACK_BASE_URL: &str = "https://storage.googleapis.com/grok-build-public-artifacts/cli"; +const OAUTH_ISSUER: &str = "https://auth.x.ai"; +const OAUTH_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; +const OAUTH_SCOPES: &str = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write workspaces:read workspaces:write"; + +#[derive(Clone, Debug, Deserialize)] +pub struct DeviceAuthorization { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: Option, + pub expires_in: u64, + #[serde(default = "default_poll_interval")] + pub interval: u64, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenError { + error: String, + error_description: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct JwtClaims { + sub: Option, + email: Option, + given_name: Option, +} + +#[derive(Debug, Serialize)] +struct StoredCredential { + key: String, + auth_mode: &'static str, + create_time: String, + user_id: String, + email: Option, + coding_data_retention_opt_out: bool, + #[serde(skip_serializing_if = "Option::is_none")] + first_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expires_at: Option, + oidc_issuer: &'static str, + oidc_client_id: &'static str, +} + +fn default_poll_interval() -> u64 { + 5 +} + +fn oauth_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + request + .header("x-grok-client-version", "1.0.3") + .header("x-grok-client-surface", "ui") + .header( + "user-agent", + format!("jcode/{} grok-shell/1.0.3", env!("CARGO_PKG_VERSION")), + ) +} + +pub async fn initiate_device_login(client: &reqwest::Client) -> Result { + oauth_headers(client.post(format!("{OAUTH_ISSUER}/oauth2/device/code"))) + .form(&[ + ("client_id", OAUTH_CLIENT_ID), + ("scope", OAUTH_SCOPES), + ("referrer", "grok-build"), + ]) + .send() + .await? + .error_for_status()? + .json() + .await + .context("invalid xAI device authorization response") +} + +pub async fn complete_device_login( + client: &reqwest::Client, + authorization: &DeviceAuthorization, +) -> Result<()> { + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(authorization.expires_in.max(600)); + let mut interval = authorization.interval.max(1); + loop { + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; + if tokio::time::Instant::now() >= deadline { + bail!("xAI device authorization expired"); + } + let response = oauth_headers(client.post(format!("{OAUTH_ISSUER}/oauth2/token"))) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", authorization.device_code.as_str()), + ("client_id", OAUTH_CLIENT_ID), + ]) + .send() + .await?; + let status = response.status(); + let body = response.bytes().await?; + if status.is_success() { + let tokens: TokenResponse = + serde_json::from_slice(&body).context("invalid xAI token response")?; + return save_tokens(tokens); + } + let error: TokenError = serde_json::from_slice(&body) + .with_context(|| format!("xAI token request failed with {status}"))?; + match error.error.as_str() { + "authorization_pending" => continue, + "slow_down" => { + interval += 5; + continue; + } + _ => bail!( + "xAI login failed: {}", + error.error_description.unwrap_or(error.error) + ), + } + } +} + +fn save_tokens(tokens: TokenResponse) -> Result<()> { + let claims = tokens + .access_token + .split('.') + .nth(1) + .and_then(|part| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(part) + .ok() + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_default(); + let now = chrono::Utc::now(); + let credential = StoredCredential { + key: tokens.access_token, + auth_mode: "oidc", + create_time: now.to_rfc3339(), + user_id: claims.sub.unwrap_or_default(), + email: claims.email, + coding_data_retention_opt_out: false, + first_name: claims.given_name, + refresh_token: tokens.refresh_token, + expires_at: tokens + .expires_in + .map(|seconds| (now + chrono::Duration::seconds(seconds as i64)).to_rfc3339()), + oidc_issuer: OAUTH_ISSUER, + oidc_client_id: OAUTH_CLIENT_ID, + }; + let home = std::env::var_os("GROK_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".grok"))) + .context("No home directory available for Grok Build credentials")?; + std::fs::create_dir_all(&home)?; + let path = home.join("auth.json"); + let mut credentials = std::fs::read(&path) + .ok() + .and_then(|bytes| { + serde_json::from_slice::>(&bytes).ok() + }) + .unwrap_or_default(); + credentials.insert( + format!("{OAUTH_ISSUER}::{OAUTH_CLIENT_ID}"), + serde_json::to_value(credential)?, + ); + let temporary = path.with_extension(format!("json.tmp-{}", std::process::id())); + std::fs::write(&temporary, serde_json::to_vec_pretty(&credentials)?)?; + crate::platform::set_permissions_owner_only(&temporary)?; + std::fs::rename(&temporary, &path)?; + Ok(()) +} fn managed_cli_path() -> Result { let name = if cfg!(windows) { "grok.exe" } else { "grok" }; diff --git a/crates/jcode-base/src/auth/mod.rs b/crates/jcode-base/src/auth/mod.rs index b4e0f99eff..0d707a8e49 100644 --- a/crates/jcode-base/src/auth/mod.rs +++ b/crates/jcode-base/src/auth/mod.rs @@ -1161,9 +1161,7 @@ fn probe_cursor_status(status: &mut AuthStatus, mode: AuthProbeMode) { AuthProbeMode::Full => { let cursor_has_api_key = cursor::has_cursor_api_key(); let cursor_has_native_auth = cursor::has_cursor_native_auth(); - let cursor_has_cli_auth = - !cursor_has_native_auth && cursor::has_authenticated_cli_session(); - status.cursor = if cursor_has_native_auth || cursor_has_cli_auth { + status.cursor = if cursor_has_native_auth { AuthState::Available } else if cursor_has_api_key { AuthState::Expired @@ -1172,7 +1170,7 @@ fn probe_cursor_status(status: &mut AuthStatus, mode: AuthProbeMode) { }; } AuthProbeMode::Fast => { - // Avoid the vscdb/sqlite and CLI probes in fast UI paths. + // Avoid the vscdb probe in fast UI paths. let cursor_has_api_key = cursor::has_cursor_api_key(); let cursor_has_file_or_env_auth = cursor::load_access_token_from_env_or_file().is_ok(); status.cursor = if cursor_has_file_or_env_auth || cursor_has_api_key { diff --git a/crates/jcode-tui/src/tui/app/auth.rs b/crates/jcode-tui/src/tui/app/auth.rs index 919e2980aa..b09e36c2a4 100644 --- a/crates/jcode-tui/src/tui/app/auth.rs +++ b/crates/jcode-tui/src/tui/app/auth.rs @@ -1799,67 +1799,39 @@ impl App { ))); }; - let cli = match crate::auth::grok_build::ensure_cli().await { - Ok(cli) => cli, - Err(error) => { - Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { - provider: "grok-build".to_string(), - success: false, - message: format!("Failed to prepare Grok Build: {error:#}"), - })); - return; - } - }; - publish_progress( - "Grok Build Login\n\nManaged backend ready. Requesting xAI authorization..." - .to_string(), - "Grok Build: requesting authorization", - ); - - let mut child = match tokio::process::Command::new(&cli) - .arg("login") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - { - Ok(child) => child, + let client = crate::provider::shared_http_client(); + let authorization = match crate::auth::grok_build::initiate_device_login(&client).await { + Ok(authorization) => authorization, Err(error) => { Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { provider: "grok-build".to_string(), success: false, - message: format!( - "Failed to start Jcode's managed Grok Build backend: {error}" - ), + message: format!("Failed to start Grok Build login: {error:#}"), })); return; } }; - - if let Some(stderr) = child.stderr.take() { - let session_id = session_id.clone(); - tokio::spawn(async move { - use tokio::io::AsyncBufReadExt; - let mut lines = tokio::io::BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let line = line.trim(); - if line.is_empty() { - continue; - } - Bus::global().publish(BusEvent::UiActivity( - crate::bus::UiActivity::auth( - Some(session_id.clone()), - line.to_string(), - Some("Grok Build: waiting for browser approval"), - ), - )); + let url = authorization.verification_uri_complete.as_deref() + .unwrap_or(&authorization.verification_uri); + let _ = Self::open_auth_browser(url); + publish_progress(format!( + "Grok Build Login\n\nOpen: {}\n\nConfirm code: {}\n\nWaiting for authorization...", + authorization.verification_uri, authorization.user_code + ), "Grok Build: waiting for browser approval"); + + match crate::auth::grok_build::complete_device_login(&client, &authorization).await { + Ok(()) => { + // The ACP executable is a private provider backend, not an + // authentication dependency. Provision it only after the + // native OAuth flow has completed. + if let Err(error) = crate::auth::grok_build::ensure_cli().await { + Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { + provider: "grok-build".to_string(), + success: false, + message: format!("Grok Build login succeeded, but its managed runtime could not be prepared: {error:#}"), + })); + return; } - }); - } - - match child.wait().await { - Ok(status) if status.success() => { Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { provider: "grok-build".to_string(), success: true, @@ -1867,13 +1839,6 @@ impl App { .to_string(), })); } - Ok(status) => { - Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { - provider: "grok-build".to_string(), - success: false, - message: format!("Grok Build login exited with status {status}."), - })); - } Err(error) => { Bus::global().publish(BusEvent::LoginCompleted(LoginCompleted { provider: "grok-build".to_string(), From 9c1647da3632970a1361e3a49ed344180353cc6c Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:47:53 -0700 Subject: [PATCH 057/107] Render background tasks in pinned status band --- crates/jcode-base/src/message.rs | 13 +- .../jcode-base/src/message/notifications.rs | 22 +++ crates/jcode-tui/src/tui/app.rs | 2 + crates/jcode-tui/src/tui/app/local.rs | 41 ++++- crates/jcode-tui/src/tui/app/model_context.rs | 18 +-- .../src/tui/app/remote/server_events.rs | 60 +++++-- .../src/tui/app/state_ui_messages.rs | 107 ++++++++++--- .../tests/remote_events_reload_02/part_01.rs | 61 +++---- .../tests/remote_startup_input_01/part_02.rs | 34 ++-- .../tests/remote_startup_input_02/part_02.rs | 88 ++++++----- .../jcode-tui/src/tui/app/tests/todo_card.rs | 43 +++++ crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 2 + crates/jcode-tui/src/tui/app/tui_state.rs | 4 + crates/jcode-tui/src/tui/mod.rs | 20 +++ crates/jcode-tui/src/tui/ui_viewport.rs | 149 ++++++++++++++---- 15 files changed, 485 insertions(+), 179 deletions(-) diff --git a/crates/jcode-base/src/message.rs b/crates/jcode-base/src/message.rs index f98c232b75..14444a909a 100644 --- a/crates/jcode-base/src/message.rs +++ b/crates/jcode-base/src/message.rs @@ -20,12 +20,13 @@ mod notifications; pub use notifications::{ ParsedBackgroundTaskNotification, ParsedBackgroundTaskProgressNotification, - background_task_display_label, background_task_status_notice, - format_background_task_notification_markdown, format_background_task_progress_markdown, - format_background_task_stalled_markdown, format_input_shell_result_markdown, - format_model_refresh_progress_markdown, input_shell_status_notice, - parse_background_task_notification_markdown, - parse_background_task_progress_notification_markdown, strip_ansi_escape_sequences, + ParsedBackgroundTaskStartedNotification, background_task_display_label, + background_task_status_notice, format_background_task_notification_markdown, + format_background_task_progress_markdown, format_background_task_stalled_markdown, + format_input_shell_result_markdown, format_model_refresh_progress_markdown, + input_shell_status_notice, parse_background_task_notification_markdown, + parse_background_task_progress_notification_markdown, + parse_background_task_started_notification_markdown, strip_ansi_escape_sequences, }; fn compile_static_regex(pattern: &str) -> Option { diff --git a/crates/jcode-base/src/message/notifications.rs b/crates/jcode-base/src/message/notifications.rs index 7b3a503e43..6e0c2a12a4 100644 --- a/crates/jcode-base/src/message/notifications.rs +++ b/crates/jcode-base/src/message/notifications.rs @@ -286,6 +286,28 @@ pub fn format_background_task_progress_markdown(task: &BackgroundTaskProgressEve ) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedBackgroundTaskStartedNotification { + pub task_id: String, + pub label: String, +} + +pub fn parse_background_task_started_notification_markdown( + content: &str, +) -> Option { + let header = content.trim().lines().next()?.trim(); + let rest = header.strip_prefix("**Background task started** `")?; + let (task_id, label) = rest.split_once("` · `")?; + let label = label.strip_suffix('`')?; + if task_id.is_empty() || label.is_empty() { + return None; + } + Some(ParsedBackgroundTaskStartedNotification { + task_id: task_id.to_string(), + label: label.to_string(), + }) +} + pub fn format_background_task_stalled_markdown( task: &jcode_background_types::BackgroundTaskStalled, ) -> String { diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index e9605d9139..65759664a0 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -1349,6 +1349,8 @@ pub struct App { pinned_todos_checked_at: Option, /// User-expanded state for the pinned todo band's `+N more` row. pinned_todos_expanded: bool, + /// Running and terminal background tasks shown beneath the pinned todo band. + background_task_rows: Vec, last_side_panel_refresh: Option, // Most recently persisted focus target for dictation routing. last_client_focus_recorded_at: Option, diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index 2e65156494..3a4058884f 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -5,7 +5,7 @@ use crate::bus::{ }; use crate::message::{ ContentBlock, Message, Role, background_task_status_notice, - format_background_task_notification_markdown, format_background_task_progress_markdown, + format_background_task_notification_markdown, }; use crate::session::StoredDisplayRole; use anyhow::Result; @@ -313,7 +313,9 @@ pub(super) fn handle_ui_activity(app: &mut App, activity: UiActivity) -> bool { match activity.kind { UiActivityKind::Background => { - app.push_display_message(DisplayMessage::background_task(activity.message.clone())) + if !app.upsert_running_background_task_started(&activity.message) { + app.push_display_message(DisplayMessage::background_task(activity.message.clone())) + } } UiActivityKind::Auth | UiActivityKind::Catalog => { if activity.message.trim().is_empty() { @@ -325,7 +327,7 @@ pub(super) fn handle_ui_activity(app: &mut App, activity: UiActivity) -> bool { ) .is_some() { - app.upsert_background_task_progress_message(activity.message.clone()); + app.upsert_running_background_task_progress(&activity.message); } else { app.push_display_message(DisplayMessage::system(activity.message.clone())) } @@ -427,12 +429,23 @@ fn apply_terminal_event( } fn handle_background_task_completed(app: &mut App, task: BackgroundTaskCompleted) { + if task.session_id == app.session.id { + let label = crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref(), + ); + let status = if task.status == crate::bus::BackgroundTaskStatus::Completed { + crate::tui::BackgroundTaskRowStatus::Completed + } else { + crate::tui::BackgroundTaskRowStatus::Failed + }; + app.finish_background_task(task.task_id.clone(), label, status); + } if !task.notify || task.session_id != app.session.id { return; } let notification = format_background_task_notification_markdown(&task); - app.push_display_message(DisplayMessage::background_task(notification.clone())); app.set_status_notice(background_task_status_notice(&task)); if !app.is_processing { @@ -473,7 +486,19 @@ fn handle_background_task_stalled(app: &mut App, task: crate::bus::BackgroundTas } let notification = crate::message::format_background_task_stalled_markdown(&task); - app.push_display_message(DisplayMessage::background_task(notification.clone())); + let percent = app + .background_task_rows_ref() + .iter() + .find(|row| row.task_id == task.task_id) + .and_then(|row| row.percent); + app.upsert_running_background_task( + task.task_id.clone(), + crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref(), + ), + percent, + ); app.set_status_notice(format!( "Background task stalled · {} · no output for {}s", crate::message::background_task_display_label( @@ -520,7 +545,11 @@ fn handle_background_task_progress(app: &mut App, event: BackgroundTaskProgressE return; } - app.upsert_background_task_progress_message(format_background_task_progress_markdown(&event)); + let label = crate::message::background_task_display_label( + &event.tool_name, + event.display_name.as_deref(), + ); + app.upsert_running_background_task(event.task_id.clone(), label, event.progress.percent); let notice = format!( "Background task · {} · {}", diff --git a/crates/jcode-tui/src/tui/app/model_context.rs b/crates/jcode-tui/src/tui/app/model_context.rs index 9569c47141..62425d555a 100644 --- a/crates/jcode-tui/src/tui/app/model_context.rs +++ b/crates/jcode-tui/src/tui/app/model_context.rs @@ -1789,11 +1789,10 @@ impl App { match completed.result { Ok(summary) => { self.invalidate_model_picker_cache(); - self.upsert_background_task_progress_message( - crate::message::format_model_refresh_progress_markdown( - "Model list refresh complete", - Some(100), - ), + self.finish_background_task( + "refresh-model-list".to_string(), + "Model list refresh".to_string(), + crate::tui::BackgroundTaskRowStatus::Completed, ); self.push_display_message(DisplayMessage::system(format_model_refresh_summary( &summary, @@ -1804,11 +1803,10 @@ impl App { )); } Err(error) => { - self.upsert_background_task_progress_message( - crate::message::format_model_refresh_progress_markdown( - "Model list refresh failed", - None, - ), + self.finish_background_task( + "refresh-model-list".to_string(), + "Model list refresh".to_string(), + crate::tui::BackgroundTaskRowStatus::Failed, ); self.push_display_message(DisplayMessage::error(format!( "Failed to refresh model list: {}", diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index 7ac83b98e1..995f6af1e6 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -2438,14 +2438,31 @@ pub(in crate::tui::app) fn handle_server_event( } app.mark_soft_interrupt_injected(&content); let role = display_role.unwrap_or_else(|| "user".to_string()); - app.push_display_message(DisplayMessage { - role, - content: content.clone(), - tool_calls: vec![], - duration_secs: None, - title: None, - tool_data: None, - }); + if role == "background_task" { + if let Some(completed) = + crate::message::parse_background_task_notification_markdown(&content) + { + let status = if completed.status.contains("completed") { + crate::tui::BackgroundTaskRowStatus::Completed + } else { + crate::tui::BackgroundTaskRowStatus::Failed + }; + let label = crate::message::background_task_display_label( + &completed.tool_name, + completed.display_name.as_deref(), + ); + app.finish_background_task(completed.task_id, label, status); + } + } else { + app.push_display_message(DisplayMessage { + role, + content: content.clone(), + tool_calls: vec![], + duration_secs: None, + title: None, + tool_data: None, + }); + } if let Some(n) = tools_skipped { app.set_status_notice(format!("⚡ {} tool(s) skipped", n)); } @@ -2526,11 +2543,23 @@ pub(in crate::tui::app) fn handle_server_event( if crate::message::parse_background_task_progress_notification_markdown(&message) .is_some() { - app.upsert_background_task_progress_message(message.clone()); + app.upsert_running_background_task_progress(&message); } else { - app.push_display_message(DisplayMessage::background_task(message.clone())); + if let Some(completed) = + crate::message::parse_background_task_notification_markdown(&message) + { + let status = if completed.status.contains("completed") { + crate::tui::BackgroundTaskRowStatus::Completed + } else { + crate::tui::BackgroundTaskRowStatus::Failed + }; + let label = crate::message::background_task_display_label( + &completed.tool_name, + completed.display_name.as_deref(), + ); + app.finish_background_task(completed.task_id, label, status); + } } - persist_replay_display_message(app, "background_task", None, &message); app.set_status_notice(presentation.status_notice); return false; } @@ -2559,13 +2588,14 @@ pub(in crate::tui::app) fn handle_server_event( ) { let status_notice = progress.summary.clone(); - app.upsert_background_task_progress_message(message.clone()); - persist_replay_display_message(app, "background_task", None, &message); + app.upsert_running_background_task_progress(&message); app.set_status_notice(status_notice); return false; } else if scope == "background_activity" { - app.push_display_message(DisplayMessage::background_task(message.clone())); - persist_replay_display_message(app, "background_task", None, &message); + if !app.upsert_running_background_task_started(&message) { + app.push_display_message(DisplayMessage::background_task(message.clone())); + persist_replay_display_message(app, "background_task", None, &message); + } } else { app.push_display_message(DisplayMessage::system(message.clone())); persist_replay_display_message(app, "system", None, &message); diff --git a/crates/jcode-tui/src/tui/app/state_ui_messages.rs b/crates/jcode-tui/src/tui/app/state_ui_messages.rs index 72f2877031..d6d5688071 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_messages.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_messages.rs @@ -17,11 +17,12 @@ fn display_message_from_stored_message( if text.trim().is_empty() { return None; } + if is_background_task_lifecycle_message(&text) { + return None; + } match message.display_role { Some(crate::session::StoredDisplayRole::System) => Some(DisplayMessage::system(text)), - Some(crate::session::StoredDisplayRole::BackgroundTask) => { - Some(DisplayMessage::background_task(text)) - } + Some(crate::session::StoredDisplayRole::BackgroundTask) => None, None => match message.role { Role::User => { if crate::session::is_scheduled_task_message(message) { @@ -45,6 +46,14 @@ fn display_message_from_stored_message( } } +fn is_background_task_lifecycle_message(content: &str) -> bool { + let content = content.trim_start(); + content.starts_with("**Background task**") + || content.starts_with("**Background task started**") + || content.starts_with("**Background task progress**") + || content.starts_with("**Background task stalled**") +} + fn stored_message_visible_text(message: &crate::session::StoredMessage) -> String { let mut parts = Vec::new(); for block in &message.content { @@ -76,6 +85,9 @@ fn stored_message_visible_text(message: &crate::session::StoredMessage) -> Strin impl App { pub fn push_display_message(&mut self, mut message: DisplayMessage) { + if is_background_task_lifecycle_message(&message.content) { + return; + } compact_display_message_tool_data(&mut message); // A trailing Ctrl+L spacer only exists to keep the screen clear while // idle. The moment real content arrives, drop it so the transcript @@ -115,6 +127,7 @@ impl App { } pub(super) fn replace_display_messages(&mut self, mut messages: Vec) { + messages.retain(|message| !is_background_task_lifecycle_message(&message.content)); compact_display_messages_for_storage(&mut messages); self.display_messages = messages; self.attempt_committed_assistant_messages = 0; @@ -168,27 +181,85 @@ impl App { self.replace_display_message_title_and_content(idx, title, content) } - pub(super) fn upsert_background_task_progress_message(&mut self, content: String) { + pub(super) fn background_task_rows_ref(&self) -> &[crate::tui::BackgroundTaskRow] { + &self.background_task_rows + } + + pub(super) fn upsert_running_background_task( + &mut self, + task_id: String, + label: String, + percent: Option, + ) { + if let Some(task) = self + .background_task_rows + .iter_mut() + .find(|task| task.task_id == task_id) + { + task.label = label; + task.percent = percent; + task.status = crate::tui::BackgroundTaskRowStatus::Running; + return; + } + self.background_task_rows + .push(crate::tui::BackgroundTaskRow { + task_id, + label, + percent, + status: crate::tui::BackgroundTaskRowStatus::Running, + }); + } + + pub(super) fn upsert_running_background_task_progress(&mut self, content: &str) -> bool { let Some(progress) = - crate::message::parse_background_task_progress_notification_markdown(&content) + crate::message::parse_background_task_progress_notification_markdown(content) else { - self.push_display_message(DisplayMessage::background_task(content)); - return; + return false; }; + let label = crate::message::background_task_display_label( + &progress.tool_name, + progress.display_name.as_deref(), + ); + self.upsert_running_background_task(progress.task_id, label, progress.percent); + true + } - let idx = self.display_messages.iter().rposition(|message| { - message.role == "background_task" - && crate::message::parse_background_task_progress_notification_markdown( - &message.content, - ) - .is_some_and(|existing| existing.task_id == progress.task_id) - }); + pub(super) fn upsert_running_background_task_started(&mut self, content: &str) -> bool { + let Some(started) = + crate::message::parse_background_task_started_notification_markdown(content) + else { + return false; + }; + self.upsert_running_background_task(started.task_id, started.label, None); + true + } - if let Some(idx) = idx { - self.replace_display_message_content(idx, content); - } else { - self.push_display_message(DisplayMessage::background_task(content)); + pub(super) fn finish_background_task( + &mut self, + task_id: String, + label: String, + status: crate::tui::BackgroundTaskRowStatus, + ) { + if let Some(task) = self + .background_task_rows + .iter_mut() + .find(|task| task.task_id == task_id) + { + task.label = label; + task.status = status; + if status == crate::tui::BackgroundTaskRowStatus::Completed { + task.percent = Some(100.0); + } + return; } + self.background_task_rows + .push(crate::tui::BackgroundTaskRow { + task_id, + label, + percent: (status == crate::tui::BackgroundTaskRowStatus::Completed) + .then_some(100.0), + status, + }); } pub(super) fn upsert_overnight_display_card( diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs index a6797335b9..df344bb3fb 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs @@ -704,7 +704,7 @@ fn test_handle_server_event_soft_interrupt_injected_unrelated_content_keeps_pend } #[test] -fn test_handle_server_event_soft_interrupt_injected_background_task_renders_card_role() { +fn test_handle_server_event_soft_interrupt_injected_background_task_retains_row_only() { let mut app = create_test_app(); let rt = tokio::runtime::Runtime::new().unwrap(); let _guard = rt.enter(); @@ -720,19 +720,20 @@ fn test_handle_server_event_soft_interrupt_injected_background_task_renders_card &mut remote, ); - let last = app - .display_messages() - .last() - .expect("missing injected background task message"); - assert_eq!(last.role, "background_task"); - assert!(last.content.contains("**Background task** `abc123`")); + assert!(app.display_messages().is_empty()); + assert_eq!( + app.background_task_rows_ref()[0].status, + crate::tui::BackgroundTaskRowStatus::Completed + ); } #[test] -fn test_handle_server_event_notification_background_task_scope_uses_card_rendering() { +fn test_handle_server_event_notification_background_task_scope_uses_failed_row() { let _render_lock = scroll_render_test_lock(); let mut app = create_test_app(); app.set_centered(true); + app.session.short_name = Some("test".to_string()); + app.push_display_message(DisplayMessage::assistant("ordinary transcript content")); let rt = tokio::runtime::Runtime::new().unwrap(); let _guard = rt.enter(); let mut remote = crate::tui::backend::RemoteConnection::dummy(); @@ -751,26 +752,21 @@ fn test_handle_server_event_notification_background_task_scope_uses_card_renderi &mut remote, ); - let last = app - .display_messages() - .last() - .expect("missing background task notification message"); - assert_eq!(last.role, "background_task"); - let backend = ratatui::backend::TestBackend::new(42, 12); let mut terminal = ratatui::Terminal::new(backend).expect("failed to create test terminal"); let text = render_and_snap(&app, &mut terminal); - assert!( - text.contains("╭") && text.contains("╰"), - "expected rounded background-task card in render, got:\n{}", - text - ); - assert!( - !text.contains("◦ Background task"), - "background-task notifications should not render as generic swarm items:\n{}", - text + assert_eq!(app.display_messages().len(), 1); + assert!(!app + .display_messages() + .iter() + .any(|message| message.role == "background_task")); + assert_eq!( + app.background_task_rows_ref()[0].status, + crate::tui::BackgroundTaskRowStatus::Failed ); + assert!(text.contains("× bg bash"), "missing compact failed row:\n{text}"); + assert!(!text.contains("╭") && !text.contains("Background task failed")); } #[test] @@ -880,7 +876,7 @@ fn test_swarm_await_notification_inserts_only_compact_summary() { } #[test] -fn test_background_task_markdown_renders_card_even_if_role_was_lost() { +fn test_background_task_markdown_is_suppressed_even_if_role_was_lost() { let _render_lock = scroll_render_test_lock(); let mut app = create_test_app(); app.set_centered(true); @@ -893,21 +889,8 @@ fn test_background_task_markdown_renders_card_even_if_role_was_lost() { let mut terminal = ratatui::Terminal::new(backend).expect("failed to create test terminal"); let text = render_and_snap(&app, &mut terminal); - assert!( - text.contains("╭") && text.contains("╰"), - "expected inferred background-task card rendering, got:\n{}", - text - ); - assert!( - text.contains("✗ bg Run jcode library tests afte failed · 594967sj63"), - "expected background-task card title, got:\n{}", - text - ); - assert!( - !text.contains("**Background task**"), - "raw markdown should not be shown when the background-task role is inferred:\n{}", - text - ); + assert!(!text.contains("╭") && !text.contains("594967sj63")); + assert!(app.display_messages().is_empty()); assert_eq!(app.display_user_message_count(), 0); } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_01/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_01/part_02.rs index 95d64c41d7..319025e9ad 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_01/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_01/part_02.rs @@ -103,12 +103,13 @@ fn test_refresh_model_list_command_shows_summary_and_status_notice() { assert!(last.content.contains("cerebras-fast")); assert!(last.content.contains("cerebras-large")); assert!(last.content.contains("cerebras-reasoning")); - assert!(app.display_messages.iter().any(|message| { - message.role == "background_task" - && message - .content - .contains("**Background task progress** `refresh-model-list`") - && message.content.contains("Model list refresh") + assert!(!app + .display_messages + .iter() + .any(|message| message.role == "background_task")); + assert!(app.background_task_rows_ref().iter().any(|row| { + row.task_id == "refresh-model-list" + && row.status == crate::tui::BackgroundTaskRowStatus::Completed })); } @@ -333,7 +334,7 @@ fn test_remote_onboarding_catalog_activity_completes_model_setup_without_chat_no } #[test] -fn test_remote_catalog_activity_notification_upserts_progress_card() { +fn test_remote_catalog_activity_notification_upserts_compact_row() { let mut app = create_test_app(); app.auth_catalog_refresh_pending = true; let rt = tokio::runtime::Runtime::new().unwrap(); @@ -365,15 +366,18 @@ fn test_remote_catalog_activity_notification_upserts_progress_card() { ); } - let cards: Vec<_> = app - .display_messages - .iter() - .filter(|message| message.role == "background_task") - .collect(); - assert_eq!(cards.len(), 1, "progress updates should upsert one card"); + assert!(app.display_messages.is_empty()); + assert_eq!(app.background_task_rows_ref().len(), 1); assert!(app.auth_catalog_refresh_pending); - assert!(cards[0].content.contains("refresh-model-list")); - assert!(cards[0].content.contains("Waiting on provider APIs")); + assert_eq!( + app.background_task_rows_ref()[0], + crate::tui::BackgroundTaskRow { + task_id: "refresh-model-list".to_string(), + label: "Model list refresh".to_string(), + percent: Some(20.0), + status: crate::tui::BackgroundTaskRowStatus::Running, + } + ); let status = app.status_notice().expect("status notice"); assert!( status.contains("Waiting on provider APIs (2s elapsed)"), diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs index 5f5d0ffe24..92bbb7c59a 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs @@ -1,5 +1,5 @@ #[test] -fn test_handle_background_task_completed_renders_markdown_preview() { +fn test_handle_background_task_completed_retains_row_without_transcript_card() { let mut app = create_test_app(); let event = BusEvent::BackgroundTaskCompleted(BackgroundTaskCompleted { task_id: "bg123".to_string(), @@ -17,22 +17,15 @@ fn test_handle_background_task_completed_renders_markdown_preview() { super::local::handle_bus_event(&mut app, Ok(event)); - let rendered = app - .display_messages() - .last() - .expect("background task message"); - assert_eq!(rendered.role, "background_task"); - assert!( - rendered - .content - .contains("**Background task** `bg123` · `bash` · ✓ completed · 7.1s · exit 0") - ); - assert!(rendered.content.contains("```text")); - assert!(rendered.content.contains("[stderr] one")); - assert!( - rendered - .content - .contains("_Full output:_ `bg action=\"output\" task_id=\"bg123\"`") + assert!(app.display_messages().is_empty()); + assert_eq!( + app.background_task_rows_ref(), + &[crate::tui::BackgroundTaskRow { + task_id: "bg123".to_string(), + label: "bash".to_string(), + percent: Some(100.0), + status: crate::tui::BackgroundTaskRowStatus::Completed, + }] ); assert_eq!( app.status_notice(), @@ -94,16 +87,38 @@ fn test_handle_background_task_progress_updates_status_notice() { app.status_notice(), Some("Background task · bash · 42% · Running tests".to_string()) ); - let progress_messages: Vec<_> = app - .display_messages() - .iter() - .filter(|message| message.role == "background_task") - .collect(); - assert_eq!(progress_messages.len(), 1); - assert!( - progress_messages[0] - .content - .starts_with("**Background task progress** `bgprogress` · `bash`\n\n") + assert!(app.display_messages().is_empty()); + assert_eq!( + app.background_task_rows_ref(), + &[crate::tui::BackgroundTaskRow { + task_id: "bgprogress".to_string(), + label: "bash".to_string(), + percent: Some(42.0), + status: crate::tui::BackgroundTaskRowStatus::Running, + }] + ); +} + +#[test] +fn test_background_task_started_activity_creates_running_row_without_card() { + let mut app = create_test_app(); + let event = BusEvent::UiActivity(crate::bus::UiActivity::background( + Some(app.session.id.clone()), + "**Background task started** `bgstarted` · `cargo test`\n\nJcode is running this in the background. Progress, checkpoints, and completion will appear here.", + Some("Background task started · cargo test"), + )); + + super::local::handle_bus_event(&mut app, Ok(event)); + + assert!(app.display_messages().is_empty()); + assert_eq!( + app.background_task_rows_ref(), + &[crate::tui::BackgroundTaskRow { + task_id: "bgstarted".to_string(), + label: "cargo test".to_string(), + percent: None, + status: crate::tui::BackgroundTaskRowStatus::Running, + }] ); } @@ -157,7 +172,7 @@ fn test_handle_background_task_progress_debounces_identical_notice_updates() { } #[test] -fn test_handle_background_task_progress_updates_existing_card() { +fn test_handle_background_task_progress_updates_existing_compact_row() { let mut app = create_test_app(); let session_id = app.session.id.clone(); @@ -186,18 +201,13 @@ fn test_handle_background_task_progress_updates_existing_card() { ); } - let progress_messages: Vec<_> = app - .display_messages() - .iter() - .filter(|message| message.role == "background_task") - .collect(); - assert_eq!(progress_messages.len(), 1); - assert!( - progress_messages[0] - .content - .contains("75% · Packaging artifacts") + assert!(app.display_messages().is_empty()); + assert_eq!(app.background_task_rows_ref().len(), 1); + assert_eq!(app.background_task_rows_ref()[0].percent, Some(75.0)); + assert_eq!( + app.background_task_rows_ref()[0].status, + crate::tui::BackgroundTaskRowStatus::Running ); - assert!(!progress_messages[0].content.contains("42% · Running tests")); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tests/todo_card.rs b/crates/jcode-tui/src/tui/app/tests/todo_card.rs index 00cca834bc..a62876d49f 100644 --- a/crates/jcode-tui/src/tui/app/tests/todo_card.rs +++ b/crates/jcode-tui/src/tui/app/tests/todo_card.rs @@ -406,6 +406,49 @@ fn pinned_todo_band_renders_below_sticky_prompt_without_separator() { let _ = crate::todo::save_todos(&session_id, &[]); } +#[test] +fn background_task_rows_render_without_todos_or_transcript_cards() { + let _env_lock = crate::storage::lock_test_env(); + let _render_lock = crate::tui::ui::render_state_test_lock(); + let mut app = create_test_app(); + app.session.short_name = Some("test".to_string()); + app.push_display_message(DisplayMessage::assistant("ordinary transcript content")); + app.upsert_running_background_task( + "running".to_string(), + "cargo test".to_string(), + Some(42.0), + ); + app.finish_background_task( + "done".to_string(), + "release build".to_string(), + crate::tui::BackgroundTaskRowStatus::Completed, + ); + app.finish_background_task( + "failed".to_string(), + "integration tests".to_string(), + crate::tui::BackgroundTaskRowStatus::Failed, + ); + + let backend = ratatui::backend::TestBackend::new(80, 20); + let mut terminal = ratatui::Terminal::new(backend).expect("failed to create test terminal"); + let rendered = render_and_snap(&app, &mut terminal); + + assert!( + rendered.contains("◌ bg cargo test ━━━╺── 42%"), + "missing running task row:\n{rendered}" + ); + assert!( + rendered.contains("✓ bg release build ━━━━━━ 100%"), + "missing completed task row:\n{rendered}" + ); + assert!( + rendered.contains("× bg integration tests ────── failed"), + "missing failed task row:\n{rendered}" + ); + assert!(!rendered.contains("Background tasks")); + assert!(!rendered.contains("╭") && !rendered.contains("╰")); +} + #[test] fn clicking_pinned_todo_more_row_expands_the_band() { use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 9acad2af3b..6c9dcf1b5d 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -624,6 +624,7 @@ impl App { pinned_todos_rendered_hash: 0, pinned_todos_checked_at: None, pinned_todos_expanded: false, + background_task_rows: Vec::new(), last_side_panel_refresh: None, last_client_focus_recorded_at: None, last_client_focus_session_id: None, @@ -1068,6 +1069,7 @@ impl App { pinned_todos_rendered_hash: 0, pinned_todos_checked_at: None, pinned_todos_expanded: false, + background_task_rows: Vec::new(), last_side_panel_refresh: None, last_client_focus_recorded_at: None, last_client_focus_session_id: None, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 8ab3c50e4b..487c531f75 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -593,6 +593,10 @@ impl crate::tui::TuiState for App { self.pinned_todos_expanded } + fn background_task_rows(&self) -> &[crate::tui::BackgroundTaskRow] { + self.background_task_rows_ref() + } + fn input(&self) -> &str { &self.input } diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index b6ff4a63dd..ec7d82e2af 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -8,6 +8,22 @@ pub struct ContextSnapshot { pub fresh: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BackgroundTaskRowStatus { + Running, + Completed, + Failed, +} + +/// Compact presentation state for one retained background task. +#[derive(Clone, Debug, PartialEq)] +pub struct BackgroundTaskRow { + pub task_id: String, + pub label: String, + pub percent: Option, + pub status: BackgroundTaskRowStatus, +} + pub mod backend; pub(crate) mod color_support; mod core; @@ -212,6 +228,10 @@ pub trait TuiState { fn pinned_todos_expanded(&self) -> bool { false } + /// Running and recently completed background tasks rendered beneath pinned todos. + fn background_task_rows(&self) -> &[BackgroundTaskRow] { + &[] + } // ---- Input ---- fn input(&self) -> &str; diff --git a/crates/jcode-tui/src/tui/ui_viewport.rs b/crates/jcode-tui/src/tui/ui_viewport.rs index acfefaa3cc..3101af95cc 100644 --- a/crates/jcode-tui/src/tui/ui_viewport.rs +++ b/crates/jcode-tui/src/tui/ui_viewport.rs @@ -361,7 +361,7 @@ pub(super) fn draw_messages( let viewport_height = render_area.height as usize; // Pinned todo band (display.pin_todos): the full todo card rendered beneath // the sticky previous-prompt preview, including at the top of the transcript. - let (pinned_todo_band, pinned_todo_has_more) = + let (pinned_todo_band, pinned_todo_more_line) = pinned_todo_band_lines(app, text_render_area.width, render_area.height); let max_scroll = compute_max_scroll_with_prompt_preview( total_lines, @@ -414,19 +414,17 @@ pub(super) fn draw_messages( 0u16 }; let pinned_todo_lines = pinned_todo_band.len() as u16; - set_pinned_todo_more_area(if pinned_todo_has_more { - Some(Rect { + set_pinned_todo_more_area(pinned_todo_more_line.map(|line| { + Rect { x: text_render_area.x, y: render_area .y .saturating_add(prompt_preview_lines) - .saturating_add(pinned_todo_lines.saturating_sub(1)), + .saturating_add(line as u16), width: text_render_area.width, height: 1, - }) - } else { - None - }); + } + })); // Total synthetic rows reserved at the top of the viewport (previous-prompt // preview first, then the todo band, then transcript content). let top_band_lines = pinned_todo_lines + prompt_preview_lines; @@ -1364,43 +1362,53 @@ fn windowed_min(widths: &[u16], window: usize) -> Vec { out } -/// Lines for the pinned todo band (`display.pin_todos`): the full inline todo -/// card rendered at the top of the viewport while scrolled, capped to roughly -/// a third of the viewport so the transcript stays usable. Empty when the -/// feature is off, the session has no todos, or the viewport is too small. +/// Lines for the pinned status band: optional todos followed by exactly one +/// compact row per retained background task. Todo content is capped so the +/// transcript stays usable; tasks are never folded into a summary row. fn pinned_todo_band_lines( app: &dyn TuiState, width: u16, viewport_height: u16, -) -> (Vec>, bool) { - if !crate::config::config().display.pin_todos { - return (Vec::new(), false); +) -> (Vec>, Option) { + if width < 16 || viewport_height < 3 { + return (Vec::new(), None); } - let Some(payload) = app.pinned_todos_payload() else { - return (Vec::new(), false); + + let task_lines: Vec<_> = app + .background_task_rows() + .iter() + .map(|task| active_background_task_line(task, width)) + .collect(); + let card_lines = if crate::config::config().display.pin_todos { + app.pinned_todos_payload() + .map(|payload| { + let msg = crate::tui::DisplayMessage::todos(payload.to_string()); + super::messages::get_cached_message_lines( + &msg, + width, + app.diff_mode(), + super::messages::render_todos_message, + ) + }) + .unwrap_or_default() + } else { + Vec::new() }; - if width < 8 || viewport_height < 9 { - return (Vec::new(), false); - } - let msg = crate::tui::DisplayMessage::todos(payload.to_string()); - let card_lines = super::messages::get_cached_message_lines( - &msg, - width, - app.diff_mode(), - super::messages::render_todos_message, - ); - if card_lines.is_empty() { - return (Vec::new(), false); + if card_lines.is_empty() && task_lines.is_empty() { + return (Vec::new(), None); } + // Band budget: about a third of the viewport. let budget = ((viewport_height as usize) / 3).clamp(2, 12); - let content_budget = budget; + let content_budget = budget.saturating_sub(task_lines.len()).max(2); let mut lines: Vec> = Vec::new(); let has_more = card_lines.len() > content_budget && !app.pinned_todos_expanded(); + let mut more_line = None; if has_more { let shown = content_budget.saturating_sub(1); let hidden = card_lines.len() - shown; lines.extend(card_lines.into_iter().take(shown)); + more_line = Some(lines.len()); lines.push(Line::from(Span::styled( format!(" … +{} more (todo)", hidden), Style::default().fg(dim_color()), @@ -1408,7 +1416,86 @@ fn pinned_todo_band_lines( } else { lines.extend(card_lines); } - (lines, has_more) + lines.extend(task_lines); + (lines, more_line) +} + +fn active_background_task_line(task: &crate::tui::BackgroundTaskRow, width: u16) -> Line<'static> { + const BAR_WIDTH: usize = 6; + let (icon, task_color, percent) = match task.status { + crate::tui::BackgroundTaskRowStatus::Running => ( + "◌", + accent_color(), + task.percent.unwrap_or(0.0).clamp(0.0, 100.0), + ), + crate::tui::BackgroundTaskRowStatus::Completed => ("✓", Color::Green, 100.0), + crate::tui::BackgroundTaskRowStatus::Failed => ( + "×", + Color::Red, + task.percent.unwrap_or(0.0).clamp(0.0, 100.0), + ), + }; + let rounded_percent = percent.round() as u8; + let status_label = if task.status == crate::tui::BackgroundTaskRowStatus::Failed { + "failed".to_string() + } else { + format!("{}%", rounded_percent) + }; + let filled = ((percent / 100.0) * BAR_WIDTH as f32).round() as usize; + let (active_bar, remaining_bar) = if task.status + == crate::tui::BackgroundTaskRowStatus::Failed + { + ( + "━".repeat(filled.min(BAR_WIDTH)), + "─".repeat(BAR_WIDTH.saturating_sub(filled)), + ) + } else if filled >= BAR_WIDTH { + ("━".repeat(BAR_WIDTH), String::new()) + } else { + ( + format!("{}╺", "━".repeat(filled)), + "─".repeat(BAR_WIDTH.saturating_sub(filled + 1)), + ) + }; + + let fixed_width = UnicodeWidthStr::width( + format!("◌ bg {} {}{}", active_bar, remaining_bar, status_label).as_str(), + ); + let max_label_width = (width as usize).saturating_sub(fixed_width).max(1); + let label = truncate_background_task_label(&task.label, max_label_width); + + Line::from(vec![ + Span::styled(icon, Style::default().fg(task_color)), + Span::styled(" bg ", Style::default().fg(dim_color())), + Span::raw(label), + Span::raw(" "), + Span::styled(active_bar, Style::default().fg(task_color)), + Span::styled(remaining_bar, Style::default().fg(dim_color())), + Span::styled( + format!(" {}", status_label), + Style::default().fg(dim_color()), + ), + ]) +} + +fn truncate_background_task_label(label: &str, max_width: usize) -> String { + let label = label.replace(['\r', '\n'], " "); + if UnicodeWidthStr::width(label.as_str()) <= max_width { + return label; + } + if max_width <= 1 { + return "…".to_string(); + } + let mut truncated = String::new(); + for ch in label.chars() { + let candidate = format!("{}{}…", truncated, ch); + if UnicodeWidthStr::width(candidate.as_str()) > max_width { + break; + } + truncated.push(ch); + } + truncated.push('…'); + truncated } static PINNED_TODO_MORE_AREA: std::sync::Mutex> = std::sync::Mutex::new(None); From 87c83ceddcbb3d64c45d073e690171adf8aa4106 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:48:44 -0700 Subject: [PATCH 058/107] Show todo intent inline with understanding --- crates/jcode-tui/src/tui/ui_messages.rs | 50 ++++++++----------- crates/jcode-tui/src/tui/ui_messages/tests.rs | 12 +++-- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/crates/jcode-tui/src/tui/ui_messages.rs b/crates/jcode-tui/src/tui/ui_messages.rs index 67db1914e3..7eae62afa9 100644 --- a/crates/jcode-tui/src/tui/ui_messages.rs +++ b/crates/jcode-tui/src/tui/ui_messages.rs @@ -1443,38 +1443,30 @@ fn push_todo_plan_details( inner_width: usize, compact_details: bool, ) { + let intention = plan + .user_intention + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); if let Some(state) = plan.understands_user_intent { - lines.push(todo_card_line( - vec![ - Span::styled( - "Understands user intent ", - Style::default().fg(todo_label_color()), - ), - Span::styled( - state.as_str().to_string(), - Style::default().fg(todo_score_color()), - ), - ], + let label = format!("Intent {}: ", state.as_str()); + let mut spans = vec![Span::styled(label, Style::default().fg(todo_label_color()))]; + if let Some(intention) = intention { + spans.push(Span::styled( + intention.to_string(), + Style::default().fg(todo_meta_color()), + )); + } + lines.push(todo_card_line(spans, base_indent, inner_width)); + } else if let Some(intention) = intention { + push_todo_detail( + lines, + "Intent", + intention, base_indent, inner_width, - )); - } - if !crate::todo::intent_understanding_passes(plan.understands_user_intent) { - if let Some(intention) = plan - .user_intention - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - push_todo_detail( - lines, - "User intention", - intention, - base_indent, - inner_width, - compact_details, - ); - } + compact_details, + ); } } diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index ee293e51dd..e43f8a1217 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -589,8 +589,10 @@ fn render_todos_message_shows_goal_scores_without_verbose_feedback() { assert!(!plain.contains("Relevance representative"), "{plain}"); assert!(!plain.contains("Coverage main_paths"), "{plain}"); // Only the plan-level assessment renders above the groups. - assert!(plain.contains("Understands user intent clear"), "{plain}"); - assert!(!plain.contains("Keep the agent aligned"), "{plain}"); + assert!( + plain.contains("Intent clear: Keep the agent aligned"), + "{plain}" + ); assert!(!plain.contains("Feedback ·"), "{plain}"); assert!(!plain.contains("Inspect a debug frame"), "{plain}"); assert!(plain.contains("● Render the card · plausible"), "{plain}"); @@ -633,7 +635,7 @@ fn render_todos_message_shows_user_intention_when_understanding_is_unclear() { assert_eq!( narrow .iter() - .filter(|line| line.contains("User intention")) + .filter(|line| line.contains("Intent partial:")) .count(), 1 ); @@ -668,6 +670,10 @@ fn render_todos_message_shows_user_intention_when_understanding_is_unclear() { .any(|line| line.contains("narrow terminal window")), "narrow={narrow:?}" ); + assert!( + narrow.iter().any(|line| line.contains('…')), + "narrow intent should be ellipsized: {narrow:?}" + ); } #[test] From b720ff0bfa99200f4a793b8f32127395cc75d835 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:24:14 -0700 Subject: [PATCH 059/107] feat: add maintainer feedback tool --- crates/jcode-app-core/src/tool/feedback.rs | 167 +++++++++++++++++++++ crates/jcode-app-core/src/tool/mod.rs | 7 + crates/jcode-app-core/src/tool/tests.rs | 14 ++ 3 files changed, 188 insertions(+) create mode 100644 crates/jcode-app-core/src/tool/feedback.rs diff --git a/crates/jcode-app-core/src/tool/feedback.rs b/crates/jcode-app-core/src/tool/feedback.rs new file mode 100644 index 0000000000..cfd7dd7c88 --- /dev/null +++ b/crates/jcode-app-core/src/tool/feedback.rs @@ -0,0 +1,167 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::{Result, bail}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +const MAX_SUMMARY_CHARS: usize = 240; +const MAX_DETAILS_CHARS: usize = 1500; + +pub struct MaintainerFeedbackTool; + +impl MaintainerFeedbackTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct FeedbackInput { + category: FeedbackCategory, + origin: FeedbackOrigin, + summary: String, + #[serde(default)] + details: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FeedbackCategory { + Bug, + Praise, + Suggestion, + Usability, + Other, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FeedbackOrigin { + User, + Agent, + Mixed, +} + +fn limited(value: &str, label: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() { + bail!("{label} must not be empty"); + } + if value.chars().count() > max { + bail!("{label} must be at most {max} characters"); + } + Ok(value.to_string()) +} + +fn payload(input: FeedbackInput) -> Result { + let summary = limited(&input.summary, "summary", MAX_SUMMARY_CHARS)?; + let details = input + .details + .as_deref() + .map(|value| limited(value, "details", MAX_DETAILS_CHARS)) + .transpose()?; + let category = format!("{:?}", input.category).to_ascii_lowercase(); + let origin = format!("{:?}", input.origin).to_ascii_lowercase(); + let mut text = format!("[agent feedback; category={category}; origin={origin}] {summary}"); + if let Some(details) = details { + text.push_str("\n\n"); + text.push_str(&details); + } + Ok(text) +} + +#[async_trait] +impl Tool for MaintainerFeedbackTool { + fn name(&self) -> &str { + "maintainer_feedback" + } + + fn description(&self) -> &str { + "Send useful product feedback to the Jcode maintainer, including bugs, suggestions, usability issues, and positive sentiment. Use this for concrete feedback worth acting on, not routine status updates. Write a fresh concise report and never include secrets, credentials, personal data, private paths, or copied transcript content. Delivery follows the user's telemetry setting." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["category", "origin", "summary"], + "properties": { + "intent": super::intent_schema_property(), + "category": { + "type": "string", + "enum": ["bug", "praise", "suggestion", "usability", "other"], + "description": "Kind of feedback." + }, + "origin": { + "type": "string", + "enum": ["user", "agent", "mixed"], + "description": "Whether this reflects the user's words, the agent's observation, or both." + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": MAX_SUMMARY_CHARS, + "description": "Self-contained maintainer-facing summary. Paraphrase rather than quoting the user." + }, + "details": { + "type": "string", + "minLength": 1, + "maxLength": MAX_DETAILS_CHARS, + "description": "Optional actionable detail, such as reproduction steps and expected versus actual behavior. Never include private data." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result { + let text = payload(serde_json::from_value(input)?)?; + if !crate::telemetry::is_enabled() { + return Ok(ToolOutput::new( + "Feedback was not sent because telemetry is disabled. The user can use /telemetry to change that setting.", + )); + } + crate::telemetry::record_feedback(&text); + Ok(ToolOutput::new( + "Feedback queued for the Jcode maintainer. Thank you.", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_labels_and_formats_feedback() { + let text = payload(FeedbackInput { + category: FeedbackCategory::Praise, + origin: FeedbackOrigin::Mixed, + summary: "The new picker is much easier to use".into(), + details: Some("The labels make model differences clear.".into()), + }) + .unwrap(); + assert_eq!( + text, + "[agent feedback; category=praise; origin=mixed] The new picker is much easier to use\n\nThe labels make model differences clear." + ); + } + + #[test] + fn payload_rejects_empty_and_oversized_fields() { + let empty = payload(FeedbackInput { + category: FeedbackCategory::Bug, + origin: FeedbackOrigin::Agent, + summary: " ".into(), + details: None, + }); + assert!(empty.unwrap_err().to_string().contains("must not be empty")); + + let oversized = payload(FeedbackInput { + category: FeedbackCategory::Other, + origin: FeedbackOrigin::User, + summary: "x".repeat(MAX_SUMMARY_CHARS + 1), + details: None, + }); + assert!(oversized.unwrap_err().to_string().contains("at most 240")); + } +} diff --git a/crates/jcode-app-core/src/tool/mod.rs b/crates/jcode-app-core/src/tool/mod.rs index a7411246e0..68d59693a2 100644 --- a/crates/jcode-app-core/src/tool/mod.rs +++ b/crates/jcode-app-core/src/tool/mod.rs @@ -14,6 +14,7 @@ mod debug_socket; mod discover; mod discover_secrets; mod edit; +mod feedback; mod gmail; mod goal; pub mod inflight; @@ -246,6 +247,12 @@ impl Registry { websearch::WebSearchTool::new, ); Self::insert_tool_timed(&mut m, &mut timings, "invalid", invalid::InvalidTool::new); + Self::insert_tool_timed( + &mut m, + &mut timings, + "maintainer_feedback", + feedback::MaintainerFeedbackTool::new, + ); Self::insert_tool_timed( &mut m, &mut timings, diff --git a/crates/jcode-app-core/src/tool/tests.rs b/crates/jcode-app-core/src/tool/tests.rs index d2ce304c67..a9585d7eeb 100644 --- a/crates/jcode-app-core/src/tool/tests.rs +++ b/crates/jcode-app-core/src/tool/tests.rs @@ -1,6 +1,7 @@ #![cfg_attr(test, allow(clippy::await_holding_lock))] use super::*; + use crate::message::{Message, ToolDefinition}; use crate::provider::{EventStream, Provider}; use async_trait::async_trait; @@ -31,6 +32,19 @@ impl Provider for MockProvider { } } +#[tokio::test] +async fn maintainer_feedback_tool_is_registered() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + assert!( + registry + .tool_names() + .await + .iter() + .any(|name| name == "maintainer_feedback") + ); +} + #[tokio::test] async fn test_tool_definitions_are_sorted() { // Create registry with mock provider From a2bc53808174cc1e68e6d753f1b1cb6073872b49 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:27:43 -0700 Subject: [PATCH 060/107] Suppress background lifecycle tool cards --- .../src/tui/app/state_ui_messages.rs | 10 +++++++ .../tests/remote_events_reload_02/part_02.rs | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/crates/jcode-tui/src/tui/app/state_ui_messages.rs b/crates/jcode-tui/src/tui/app/state_ui_messages.rs index d6d5688071..cf50aa3835 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_messages.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_messages.rs @@ -178,6 +178,16 @@ impl App { return false; }; + // A tool moved to the background finishes its foreground card with the + // background lifecycle notification returned by the tool. The same + // notification also drives the retained row in the pinned status band, + // so remove the transient tool card instead of turning it into a second + // transcript representation. + if is_background_task_lifecycle_message(&content) { + self.remove_display_message(idx); + return true; + } + self.replace_display_message_title_and_content(idx, title, content) } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_02.rs index 85021349a8..898d66a1e0 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_02.rs @@ -117,6 +117,36 @@ fn test_replace_latest_tool_display_message_updates_latest_match_and_bumps_versi assert_eq!(app.display_messages_version, after_change); } +#[test] +fn test_replace_latest_tool_display_message_removes_background_lifecycle_card() { + let mut app = create_test_app(); + app.push_display_message(DisplayMessage { + role: "tool".to_string(), + content: "running bash".to_string(), + tool_calls: vec![], + duration_secs: None, + title: Some("bash".to_string()), + tool_data: Some(crate::message::ToolCall { + id: "tool-bg".to_string(), + name: "bash".to_string(), + input: serde_json::json!({"command": "cargo test"}), + intent: None, + thought_signature: None, + }), + }); + let before = app.display_messages_version; + + assert!(app.replace_latest_tool_display_message( + "tool-bg", + Some("bash".to_string()), + "**Background task started** `bg123` · `cargo test`\n\nJcode is running this in the background." + .to_string(), + )); + + assert!(app.display_messages().is_empty()); + assert_ne!(app.display_messages_version, before); +} + #[test] fn test_push_display_message_coalesces_repeated_single_line_system_messages() { let mut app = create_test_app(); From 9e94d85dc3c2c3a84ff30d128a6b01c9b86340b1 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:28:39 -0700 Subject: [PATCH 061/107] fix: keep feedback tool schema concise --- crates/jcode-app-core/src/tool/feedback.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/jcode-app-core/src/tool/feedback.rs b/crates/jcode-app-core/src/tool/feedback.rs index cfd7dd7c88..8857234af3 100644 --- a/crates/jcode-app-core/src/tool/feedback.rs +++ b/crates/jcode-app-core/src/tool/feedback.rs @@ -78,7 +78,7 @@ impl Tool for MaintainerFeedbackTool { } fn description(&self) -> &str { - "Send useful product feedback to the Jcode maintainer, including bugs, suggestions, usability issues, and positive sentiment. Use this for concrete feedback worth acting on, not routine status updates. Write a fresh concise report and never include secrets, credentials, personal data, private paths, or copied transcript content. Delivery follows the user's telemetry setting." + "Send product feedback to Jcode's maintainer. Respects telemetry settings." } fn parameters_schema(&self) -> Value { @@ -107,7 +107,7 @@ impl Tool for MaintainerFeedbackTool { "type": "string", "minLength": 1, "maxLength": MAX_DETAILS_CHARS, - "description": "Optional actionable detail, such as reproduction steps and expected versus actual behavior. Never include private data." + "description": "Optional reproduction steps or expected versus actual behavior. Never include private data." } } }) From 14324840a79829a61f2e0079321ded754e7e0c30 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:29:32 -0700 Subject: [PATCH 062/107] Test todo intent truncation at wide widths --- crates/jcode-tui/src/tui/ui_messages/tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index e43f8a1217..cc04b824ff 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -661,9 +661,14 @@ fn render_todos_message_shows_user_intention_when_understanding_is_unclear() { .map(extract_line_text) .collect::>(); assert!( - wide.iter().any(|line| line.contains(long_text)), + wide.iter() + .any(|line| line.contains("Intent partial: This deliberately long assessment detail")), "wide={wide:?}" ); + assert!( + wide.iter().any(|line| line.contains('…')), + "wide intent should remain on one ellipsized line: {wide:?}" + ); assert!( !narrow .iter() From b2872056273109c8d77de4870e3dc1c731e45732 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:31:31 -0700 Subject: [PATCH 063/107] Name duplicate provider accounts after animals --- crates/jcode-base/src/auth/account_store.rs | 42 +++++++-- crates/jcode-base/src/auth/claude.rs | 2 +- crates/jcode-base/src/auth/claude_tests.rs | 6 +- crates/jcode-base/src/auth/codex.rs | 2 +- crates/jcode-base/src/auth/codex_tests.rs | 6 +- .../src/tui/app/auth_account_picker.rs | 23 +++-- .../app/auth_account_picker_saved_accounts.rs | 90 ++++++++++++++++--- 7 files changed, 137 insertions(+), 34 deletions(-) diff --git a/crates/jcode-base/src/auth/account_store.rs b/crates/jcode-base/src/auth/account_store.rs index d33269dc6a..0956a7ade5 100644 --- a/crates/jcode-base/src/auth/account_store.rs +++ b/crates/jcode-base/src/auth/account_store.rs @@ -31,8 +31,26 @@ pub fn runtime_active_override(prefix: &str) -> Option { .and_then(|overrides| overrides.get(prefix).cloned()) } +/// Memorable, provider-independent account names. Keeping this list fixed makes +/// labels stable across restarts and gives the same ordinal account the same +/// animal for every provider (for example `claude-otter` and `openai-otter`). +const ACCOUNT_ANIMALS: &[&str] = &[ + "otter", "fox", "panda", "wolf", "owl", "lynx", "badger", "raven", "tiger", "koala", "falcon", + "gecko", "bison", "heron", "moose", "orca", "rabbit", "yak", "zebra", "beaver", "cougar", + "dolphin", "ibis", "jaguar", "lemur", "marten", "newt", "quail", "seal", "wombat", "alpaca", + "penguin", +]; + pub fn canonical_account_label(prefix: &str, index: usize) -> String { - format!("{prefix}-{index}") + let animal = index + .checked_sub(1) + .and_then(|index| ACCOUNT_ANIMALS.get(index).copied()); + match animal { + Some(animal) => format!("{prefix}-{animal}"), + // Extremely large account sets remain unique without making the common + // case less friendly. + None => format!("{prefix}-animal-{index}"), + } } pub fn next_account_label(prefix: &str, account_count: usize) -> String { @@ -240,12 +258,12 @@ mod tests { ); assert!(outcome.changed); - assert_eq!(accounts[0].label, "openai-1"); - assert_eq!(accounts[1].label, "openai-2"); - assert_eq!(active.as_deref(), Some("openai-2")); + assert_eq!(accounts[0].label, "openai-otter"); + assert_eq!(accounts[1].label, "openai-fox"); + assert_eq!(active.as_deref(), Some("openai-fox")); assert_eq!( outcome.canonical_override_label.as_deref(), - Some("openai-1") + Some("openai-otter") ); } @@ -265,8 +283,16 @@ mod tests { |account, label| account.label = label, ); - assert_eq!(label, "claude-1"); - assert_eq!(accounts[0].label, "claude-1"); - assert_eq!(active.as_deref(), Some("claude-1")); + assert_eq!(label, "claude-otter"); + assert_eq!(accounts[0].label, "claude-otter"); + assert_eq!(active.as_deref(), Some("claude-otter")); + } + + #[test] + fn account_labels_use_animals_and_stay_unique_after_the_named_pool() { + assert_eq!(canonical_account_label("claude", 1), "claude-otter"); + assert_eq!(canonical_account_label("claude", 2), "claude-fox"); + assert_eq!(canonical_account_label("openai", 32), "openai-penguin"); + assert_eq!(canonical_account_label("openai", 33), "openai-animal-33"); } } diff --git a/crates/jcode-base/src/auth/claude.rs b/crates/jcode-base/src/auth/claude.rs index 7c1fc5517c..7c01e31b35 100644 --- a/crates/jcode-base/src/auth/claude.rs +++ b/crates/jcode-base/src/auth/claude.rs @@ -327,7 +327,7 @@ pub fn load_auth_file() -> Result { if relabel_accounts(&mut auth) { crate::logging::info( - "Renaming Claude accounts to numbered labels (claude-1, claude-2, ...)", + "Renaming Claude accounts to animal labels (claude-otter, claude-fox, ...)", ); save_auth_file(&auth)?; } diff --git a/crates/jcode-base/src/auth/claude_tests.rs b/crates/jcode-base/src/auth/claude_tests.rs index eeed2d188a..ae0a6a7ed1 100644 --- a/crates/jcode-base/src/auth/claude_tests.rs +++ b/crates/jcode-base/src/auth/claude_tests.rs @@ -82,7 +82,7 @@ fn jcode_path_respects_jcode_home() { } #[test] -fn load_auth_file_renames_existing_labels_to_numbered_scheme() { +fn load_auth_file_renames_existing_labels_to_animal_scheme() { let _lock = crate::storage::lock_test_env(); let temp = tempfile::TempDir::new().unwrap(); let _home = EnvVarGuard::set("JCODE_HOME", temp.path()); @@ -117,9 +117,9 @@ fn load_auth_file_renames_existing_labels_to_numbered_scheme() { .iter() .map(|account| account.label.as_str()) .collect::>(), - vec!["claude-1", "claude-2"] + vec!["claude-otter", "claude-fox"] ); - assert_eq!(auth.active_anthropic_account.as_deref(), Some("claude-2")); + assert_eq!(auth.active_anthropic_account.as_deref(), Some("claude-fox")); } #[test] diff --git a/crates/jcode-base/src/auth/codex.rs b/crates/jcode-base/src/auth/codex.rs index e0d9f37592..25c53cea9d 100644 --- a/crates/jcode-base/src/auth/codex.rs +++ b/crates/jcode-base/src/auth/codex.rs @@ -169,7 +169,7 @@ pub fn load_auth_file() -> Result { if relabel_accounts(&mut auth) { crate::logging::info( - "Renaming OpenAI accounts to numbered labels (openai-1, openai-2, ...)", + "Renaming OpenAI accounts to animal labels (openai-otter, openai-fox, ...)", ); save_auth_file(&auth)?; } diff --git a/crates/jcode-base/src/auth/codex_tests.rs b/crates/jcode-base/src/auth/codex_tests.rs index a9c69b81f8..a235351baa 100644 --- a/crates/jcode-base/src/auth/codex_tests.rs +++ b/crates/jcode-base/src/auth/codex_tests.rs @@ -396,7 +396,7 @@ fn load_credentials_reads_legacy_oauth_without_changing_external_permissions() { } #[test] -fn load_auth_file_renames_existing_labels_to_numbered_scheme() { +fn load_auth_file_renames_existing_labels_to_animal_scheme() { let _lock = crate::storage::lock_test_env(); let temp = tempfile::TempDir::new().unwrap(); let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); @@ -429,7 +429,7 @@ fn load_auth_file_renames_existing_labels_to_numbered_scheme() { .iter() .map(|account| account.label.as_str()) .collect::>(), - vec!["openai-1", "openai-2"] + vec!["openai-otter", "openai-fox"] ); - assert_eq!(auth.active_openai_account.as_deref(), Some("openai-2")); + assert_eq!(auth.active_openai_account.as_deref(), Some("openai-fox")); } diff --git a/crates/jcode-tui/src/tui/app/auth_account_picker.rs b/crates/jcode-tui/src/tui/app/auth_account_picker.rs index 29ad1da7e0..49e4d8b1de 100644 --- a/crates/jcode-tui/src/tui/app/auth_account_picker.rs +++ b/crates/jcode-tui/src/tui/app/auth_account_picker.rs @@ -1,4 +1,5 @@ use super::*; +use super::auth_account_picker_saved_accounts::{account_display_name, anthropic_account_use}; impl App { pub(crate) fn open_account_center(&mut self, provider_filter: Option<&str>) { @@ -511,7 +512,7 @@ impl App { selected = idx; } models.push(crate::tui::PickerEntry { - name: account.label.clone(), + name: account_display_name("Claude", &account.label, claude_accounts.len()), options: vec![crate::tui::PickerOption { provider: "Claude".to_string(), api_method: if is_active { @@ -520,7 +521,13 @@ impl App { "saved".to_string() }, available: true, - detail: format!("{} - {} - plan {}", email, status, plan), + detail: format!( + "{} - {} - {} - plan {}", + email, + anthropic_account_use(account.subscription_type.as_deref()), + status, + plan + ), estimated_reference_cost_micros: None, }], action: crate::tui::PickerAction::Account( @@ -560,7 +567,7 @@ impl App { selected = idx; } models.push(crate::tui::PickerEntry { - name: account.label.clone(), + name: account_display_name("OpenAI", &account.label, openai_accounts.len()), options: vec![crate::tui::PickerOption { provider: "OpenAI".to_string(), api_method: if is_active { @@ -769,7 +776,7 @@ impl App { .unwrap_or_else(|| "unknown".to_string()); let plan = account.subscription_type.as_deref().unwrap_or("unknown"); models.push(crate::tui::PickerEntry { - name: account.label.clone(), + name: account_display_name("Claude", &account.label, accounts.len()), options: vec![crate::tui::PickerOption { provider: "Claude".to_string(), api_method: if is_active { @@ -778,7 +785,13 @@ impl App { "saved".to_string() }, available: true, - detail: format!("{} - {} - plan {}", email, status, plan), + detail: format!( + "{} - {} - {} - plan {}", + email, + anthropic_account_use(account.subscription_type.as_deref()), + status, + plan + ), estimated_reference_cost_micros: None, }], action: crate::tui::PickerAction::Account( diff --git a/crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs b/crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs index a10171d990..059138fcdb 100644 --- a/crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs +++ b/crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs @@ -36,7 +36,7 @@ impl App { if accounts.is_empty() { return "OpenAI Accounts: none configured\n\n\ - Use /account openai add to add the next numbered account, or /login openai to refresh the active one." + Use /account openai add to add another account, or /login openai to refresh the active one." .to_string(); } @@ -57,7 +57,7 @@ impl App { let account_id = account.account_id.as_deref().unwrap_or("unknown"); let active_mark = if is_active { "active" } else { "" }; rows.push([ - account.label.clone(), + account_display_name("OpenAI", &account.label, accounts.len()), email, status.to_string(), account_id.to_string(), @@ -83,11 +83,11 @@ impl App { if accounts.is_empty() { return "Anthropic Accounts: none configured\n\n\ - Use /account claude add to add the next numbered account, or /login claude to refresh the active one." + Use /account claude add to add another account, or /login claude to refresh the active one." .to_string(); } - let headers = ["Account", "Email", "Status", "Subscription", "Active"]; + let headers = ["Account", "Email", "Status", "Use", "Subscription"]; let mut rows: Vec<[String; 5]> = Vec::new(); for account in &accounts { let is_active = active_label.as_deref() == Some(&account.label); @@ -102,13 +102,18 @@ impl App { .map(mask_email) .unwrap_or_else(|| "unknown".to_string()); let sub = account.subscription_type.as_deref().unwrap_or("unknown"); - let active_mark = if is_active { "active" } else { "" }; + let account_use = anthropic_account_use(account.subscription_type.as_deref()); + let sub = if is_active { + format!("{sub} (active)") + } else { + sub.to_string() + }; rows.push([ - account.label.clone(), + account_display_name("Claude", &account.label, accounts.len()), email, status.to_string(), - sub.to_string(), - active_mark.to_string(), + account_use.to_string(), + sub, ]); } @@ -130,7 +135,8 @@ impl App { ) { let active_label = crate::auth::claude::active_account_label(); let now_ms = chrono::Utc::now().timestamp_millis(); - for account in crate::auth::claude::list_accounts().unwrap_or_default() { + let accounts = crate::auth::claude::list_accounts().unwrap_or_default(); + for account in &accounts { let status = if account.expires > now_ms { "valid" } else { @@ -142,7 +148,9 @@ impl App { .map(mask_email) .unwrap_or_else(|| "unknown".to_string()); let plan = account.subscription_type.as_deref().unwrap_or("unknown"); + let account_use = anthropic_account_use(account.subscription_type.as_deref()); let label = account.label.clone(); + let display_name = account_display_name("Claude", &label, accounts.len()); let active_suffix = if active_label.as_deref() == Some(label.as_str()) { " - active" } else { @@ -151,8 +159,8 @@ impl App { items.push(crate::tui::account_picker::AccountPickerItem::action( provider.id, provider.display_name, - format!("Switch account `{label}`"), - format!("{email} - {status} - plan {plan}{active_suffix}"), + format!("Switch {display_name}"), + format!("{email} - {account_use} - {status} - plan {plan}{active_suffix}"), crate::tui::account_picker::AccountPickerCommand::SubmitInput(format!( "/account {} switch {}", provider.id, label @@ -188,7 +196,8 @@ impl App { ) { let active_label = crate::auth::codex::active_account_label(); let now_ms = chrono::Utc::now().timestamp_millis(); - for account in crate::auth::codex::list_accounts().unwrap_or_default() { + let accounts = crate::auth::codex::list_accounts().unwrap_or_default(); + for account in &accounts { let status = match account.expires_at { Some(expires_at) if expires_at > now_ms => "valid", Some(_) => "expired", @@ -201,6 +210,7 @@ impl App { .unwrap_or_else(|| "unknown".to_string()); let account_id = account.account_id.as_deref().unwrap_or("unknown"); let label = account.label.clone(); + let display_name = account_display_name("OpenAI", &label, accounts.len()); let active_suffix = if active_label.as_deref() == Some(label.as_str()) { " - active" } else { @@ -209,7 +219,7 @@ impl App { items.push(crate::tui::account_picker::AccountPickerItem::action( provider.id, provider.display_name, - format!("Switch account `{label}`"), + format!("Switch {display_name}"), format!("{email} - {status} - acct {account_id}{active_suffix}"), crate::tui::account_picker::AccountPickerCommand::SubmitInput(format!( "/account {} switch {}", @@ -240,6 +250,60 @@ impl App { } } +/// A provider name is enough when there is only one login. Animal names are +/// useful only when multiple logins of that provider need distinguishing. +pub(super) fn account_display_name(provider: &str, label: &str, account_count: usize) -> String { + if account_count <= 1 { + return provider.to_string(); + } + let animal = label + .rsplit_once('-') + .map(|(_, animal)| animal) + .unwrap_or(label); + let mut chars = animal.chars(); + let animal = chars + .next() + .map(|first| first.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_else(|| "Account".to_string()); + format!("{provider} {animal}") +} + +/// Anthropic exposes the subscription kind but not an explicit work/personal +/// flag. Team and enterprise plans are organizational; individual plans are +/// personal. Unknown values stay unknown rather than being guessed from email. +pub(super) fn anthropic_account_use(subscription_type: Option<&str>) -> &'static str { + match subscription_type.map(str::to_ascii_lowercase).as_deref() { + Some("team" | "enterprise" | "business") => "work", + Some("free" | "pro" | "max") => "personal", + _ => "unknown", + } +} + +#[cfg(test)] +mod account_display_tests { + use super::*; + + #[test] + fn animals_only_distinguish_duplicate_provider_logins() { + assert_eq!(account_display_name("Claude", "claude-otter", 1), "Claude"); + assert_eq!( + account_display_name("Claude", "claude-otter", 2), + "Claude Otter" + ); + assert_eq!( + account_display_name("Claude", "claude-fox", 2), + "Claude Fox" + ); + } + + #[test] + fn known_anthropic_plans_identify_personal_and_work_accounts() { + assert_eq!(anthropic_account_use(Some("max")), "personal"); + assert_eq!(anthropic_account_use(Some("team")), "work"); + assert_eq!(anthropic_account_use(None), "unknown"); + } +} + fn format_account_table(headers: &[&str; 5], rows: &[[String; 5]]) -> Vec { let mut widths = [0usize; 5]; for (i, h) in headers.iter().enumerate() { From a0ccd7a591b9e3e4773653e65924abd2aa3fde7d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:31:57 -0700 Subject: [PATCH 064/107] Validate intermediate background progress --- crates/jcode-background-types/src/lib.rs | 27 ++++++++++++++- crates/jcode-base/src/background.rs | 14 +++++++- crates/jcode-base/src/background/tests.rs | 40 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/jcode-background-types/src/lib.rs b/crates/jcode-background-types/src/lib.rs index 3af060ca27..3d34c56de3 100644 --- a/crates/jcode-background-types/src/lib.rs +++ b/crates/jcode-background-types/src/lib.rs @@ -43,8 +43,10 @@ impl BackgroundTaskProgress { pub fn normalize(mut self) -> Self { if let (Some(current), Some(total)) = (self.current, self.total) && total > 0 - && self.percent.is_none() { + // Counts are the directly measurable work units, so they are the + // source of truth when a producer also supplies a percentage. This + // prevents contradictory updates such as `2/10` paired with 80%. let computed = (current as f64 / total as f64) * 100.0; self.percent = Some(((computed * 100.0).round() / 100.0) as f32); } @@ -64,6 +66,29 @@ impl BackgroundTaskProgress { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn measurable_counts_override_a_conflicting_reported_percent() { + let progress = BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(80.0), + message: None, + current: Some(2), + total: Some(10), + unit: Some("tests".into()), + eta_seconds: None, + updated_at: "now".into(), + source: BackgroundTaskProgressSource::Reported, + } + .normalize(); + + assert_eq!(progress.percent, Some(20.0)); + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct BackgroundTaskProgressEvent { pub task_id: String, diff --git a/crates/jcode-base/src/background.rs b/crates/jcode-base/src/background.rs index d6b2a981fe..ff1f71d5e9 100644 --- a/crates/jcode-base/src/background.rs +++ b/crates/jcode-base/src/background.rs @@ -1058,6 +1058,18 @@ impl BackgroundTaskManager { return Ok(Some(status)); } + // A determinate bar represents progress through the whole task. A + // lower later value would make that representation knowingly + // inconsistent, regardless of whether it was explicitly reported + // or inferred from output. Keep the last trustworthy high-water + // mark instead of allowing the UI to move backwards. + if let (Some(existing_percent), Some(new_percent)) = + (existing.percent, progress.percent) + && new_percent < existing_percent + { + return Ok(Some(status)); + } + let existing_is_more_determinate = existing.percent.is_some() || matches!((existing.current, existing.total), (_, Some(total)) if total > 0); let new_is_less_determinate = progress.percent.is_none() @@ -1324,7 +1336,7 @@ impl BackgroundTaskManager { wake: wake_flag, progress: None, event_history: Vec::new(), - stall_wake_seconds: None, + stall_wake_seconds: None, }; let event_status = final_status.status.clone(); let event_exit_code = final_status.exit_code; diff --git a/crates/jcode-base/src/background/tests.rs b/crates/jcode-base/src/background/tests.rs index 79f06f2d2b..cb60603d19 100644 --- a/crates/jcode-base/src/background/tests.rs +++ b/crates/jcode-base/src/background/tests.rs @@ -155,6 +155,46 @@ async fn update_progress_persists_status_and_emits_bus_event() -> Result<()> { )) } +#[tokio::test] +async fn update_progress_keeps_the_determinate_high_water_mark() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + let info = manager + .spawn_with_notify( + "bash", + None, + "session-monotonic-progress", + false, + false, + |_output_path| async move { + sleep(Duration::from_secs(2)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + let progress = |percent| BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(percent), + message: None, + current: None, + total: None, + unit: None, + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + }; + + manager.update_progress(&info.task_id, progress(60.0)).await?; + let status = manager + .update_progress(&info.task_id, progress(25.0)) + .await? + .ok_or_else(|| anyhow!("task should exist"))?; + + assert_eq!(status.progress.and_then(|value| value.percent), Some(60.0)); + Ok(()) +} + #[tokio::test] async fn wait_returns_when_task_finishes() -> Result<()> { let tmp = tempdir()?; From 44eed54afc43f2f78e3b3b76e1a58d5448d2f42a Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:39:37 -0700 Subject: [PATCH 065/107] docs: clarify agent feedback privacy contract --- TELEMETRY.md | 8 +++++++- crates/jcode-app-core/src/tool/feedback.rs | 24 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index 85d7e667f9..586383c83e 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -95,10 +95,16 @@ Recent telemetry additions also include: coarse onboarding steps, explicit thumb | Field | Example | Purpose | |-------|---------|----------| | `event` | `"feedback"` | Event type | -| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback explicitly submitted with `/feedback ...` | +| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback submitted with `/feedback ...` or the `maintainer_feedback` agent tool | | `feedback_rating` | `"up"` / `"down"` | Legacy explicit product sentiment, if present | | `feedback_reason` | `"slow"` | Legacy optional coarse reason bucket, if present | +The `maintainer_feedback` tool is available only as another explicit telemetry +path: it obeys the same telemetry opt-out as `/feedback` and sends no event when +telemetry is disabled. Its schema tells the agent to paraphrase, omit secrets and +private data, and label whether the report originated with the user, the agent, +or both. Jcode does not attach transcript content, repository files, or paths. + ### Sponsored Discovery Event One event is sent after each `discover_tools` attempt. A random per-request ID diff --git a/crates/jcode-app-core/src/tool/feedback.rs b/crates/jcode-app-core/src/tool/feedback.rs index 8857234af3..c3f162d53a 100644 --- a/crates/jcode-app-core/src/tool/feedback.rs +++ b/crates/jcode-app-core/src/tool/feedback.rs @@ -164,4 +164,28 @@ mod tests { }); assert!(oversized.unwrap_err().to_string().contains("at most 240")); } + + #[test] + fn schema_requires_provenance_and_carries_privacy_guidance() { + let tool = MaintainerFeedbackTool::new(); + let schema = tool.parameters_schema(); + assert_eq!(schema["required"], json!(["category", "origin", "summary"])); + assert_eq!( + schema["properties"]["origin"]["enum"], + json!(["user", "agent", "mixed"]) + ); + assert!( + schema["properties"]["summary"]["description"] + .as_str() + .unwrap() + .contains("Paraphrase") + ); + assert!( + schema["properties"]["details"]["description"] + .as_str() + .unwrap() + .contains("Never include private data") + ); + assert!(tool.description().contains("telemetry settings")); + } } From ce9e9fe4d441d682b436c5f8717a2d315d26b862 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:40:37 -0700 Subject: [PATCH 066/107] fix(tui): color pinned todo intent state --- crates/jcode-tui/src/tui/ui_messages.rs | 13 +++++++++++-- crates/jcode-tui/src/tui/ui_messages/tests.rs | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/jcode-tui/src/tui/ui_messages.rs b/crates/jcode-tui/src/tui/ui_messages.rs index 7eae62afa9..86a4fd6788 100644 --- a/crates/jcode-tui/src/tui/ui_messages.rs +++ b/crates/jcode-tui/src/tui/ui_messages.rs @@ -1449,8 +1449,17 @@ fn push_todo_plan_details( .map(str::trim) .filter(|value| !value.is_empty()); if let Some(state) = plan.understands_user_intent { - let label = format!("Intent {}: ", state.as_str()); - let mut spans = vec![Span::styled(label, Style::default().fg(todo_label_color()))]; + let state_color = match state { + crate::todo::IntentUnderstanding::Uncertain => todo_failure_color(), + crate::todo::IntentUnderstanding::Partial => todo_warning_color(), + crate::todo::IntentUnderstanding::Clear + | crate::todo::IntentUnderstanding::Complete => todo_score_color(), + }; + let mut spans = vec![ + Span::styled("Intent ", Style::default().fg(todo_label_color())), + Span::styled(state.as_str().to_string(), Style::default().fg(state_color)), + Span::styled(": ", Style::default().fg(todo_label_color())), + ]; if let Some(intention) = intention { spans.push(Span::styled( intention.to_string(), diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index cc04b824ff..e69f0db36f 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -719,6 +719,7 @@ fn render_todos_message_uses_readable_semantic_colors() { }; assert_eq!(color_for("todo rendering"), Some(todo_group_color())); + assert_eq!(color_for("clear"), Some(todo_score_color())); assert_eq!(color_for("Readable metadata"), Some(todo_meta_color())); assert_eq!(color_for("● "), Some(asap_color())); assert_eq!(color_for(" (high)"), None); From 7d3f427c0c6913f65baf1cb57e67dd6966c4c392 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:41:48 -0700 Subject: [PATCH 067/107] Keep background row render test UI-specific --- crates/jcode-tui/src/tui/app/tests/todo_card.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/app/tests/todo_card.rs b/crates/jcode-tui/src/tui/app/tests/todo_card.rs index a62876d49f..556943ca0e 100644 --- a/crates/jcode-tui/src/tui/app/tests/todo_card.rs +++ b/crates/jcode-tui/src/tui/app/tests/todo_card.rs @@ -446,7 +446,9 @@ fn background_task_rows_render_without_todos_or_transcript_cards() { "missing failed task row:\n{rendered}" ); assert!(!rendered.contains("Background tasks")); - assert!(!rendered.contains("╭") && !rendered.contains("╰")); + assert!(!rendered.contains("Background task started")); + assert!(!rendered.contains("Background task progress")); + assert!(!rendered.contains("Background task completed")); } #[test] From c443e7b55a37a110085f991ec0a44a108ffcd974 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:41:57 -0700 Subject: [PATCH 068/107] fix: require consent for user feedback --- TELEMETRY.md | 4 +- crates/jcode-app-core/src/tool/feedback.rs | 43 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index 586383c83e..f85e421d06 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -103,7 +103,9 @@ The `maintainer_feedback` tool is available only as another explicit telemetry path: it obeys the same telemetry opt-out as `/feedback` and sends no event when telemetry is disabled. Its schema tells the agent to paraphrase, omit secrets and private data, and label whether the report originated with the user, the agent, -or both. Jcode does not attach transcript content, repository files, or paths. +or both. User-originated and mixed reports are rejected unless the user explicitly +approved sharing them; agent-only technical observations do not need per-report +approval. Jcode does not attach transcript content, repository files, or paths. ### Sponsored Discovery Event diff --git a/crates/jcode-app-core/src/tool/feedback.rs b/crates/jcode-app-core/src/tool/feedback.rs index c3f162d53a..b34ebd9851 100644 --- a/crates/jcode-app-core/src/tool/feedback.rs +++ b/crates/jcode-app-core/src/tool/feedback.rs @@ -20,6 +20,7 @@ impl MaintainerFeedbackTool { struct FeedbackInput { category: FeedbackCategory, origin: FeedbackOrigin, + user_confirmed: bool, summary: String, #[serde(default)] details: Option, @@ -55,6 +56,10 @@ fn limited(value: &str, label: &str, max: usize) -> Result { } fn payload(input: FeedbackInput) -> Result { + if matches!(input.origin, FeedbackOrigin::User | FeedbackOrigin::Mixed) && !input.user_confirmed + { + bail!("user_confirmed must be true for user or mixed-origin feedback"); + } let summary = limited(&input.summary, "summary", MAX_SUMMARY_CHARS)?; let details = input .details @@ -84,7 +89,7 @@ impl Tool for MaintainerFeedbackTool { fn parameters_schema(&self) -> Value { json!({ "type": "object", - "required": ["category", "origin", "summary"], + "required": ["category", "origin", "user_confirmed", "summary"], "properties": { "intent": super::intent_schema_property(), "category": { @@ -97,6 +102,10 @@ impl Tool for MaintainerFeedbackTool { "enum": ["user", "agent", "mixed"], "description": "Whether this reflects the user's words, the agent's observation, or both." }, + "user_confirmed": { + "type": "boolean", + "description": "True only if the user approved sharing user-originated feedback. Agent observations may use false." + }, "summary": { "type": "string", "minLength": 1, @@ -136,6 +145,7 @@ mod tests { let text = payload(FeedbackInput { category: FeedbackCategory::Praise, origin: FeedbackOrigin::Mixed, + user_confirmed: true, summary: "The new picker is much easier to use".into(), details: Some("The labels make model differences clear.".into()), }) @@ -151,6 +161,7 @@ mod tests { let empty = payload(FeedbackInput { category: FeedbackCategory::Bug, origin: FeedbackOrigin::Agent, + user_confirmed: false, summary: " ".into(), details: None, }); @@ -159,6 +170,7 @@ mod tests { let oversized = payload(FeedbackInput { category: FeedbackCategory::Other, origin: FeedbackOrigin::User, + user_confirmed: true, summary: "x".repeat(MAX_SUMMARY_CHARS + 1), details: None, }); @@ -169,7 +181,10 @@ mod tests { fn schema_requires_provenance_and_carries_privacy_guidance() { let tool = MaintainerFeedbackTool::new(); let schema = tool.parameters_schema(); - assert_eq!(schema["required"], json!(["category", "origin", "summary"])); + assert_eq!( + schema["required"], + json!(["category", "origin", "user_confirmed", "summary"]) + ); assert_eq!( schema["properties"]["origin"]["enum"], json!(["user", "agent", "mixed"]) @@ -188,4 +203,28 @@ mod tests { ); assert!(tool.description().contains("telemetry settings")); } + + #[test] + fn user_origin_requires_explicit_confirmation() { + let error = payload(FeedbackInput { + category: FeedbackCategory::Praise, + origin: FeedbackOrigin::User, + user_confirmed: false, + summary: "The user likes the new workflow".into(), + details: None, + }) + .unwrap_err(); + assert!(error.to_string().contains("user_confirmed must be true")); + + assert!( + payload(FeedbackInput { + category: FeedbackCategory::Bug, + origin: FeedbackOrigin::Agent, + user_confirmed: false, + summary: "The agent observed a reproducible tool error".into(), + details: None, + }) + .is_ok() + ); + } } From 269d7af1aa80c3416dc19dc1b684302206eafbe1 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:42:21 -0700 Subject: [PATCH 069/107] Update account picker integration fixtures --- .../src/tui/app/tests/commands_accounts_02/part_01.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs index fb639cbed5..5507aefc8b 100644 --- a/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs @@ -266,9 +266,11 @@ fn test_account_command_opens_account_picker() { crate::tui::PickerAction::Account(crate::tui::AccountPickerAction::Switch { ref provider_id, ref label - }) if provider_id == "claude" && label == "claude-1" + }) if provider_id == "claude" && label == "claude-otter" ) })); + assert!(picker.entries.iter().any(|entry| entry.name == "Claude")); + assert!(picker.entries.iter().any(|entry| entry.name == "OpenAI")); assert!(picker.entries.iter().any(|entry| { matches!( entry.action, @@ -451,7 +453,7 @@ fn test_account_command_combines_claude_and_openai_accounts() { crate::tui::PickerAction::Account(crate::tui::AccountPickerAction::Switch { ref provider_id, ref label - }) if provider_id == "claude" && label == "claude-1" + }) if provider_id == "claude" && label == "claude-otter" ) })); assert!(picker.entries.iter().any(|entry| { @@ -460,7 +462,7 @@ fn test_account_command_combines_claude_and_openai_accounts() { crate::tui::PickerAction::Account(crate::tui::AccountPickerAction::Switch { ref provider_id, ref label - }) if provider_id == "openai" && label == "openai-1" + }) if provider_id == "openai" && label == "openai-otter" ) })); assert!( From c8189b5099c3750f9d1d5b905a53eb85fde44db2 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:43:10 -0700 Subject: [PATCH 070/107] Show animal names in provider account picker --- crates/jcode-tui/src/tui/app/auth_account_picker.rs | 2 +- .../src/tui/app/tests/commands_accounts_02/part_01.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/app/auth_account_picker.rs b/crates/jcode-tui/src/tui/app/auth_account_picker.rs index 49e4d8b1de..0c316fea15 100644 --- a/crates/jcode-tui/src/tui/app/auth_account_picker.rs +++ b/crates/jcode-tui/src/tui/app/auth_account_picker.rs @@ -932,7 +932,7 @@ impl App { .unwrap_or_else(|| "unknown".to_string()); let account_id = account.account_id.as_deref().unwrap_or("unknown"); models.push(crate::tui::PickerEntry { - name: account.label.clone(), + name: account_display_name("OpenAI", &account.label, accounts.len()), options: vec![crate::tui::PickerOption { provider: "OpenAI".to_string(), api_method: if is_active { diff --git a/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs index 5507aefc8b..d743615dab 100644 --- a/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/commands_accounts_02/part_01.rs @@ -336,6 +336,9 @@ fn test_account_picker_supports_arrow_and_vim_navigation() { .as_ref() .expect("inline account picker should open") .selected; + let picker = app.inline_interactive_state.as_ref().unwrap(); + assert!(picker.entries.iter().any(|entry| entry.name == "OpenAI Otter")); + assert!(picker.entries.iter().any(|entry| entry.name == "OpenAI Fox")); app.handle_key(KeyCode::Down, KeyModifiers::empty()) .unwrap(); From 748435010b84eced475fd648a042d750ad450cb3 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:44:33 -0700 Subject: [PATCH 071/107] Serialize background progress updates --- crates/jcode-base/src/background.rs | 26 +++++-- crates/jcode-base/src/background/tests.rs | 91 +++++++++++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/crates/jcode-base/src/background.rs b/crates/jcode-base/src/background.rs index ff1f71d5e9..e8ee8c9085 100644 --- a/crates/jcode-base/src/background.rs +++ b/crates/jcode-base/src/background.rs @@ -5,7 +5,7 @@ use crate::bus::{ BackgroundTaskCompleted, BackgroundTaskProgress, BackgroundTaskProgressEvent, - BackgroundTaskProgressSource, BackgroundTaskStatus, Bus, BusEvent, + BackgroundTaskStatus, Bus, BusEvent, }; use anyhow::Result; use chrono::{DateTime, Utc}; @@ -15,7 +15,7 @@ use std::sync::Arc; use std::time::Instant; use tokio::fs::{self, File}; use tokio::io::AsyncWriteExt; -use tokio::sync::{RwLock, watch}; +use tokio::sync::{Mutex, RwLock, watch}; use tokio::task::JoinHandle; use tokio::time::{Duration, Instant as TokioInstant, MissedTickBehavior}; @@ -35,6 +35,9 @@ use model::{ /// Manages background task execution pub struct BackgroundTaskManager { tasks: Arc>>, + /// Serializes progress status read-modify-write cycles so concurrent output + /// readers cannot overwrite a newer high-water mark with a stale update. + progress_updates: Arc>, /// Live stall watchdogs by task id. Each entry is a spawned monitor that /// fires a [`BusEvent::BackgroundTaskStalled`] after its task produces no /// output bytes and no progress events for the configured window. @@ -50,6 +53,7 @@ impl BackgroundTaskManager { std::fs::create_dir_all(&output_dir).ok(); Self { tasks: Arc::new(RwLock::new(HashMap::new())), + progress_updates: Arc::new(Mutex::new(())), stall_watchdogs: Arc::new(RwLock::new(HashMap::new())), output_dir, } @@ -1047,12 +1051,13 @@ impl BackgroundTaskManager { progress: BackgroundTaskProgress, event_kind: BackgroundTaskEventKind, ) -> Result> { + let _progress_update_guard = self.progress_updates.lock().await; let status_path = self.status_path_for(task_id); let Some(mut status) = self.read_status_file(&status_path).await else { return Ok(None); }; - let progress = progress.normalize(); + let mut progress = progress.normalize(); if let Some(existing) = status.progress.as_ref() { if progress_equivalent(existing, &progress) { return Ok(Some(status)); @@ -1074,11 +1079,16 @@ impl BackgroundTaskManager { || matches!((existing.current, existing.total), (_, Some(total)) if total > 0); let new_is_less_determinate = progress.percent.is_none() && !matches!((progress.current, progress.total), (_, Some(total)) if total > 0); - if existing_is_more_determinate - && new_is_less_determinate - && matches!(progress.source, BackgroundTaskProgressSource::ParsedOutput) - { - return Ok(Some(status)); + if existing_is_more_determinate && new_is_less_determinate { + // Preserve the measurable high-water mark while still allowing + // a new checkpoint/phase message to reach status and clients. + progress.kind = existing.kind.clone(); + progress.percent = existing.percent; + progress.current = existing.current; + progress.total = existing.total; + if progress.unit.is_none() { + progress.unit.clone_from(&existing.unit); + } } } diff --git a/crates/jcode-base/src/background/tests.rs b/crates/jcode-base/src/background/tests.rs index cb60603d19..2489a3a92c 100644 --- a/crates/jcode-base/src/background/tests.rs +++ b/crates/jcode-base/src/background/tests.rs @@ -195,6 +195,97 @@ async fn update_progress_keeps_the_determinate_high_water_mark() -> Result<()> { Ok(()) } +#[tokio::test] +async fn update_progress_preserves_high_water_mark_through_reported_checkpoint() -> Result<()> { + let tmp = tempdir()?; + let manager = BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf()); + let info = manager + .spawn_with_notify( + "bash", + None, + "session-checkpoint-progress", + false, + false, + |_output_path| async move { + sleep(Duration::from_secs(2)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + let mut progress = BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(60.0), + message: Some("running".into()), + current: Some(6), + total: Some(10), + unit: Some("tests".into()), + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + }; + manager.update_progress(&info.task_id, progress.clone()).await?; + + progress.kind = BackgroundTaskProgressKind::Indeterminate; + progress.percent = None; + progress.current = None; + progress.total = None; + progress.unit = None; + progress.message = Some("linking".into()); + let status = manager + .update_checkpoint(&info.task_id, progress) + .await? + .ok_or_else(|| anyhow!("task should exist"))?; + let stored = status.progress.ok_or_else(|| anyhow!("progress missing"))?; + + assert_eq!(stored.percent, Some(60.0)); + assert_eq!(stored.current, Some(6)); + assert_eq!(stored.total, Some(10)); + assert_eq!(stored.message.as_deref(), Some("linking")); + Ok(()) +} + +#[tokio::test] +async fn concurrent_progress_updates_cannot_overwrite_the_high_water_mark() -> Result<()> { + let tmp = tempdir()?; + let manager = Arc::new(BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf())); + let info = manager + .spawn_with_notify( + "bash", + None, + "session-concurrent-progress", + false, + false, + |_output_path| async move { + sleep(Duration::from_secs(2)).await; + Ok(TaskResult::completed(Some(0))) + }, + ) + .await; + + let update = |percent| BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(percent), + message: None, + current: None, + total: None, + unit: None, + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + }; + let first = manager.update_progress(&info.task_id, update(80.0)); + let second = manager.update_progress(&info.task_id, update(20.0)); + tokio::try_join!(first, second)?; + + let status = manager + .status(&info.task_id) + .await + .ok_or_else(|| anyhow!("task should exist"))?; + assert_eq!(status.progress.and_then(|value| value.percent), Some(80.0)); + Ok(()) +} + #[tokio::test] async fn wait_returns_when_task_finishes() -> Result<()> { let tmp = tempdir()?; From ba80055e13bf8db643c0f14d27ad74859a3840bb Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:45:50 -0700 Subject: [PATCH 072/107] feat(tui): color todo intent understanding --- crates/jcode-tui/src/tui/ui_messages/tests.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index e69f0db36f..a381e6a0f0 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -729,6 +729,43 @@ fn render_todos_message_uses_readable_semantic_colors() { assert_ne!(todo_meta_color(), dim_color()); } +#[test] +fn render_todos_message_color_codes_every_intent_state() { + let cases = [ + (crate::todo::IntentUnderstanding::Uncertain, todo_failure_color()), + (crate::todo::IntentUnderstanding::Partial, todo_warning_color()), + (crate::todo::IntentUnderstanding::Clear, todo_score_color()), + (crate::todo::IntentUnderstanding::Complete, todo_score_color()), + ]; + + for (state, expected_color) in cases { + let state_text = state.as_str().to_string(); + let msg = DisplayMessage::todos( + serde_json::json!({ + "todos": [], + "plan": { + "user_intention": "Keep intent visible", + "understands_user_intent": state, + }, + "goals": [], + }) + .to_string(), + ); + let lines = render_todos_message(&msg, 100, crate::config::DiffDisplayMode::Off); + let rendered_color = lines + .iter() + .flat_map(|line| line.spans.iter()) + .find(|span| span.content.as_ref() == state_text) + .and_then(|span| span.style.fg); + + assert_eq!( + rendered_color, + Some(expected_color), + "intent state {state_text} should keep its semantic color in the todo renderer" + ); + } +} + #[test] fn render_todos_message_collapses_passing_quality_gates() { let todos = vec![crate::todo::TodoItem { From 9cd2e4acd0af4cf998d50537339dbcc36458bb3a Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:46:00 -0700 Subject: [PATCH 073/107] style: apply rustfmt cleanup --- crates/jcode-app-core/src/server.rs | 7 +++---- crates/jcode-base/src/message/tests.rs | 18 +++++++++--------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs index e8b976310e..44d2468db5 100644 --- a/crates/jcode-app-core/src/server.rs +++ b/crates/jcode-app-core/src/server.rs @@ -52,10 +52,9 @@ mod util; pub(super) use self::await_members_state::AwaitMembersRuntime; use self::background_tasks::{ dispatch_background_task_completion, dispatch_background_task_progress, - dispatch_background_task_stalled, - dispatch_swarm_await_completion, dispatch_swarm_batch_progress, dispatch_swarm_output_tail, - dispatch_swarm_runtime_status, dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, - dispatch_ui_activity, + dispatch_background_task_stalled, dispatch_swarm_await_completion, + dispatch_swarm_batch_progress, dispatch_swarm_output_tail, dispatch_swarm_runtime_status, + dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, dispatch_ui_activity, }; use self::debug::{ClientConnectionInfo, ClientDebugState}; use self::debug_jobs::DebugJob; diff --git a/crates/jcode-base/src/message/tests.rs b/crates/jcode-base/src/message/tests.rs index 2ea77cbbf6..21c72a9a9f 100644 --- a/crates/jcode-base/src/message/tests.rs +++ b/crates/jcode-base/src/message/tests.rs @@ -856,8 +856,8 @@ fn reasoning_trace_serde_round_trip() { #[test] fn format_background_task_stalled_markdown_renders_tail_and_guidance() { - let rendered = format_background_task_stalled_markdown( - &jcode_background_types::BackgroundTaskStalled { + let rendered = + format_background_task_stalled_markdown(&jcode_background_types::BackgroundTaskStalled { task_id: "stall01".to_string(), tool_name: "bash".to_string(), display_name: Some("long training run".to_string()), @@ -868,8 +868,7 @@ fn format_background_task_stalled_markdown_renders_tail_and_guidance() { output_file: std::path::PathBuf::from("/tmp/output.log"), notify: true, wake: true, - }, - ); + }); assert!(rendered.contains( "**Background task stalled** `stall01` · `long training run` (`bash`) · no output or progress for 300s (running 1234s total)" @@ -882,8 +881,8 @@ fn format_background_task_stalled_markdown_renders_tail_and_guidance() { #[test] fn format_background_task_stalled_markdown_handles_empty_tail() { - let rendered = format_background_task_stalled_markdown( - &jcode_background_types::BackgroundTaskStalled { + let rendered = + format_background_task_stalled_markdown(&jcode_background_types::BackgroundTaskStalled { task_id: "stall02".to_string(), tool_name: "bash".to_string(), display_name: None, @@ -894,9 +893,10 @@ fn format_background_task_stalled_markdown_handles_empty_tail() { output_file: std::path::PathBuf::from("/tmp/output.log"), notify: true, wake: true, - }, - ); + }); - assert!(rendered.contains("**Background task stalled** `stall02` · `bash` · no output or progress for 60s")); + assert!(rendered.contains( + "**Background task stalled** `stall02` · `bash` · no output or progress for 60s" + )); assert!(rendered.contains("_No output captured yet._")); } From 3af1b54425875c54d7456d68f08b4da927a2f7f7 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:46:00 -0700 Subject: [PATCH 074/107] chore: add todo score shard helper --- score_shard.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 score_shard.py diff --git a/score_shard.py b/score_shard.py new file mode 100644 index 0000000000..12737b4183 --- /dev/null +++ b/score_shard.py @@ -0,0 +1,30 @@ +import json,re,collections +inp='/home/jeremy/jcode-transcript-export/handpick/digests/shard_01.json'; out='/home/jeremy/jcode-transcript-export/handpick/scores/shard_01.scores.json' +data=json.load(open(inp)) +def score(x): + ts=x.get('todos') or []; n=len(ts); calls=x.get('todo_calls',0) or 0; st=[str(t.get('status','')).lower() for t in ts]; cs=[str(t.get('content','')).strip() for t in ts]; ne=[c for c in cs if c]; comp=sum(s=='completed' for s in st); pending=sum(s in ('pending','in_progress') for s in st) + if not ne or all(len(c)<=12 for c in ne): base=0 + else: + avg=sum(map(len,ne))/len(ne); spec=sum(bool(re.search(r'\b(map|trace|audit|inspect|verify|enumerate|identify|classify|test|run|search|document|review|inventory|analy[sz]|implement|fix|write|report|check|validate)\b',c,re.I)) for c in ne)/len(ne) + if n>=3 and avg>=35 and spec>=.5: + base=8 + if comp>=max(2,n*.6): base=9 + if comp==n and calls>=3: base=10 + elif n>=2 and avg>=20: + base=5 if pending else 6 + if comp>=n*.5 and calls>=2: base=7 + else: base=2 if calls<=1 or pending else 4 + if x.get('is_debug'): base-=2 + base=max(0,min(10,base)) + if not ne: reason='No meaningful todo items.' + elif base<=1: reason='Boilerplate or junk todos with little planning value.' + elif base<=4: reason='Sparse or vague plan, with limited completion evidence.' + elif base<=7: reason='Reasonable plan, but generic or incompletely updated.' + elif comp==n and calls>=3: reason='Specific decomposed plan, repeatedly updated and fully completed.' + elif comp>=2: reason='Specific multi-step plan with substantial completion evidence.' + else: reason='Specific multi-step plan, but completion remains partial.' + if x.get('is_debug'): reason='Debug session penalty applied; '+reason[0].lower()+reason[1:] + return {'file':x.get('file'),'score':base,'reason':reason[:99]} +r=[score(x) for x in data] +with open(out,'w') as f: json.dump(r,f,ensure_ascii=False,indent=2); f.write('\n') +print(len(r),dict(sorted(collections.Counter(x['score'] for x in r).items()))) From 6af975beb2716bcee2f99aeea853d488695572d0 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:51:32 -0700 Subject: [PATCH 075/107] chore(release): prepare v0.77.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- changelog/index.json | 4 ++++ changelog/v0.77.0.json | 26 ++++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 changelog/v0.77.0.json diff --git a/Cargo.lock b/Cargo.lock index 5fcde07878..697a13061b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3385,7 +3385,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.76.0" +version = "0.77.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index e0edc19704..62d27534f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.76.0" +version = "0.77.0" description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false diff --git a/changelog/index.json b/changelog/index.json index 49e70e0ae4..5db1d7d487 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,5 +1,9 @@ { "entries": [ + { + "version": "0.77.0", + "date": "2026-08-17" + }, { "version": "0.76.0", "date": "2026-08-14" diff --git a/changelog/v0.77.0.json b/changelog/v0.77.0.json new file mode 100644 index 0000000000..dbeabd6ff7 --- /dev/null +++ b/changelog/v0.77.0.json @@ -0,0 +1,26 @@ +{ + "version": "0.77.0", + "date": "2026-08-17", + "title": "Background visibility and expanded authentication", + "highlights": [ + "Background tasks now appear in the pinned status band, report intermediate progress reliably, and wake stalled agents automatically", + "Cursor and Grok Build now support native authentication flows, with OrcaRouter available as a provider profile", + "Duplicate provider accounts now receive memorable animal names in the account picker" + ], + "improvements": [ + "Todo intent and understanding are shown inline with clearer status colors and expandable pinned details", + "Pinned todo, copy, and edit-expand controls can now be activated with the mouse", + "Session search finds late transcript content and preserves longer search prefixes", + "The SDK now exposes persisted session titles and reasoning-effort updates", + "Bash output is collapsed by default with a cleaner presentation", + "Users can submit privacy-conscious maintainer feedback after explicit consent" + ], + "fixes": [ + "Pasted content remains visible after sending", + "Hyphenated MCP tool names dispatch correctly", + "Repeated OpenRouter tool outputs are preserved", + "Provider selection honors an explicitly requested CLI provider", + "Setup hotkey uninstall actions are honored", + "macOS swarm Option shortcuts work correctly" + ] +} From 4c58869beeb3885e5d772054fd763176c992465c Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:52:01 -0700 Subject: [PATCH 076/107] fix: make remote releases repository-aware --- crates/jcode-tui/src/tui/app/commands.rs | 5 ++++- .../src/tui/app/tests/commands_accounts_01/part_01.rs | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 891d9944d8..02fcc803d3 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -2186,9 +2186,12 @@ pub(super) fn build_fast_macos_release_prompt() -> String { } pub(super) fn build_remote_release_prompt() -> String { - build_release_prompt( + let jcode_release = build_release_prompt( "", "Then run scripts/quick-release.sh --remote v to push the tag immediately without any local build. Let the release workflow build, sign, checksum, and publish every platform, and leave publication gated on those remote checks.", + ); + format!( + "First identify the repository in the current working directory from its git remote, release documentation, package manifests, existing tags, and CI workflows. Only use the following Jcode-specific procedure when this is the Jcode self-development repository and scripts/quick-release.sh exists: {jcode_release} Otherwise, use the repository's own established release conventions. Inspect its release documentation, workflows, scripts, manifests, tag format, and recent releases before changing anything. Make logical commits for current work without disturbing unrelated changes and push them normally. Determine the next version from this repository's versioning scheme and user-visible changes, update only the version files and changelog formats it actually uses, validate the metadata, commit and push it, then trigger the repository's documented remote release mechanism. Prefer a tag-triggered or workflow-dispatch CI release that performs builds and publication remotely. Do not assume the project uses Cargo, changelog JSON, v-prefixed tags, or scripts/quick-release.sh. Do not build release artifacts locally unless this repository explicitly requires it and no remote release path exists. Never force-push, move an existing tag, bypass remote release gates, or invent a release process. Report the detected release convention, version, commits, tag or workflow invocation, and remote release status." ) } diff --git a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs index a929248021..8b9d7432a8 100644 --- a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs @@ -711,6 +711,10 @@ fn test_remote_release_command_uses_tag_only_ci_path() { assert!(prompt.contains("quick-release.sh --remote")); assert!(prompt.contains("without any local build")); assert!(prompt.contains("publication gated")); + assert!(prompt.contains("Only use the following Jcode-specific procedure")); + assert!(prompt.contains("repository's own established release conventions")); + assert!(prompt.contains("Do not assume the project uses Cargo")); + assert!(prompt.contains("tag-triggered or workflow-dispatch CI release")); } #[test] From 3d164135a16d55e555349f8005990e402ce6037a Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:15:17 -0700 Subject: [PATCH 077/107] chore(release): prepare v0.77.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- changelog/index.json | 4 ++++ changelog/v0.77.1.json | 7 +++++++ 4 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 changelog/v0.77.1.json diff --git a/Cargo.lock b/Cargo.lock index 697a13061b..e9477c8ced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3385,7 +3385,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.77.0" +version = "0.77.1" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 62d27534f9..afb1cd8c03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.77.0" +version = "0.77.1" description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false diff --git a/changelog/index.json b/changelog/index.json index 5db1d7d487..26f2e174a0 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,5 +1,9 @@ { "entries": [ + { + "version": "0.77.1", + "date": "2026-08-17" + }, { "version": "0.77.0", "date": "2026-08-17" diff --git a/changelog/v0.77.1.json b/changelog/v0.77.1.json new file mode 100644 index 0000000000..6a6743ff96 --- /dev/null +++ b/changelog/v0.77.1.json @@ -0,0 +1,7 @@ +{ + "version": "0.77.1", + "date": "2026-08-17", + "fixes": [ + "The remote release command now detects the current repository and follows its own release conventions instead of assuming Jcode-specific tooling" + ] +} From 81e82db7de62aaf52ed3ca898d411710efed4ac7 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:13:20 -0700 Subject: [PATCH 078/107] fix: use MiniMax API key variable (fixes #969) --- crates/jcode-base/src/provider_catalog_tests.rs | 2 +- crates/jcode-provider-metadata/src/catalog.rs | 2 +- crates/jcode-provider-metadata/src/lib.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/jcode-base/src/provider_catalog_tests.rs b/crates/jcode-base/src/provider_catalog_tests.rs index 793efd8b57..c3d1619bbc 100644 --- a/crates/jcode-base/src/provider_catalog_tests.rs +++ b/crates/jcode-base/src/provider_catalog_tests.rs @@ -92,7 +92,7 @@ fn auth_issue_profile_metadata_matches_direct_provider_endpoints() { assert_eq!(DEEPSEEK_PROFILE.default_model, Some("deepseek-v4-flash")); assert_eq!(DEEPSEEK_PROFILE.setup_url, "https://api-docs.deepseek.com/"); assert_eq!(MINIMAX_PROFILE.api_base, "https://api.minimax.io/v1"); - assert_eq!(MINIMAX_PROFILE.api_key_env, "OPENAI_API_KEY"); + assert_eq!(MINIMAX_PROFILE.api_key_env, "MINIMAX_API_KEY"); assert_eq!( ALIBABA_CODING_PLAN_PROFILE.api_base, "https://coding-intl.dashscope.aliyuncs.com/v1" diff --git a/crates/jcode-provider-metadata/src/catalog.rs b/crates/jcode-provider-metadata/src/catalog.rs index ea8acf3512..52cb3e711f 100644 --- a/crates/jcode-provider-metadata/src/catalog.rs +++ b/crates/jcode-provider-metadata/src/catalog.rs @@ -320,7 +320,7 @@ pub const MINIMAX_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile { id: "minimax", display_name: "MiniMax", api_base: "https://api.minimax.io/v1", - api_key_env: "OPENAI_API_KEY", + api_key_env: "MINIMAX_API_KEY", env_file: "minimax.env", setup_url: "https://platform.minimax.io/docs/guides/text-generation", default_model: Some("MiniMax-M2.7"), diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index 4008c6206c..898298ee2b 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -450,7 +450,7 @@ mod tests { #[test] fn minimax_profile_uses_official_openai_compatible_configuration() { assert_eq!(MINIMAX_PROFILE.api_base, "https://api.minimax.io/v1"); - assert_eq!(MINIMAX_PROFILE.api_key_env, "OPENAI_API_KEY"); + assert_eq!(MINIMAX_PROFILE.api_key_env, "MINIMAX_API_KEY"); } #[test] From d51cda5a83f1364555ae0665dd2430f7fc619f8d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:13:20 -0700 Subject: [PATCH 079/107] docs: mark ambient mode implemented (fixes #965) --- docs/AMBIENT_MODE.md | 57 ++++++++++---------------------------------- 1 file changed, 13 insertions(+), 44 deletions(-) diff --git a/docs/AMBIENT_MODE.md b/docs/AMBIENT_MODE.md index 92fa3303a4..fd970978c6 100644 --- a/docs/AMBIENT_MODE.md +++ b/docs/AMBIENT_MODE.md @@ -1,7 +1,10 @@ # Ambient Mode -> **Status:** Design -> **Updated:** 2026-02-08 +> **Status:** Implemented +> **Updated:** 2026-08-16 +> +> Ambient mode is disabled by default. Enable it with `[ambient] enabled = true` +> and configure an available subscription or API provider before use. A proactive, always-on agent mode that works autonomously without user prompting. Like a brain consolidating memories during sleep, ambient mode tends to the memory graph, identifies useful work, and acts on the user's behalf — all while staying within resource limits. @@ -919,48 +922,14 @@ This is a distributed systems problem that will be addressed once ambient is sta --- -## Implementation Phases - -### Phase 1: Foundation -- [ ] Ambient agent loop (spawn, run, sleep) -- [ ] Single-instance guard -- [ ] Basic scheduling (fixed interval with max ceiling) -- [ ] Provider selection chain (OpenAI OAuth → Anthropic OAuth → pay-per-token opt-in → disabled) -- [ ] Configuration (`[ambient]` section in config) -- [ ] Storage layout - -### Phase 2: Memory Consolidation — Garden -- [ ] Full graph-wide dedup scan -- [ ] Fact verification against codebase -- [ ] Retroactive session extraction (crashed/missed sessions) -- [ ] Pruning dead memories (low confidence + low strength) -- [ ] Relationship discovery across sessions -- [ ] Embedding backfill -- [ ] Contradiction resolution - -### Phase 3: Scheduling -- [ ] `schedule_ambient` tool for agent self-scheduling -- [ ] Scheduled queue (persistent, with context) -- [ ] Adaptive resource calculator -- [ ] Usage history tracking -- [ ] Rate limit awareness (from provider response headers) -- [ ] Event triggers (session close, crash, git push) -- [ ] Active session detection → pause/throttle - -### Phase 4: Proactive Work -- [ ] Scout: analyze recent sessions + git history -- [ ] Infer user priorities from memories -- [ ] Identify actionable work -- [ ] Execute on separate branch -- [ ] Report results - -### Phase 5: Info Widget -- [ ] Ambient status display in TUI -- [ ] Queue preview -- [ ] Last cycle summary -- [ ] Next wake estimate -- [ ] Budget bar (user vs ambient vs remaining) +## Implementation Status + +Ambient mode is shipped. The runtime loop, persistent scheduling, memory garden, +proactive work flow, channel integration, configuration, tools, and TUI status +display are implemented. The source of truth for current behavior is +`crates/jcode-app-core/src/ambient/`; remaining enhancements are tracked as +GitHub issues rather than in the original design checklist. --- -*Last updated: 2026-02-08* +*Last updated: 2026-08-16* From 8517b8e854adf8a1f8044cc4eda1e9fa3be24999 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:14:27 -0700 Subject: [PATCH 080/107] test: cover MiniMax API key routing (fixes #969) --- crates/jcode-base/src/provider_catalog_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/jcode-base/src/provider_catalog_tests.rs b/crates/jcode-base/src/provider_catalog_tests.rs index c3d1619bbc..5b06646c8b 100644 --- a/crates/jcode-base/src/provider_catalog_tests.rs +++ b/crates/jcode-base/src/provider_catalog_tests.rs @@ -1141,7 +1141,7 @@ fn open_weight_family_context_limits_match_published_windows() { } #[test] -fn minimax_default_provider_applies_openai_api_key_env_not_openrouter() { +fn minimax_default_provider_applies_minimax_api_key_env_not_openrouter() { // Regression for #407: `default_provider = "minimax"` (the built-in MiniMax // profile) must resolve credentials from the profile's documented // OPENAI_API_KEY / minimax.env, not the generic OPENROUTER_API_KEY / @@ -1181,8 +1181,8 @@ fn minimax_default_provider_applies_openai_api_key_env_not_openrouter() { std::env::var("JCODE_OPENROUTER_API_KEY_NAME") .ok() .as_deref(), - Some("OPENAI_API_KEY"), - "MiniMax profile must use OPENAI_API_KEY, not OPENROUTER_API_KEY" + Some("MINIMAX_API_KEY"), + "MiniMax profile must use MINIMAX_API_KEY, not OPENROUTER_API_KEY" ); assert_eq!( std::env::var("JCODE_OPENROUTER_ENV_FILE").ok().as_deref(), From 3ec0293c37c7ab58ff6f776c33b8a3f8f9116563 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:20:00 -0700 Subject: [PATCH 081/107] test: isolate MiniMax credential regressions --- crates/jcode-base/src/provider_catalog_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/jcode-base/src/provider_catalog_tests.rs b/crates/jcode-base/src/provider_catalog_tests.rs index 5b06646c8b..f057ba33d6 100644 --- a/crates/jcode-base/src/provider_catalog_tests.rs +++ b/crates/jcode-base/src/provider_catalog_tests.rs @@ -196,7 +196,8 @@ fn resolved_named_profile_skips_non_chat_models_when_picking_newest_default() { #[test] fn minimax_token_plan_keys_resolve_to_china_endpoint_without_changing_international_default() { let _lock = crate::storage::lock_test_env(); - let _guard = EnvGuard::save(&["OPENAI_API_KEY"]); + let _guard = EnvGuard::save(&["MINIMAX_API_KEY", "OPENAI_API_KEY"]); + crate::env::remove_var("MINIMAX_API_KEY"); crate::env::remove_var("OPENAI_API_KEY"); let international = resolve_openai_compatible_profile(MINIMAX_PROFILE); @@ -1144,7 +1145,7 @@ fn open_weight_family_context_limits_match_published_windows() { fn minimax_default_provider_applies_minimax_api_key_env_not_openrouter() { // Regression for #407: `default_provider = "minimax"` (the built-in MiniMax // profile) must resolve credentials from the profile's documented - // OPENAI_API_KEY / minimax.env, not the generic OPENROUTER_API_KEY / + // MINIMAX_API_KEY / minimax.env, not the generic OPENROUTER_API_KEY / // openrouter.env. The earlier bug surfaced as // "OPENROUTER_API_KEY not found ..." when applying the configured // default_model. From ac05fea9315fa1242adba67ef95cf495390ef0d6 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:45:05 -0700 Subject: [PATCH 082/107] fix: preserve legacy MiniMax credentials --- crates/jcode-base/src/provider_catalog.rs | 12 +++++++++++- crates/jcode-base/src/provider_catalog_tests.rs | 9 ++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/jcode-base/src/provider_catalog.rs b/crates/jcode-base/src/provider_catalog.rs index 5aa81572eb..ec0efa52cc 100644 --- a/crates/jcode-base/src/provider_catalog.rs +++ b/crates/jcode-base/src/provider_catalog.rs @@ -45,6 +45,16 @@ pub fn resolve_openai_compatible_profile_with_api_key_hint( requires_api_key: profile.requires_api_key, }; + // MiniMax historically used OPENAI_API_KEY because it exposes an + // OpenAI-compatible API. Prefer the dedicated key, but keep existing + // installations working when only the legacy binding is configured. + if profile.id == MINIMAX_PROFILE.id + && load_env_value_from_env_or_config(profile.api_key_env, profile.env_file).is_none() + && load_env_value_from_env_or_config("OPENAI_API_KEY", profile.env_file).is_some() + { + resolved.api_key_env = "OPENAI_API_KEY".to_string(); + } + apply_profile_key_based_endpoint_overrides(profile, &mut resolved, api_key_hint); if profile.id != OPENAI_COMPAT_PROFILE.id { @@ -222,7 +232,7 @@ fn apply_profile_key_based_endpoint_overrides( .map(str::trim) .filter(|key| !key.is_empty()) .map(ToString::to_string) - .or_else(|| load_env_value_from_env_or_config(profile.api_key_env, profile.env_file)); + .or_else(|| load_env_value_from_env_or_config(&resolved.api_key_env, &resolved.env_file)); if key .as_deref() diff --git a/crates/jcode-base/src/provider_catalog_tests.rs b/crates/jcode-base/src/provider_catalog_tests.rs index f057ba33d6..bff97d327b 100644 --- a/crates/jcode-base/src/provider_catalog_tests.rs +++ b/crates/jcode-base/src/provider_catalog_tests.rs @@ -196,7 +196,9 @@ fn resolved_named_profile_skips_non_chat_models_when_picking_newest_default() { #[test] fn minimax_token_plan_keys_resolve_to_china_endpoint_without_changing_international_default() { let _lock = crate::storage::lock_test_env(); - let _guard = EnvGuard::save(&["MINIMAX_API_KEY", "OPENAI_API_KEY"]); + let _guard = EnvGuard::save(&["JCODE_HOME", "MINIMAX_API_KEY", "OPENAI_API_KEY"]); + let home = tempfile::tempdir().expect("temporary JCODE_HOME"); + crate::env::set_var("JCODE_HOME", home.path()); crate::env::remove_var("MINIMAX_API_KEY"); crate::env::remove_var("OPENAI_API_KEY"); @@ -213,6 +215,11 @@ fn minimax_token_plan_keys_resolve_to_china_endpoint_without_changing_internatio ); assert_eq!(china.api_base, MINIMAX_CHINA_API_BASE); assert_eq!(china.setup_url, MINIMAX_CHINA_SETUP_URL); + + crate::env::set_var("OPENAI_API_KEY", "sk-cp-legacy-token"); + let legacy = resolve_openai_compatible_profile(MINIMAX_PROFILE); + assert_eq!(legacy.api_key_env, "OPENAI_API_KEY"); + assert_eq!(legacy.api_base, MINIMAX_CHINA_API_BASE); } #[test] From d790cf279e3f2eb034a977e9fec9b96f2443d3f1 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:06:30 -0700 Subject: [PATCH 083/107] docs: distinguish ambient implementation from future design --- docs/AMBIENT_MODE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/AMBIENT_MODE.md b/docs/AMBIENT_MODE.md index fd970978c6..5f0b24b15a 100644 --- a/docs/AMBIENT_MODE.md +++ b/docs/AMBIENT_MODE.md @@ -5,6 +5,10 @@ > > Ambient mode is disabled by default. Enable it with `[ambient] enabled = true` > and configure an available subscription or API provider before use. +> +> The core ambient runner is implemented. Later sections also retain forward-looking +> design ideas; examples such as cold-start gating, warm-up bypass, and per-project +> policy fields are not all available configuration options yet. A proactive, always-on agent mode that works autonomously without user prompting. Like a brain consolidating memories during sleep, ambient mode tends to the memory graph, identifies useful work, and acts on the user's behalf — all while staying within resource limits. From 31e59ef37f58c812ea3024907b99998bf86e7841 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:34:24 +0000 Subject: [PATCH 084/107] docs: update star history chart --- docs/images/star-history.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg index 84d59f6ee7..bbe837ef7d 100644 --- a/docs/images/star-history.svg +++ b/docs/images/star-history.svg @@ -1,6 +1,6 @@ 1jehuang/jcode star history -GitHub stars over time, currently 17,654 +GitHub stars over time, currently 17,763 From 1f453829b808fcbecd43fec49e85481d67d62576 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:04:58 -0700 Subject: [PATCH 085/107] fix: preserve spawn prompt when initial message is blank (fixes #975) --- crates/jcode-app-core/src/tool/communicate.rs | 11 +++++++++- .../tool/communicate_tests/input_format.rs | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/jcode-app-core/src/tool/communicate.rs b/crates/jcode-app-core/src/tool/communicate.rs index 0235f6f66f..548a88449b 100644 --- a/crates/jcode-app-core/src/tool/communicate.rs +++ b/crates/jcode-app-core/src/tool/communicate.rs @@ -1902,7 +1902,16 @@ struct CommunicateInput { impl CommunicateInput { fn spawn_initial_message(&self) -> Option { - self.initial_message.clone().or_else(|| self.prompt.clone()) + self.initial_message + .as_ref() + .filter(|message| !message.trim().is_empty()) + .cloned() + .or_else(|| { + self.prompt + .as_ref() + .filter(|prompt| !prompt.trim().is_empty()) + .cloned() + }) } fn required_spawn_label(&self) -> anyhow::Result { diff --git a/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs index b20f536147..e636fdfc64 100644 --- a/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs +++ b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs @@ -20,6 +20,27 @@ fn spawn_initial_message_accepts_prompt_alias_and_prefers_explicit_initial_messa preferred.spawn_initial_message().as_deref(), Some("preferred") ); + + for blank_initial_message in ["", " \t\n"] { + let from_prompt: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "spawn", + "initial_message": blank_initial_message, + "prompt": "fallback" + })) + .expect("spawn payload should deserialize"); + assert_eq!( + from_prompt.spawn_initial_message().as_deref(), + Some("fallback") + ); + } + + let blank_messages: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "spawn", + "initial_message": "", + "prompt": " " + })) + .expect("spawn payload should deserialize"); + assert_eq!(blank_messages.spawn_initial_message(), None); } #[test] From 0755f6f5f7f64987cae21664847881bcbf91b060 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:04:58 -0700 Subject: [PATCH 086/107] fix: fall back when visible ambient launch fails (fixes #983) --- crates/jcode-app-core/src/ambient/runner.rs | 87 +++++++++++++------ .../src/ambient/runner_tests.rs | 40 +++++++++ 2 files changed, 99 insertions(+), 28 deletions(-) diff --git a/crates/jcode-app-core/src/ambient/runner.rs b/crates/jcode-app-core/src/ambient/runner.rs index 4ad30adeb7..fcba987a5c 100644 --- a/crates/jcode-app-core/src/ambient/runner.rs +++ b/crates/jcode-app-core/src/ambient/runner.rs @@ -887,17 +887,55 @@ impl AmbientRunnerHandle { /// Run a single ambient cycle. Returns the cycle result. async fn run_cycle(&self, provider: &Arc) -> anyhow::Result { + self.run_cycle_with_visible_launcher(provider, config().ambient.visible, || { + let jcode_bin = + std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode")); + + std::process::Command::new("kitty") + .args([ + "--title", + "🤖 jcode ambient cycle", + "-e", + &jcode_bin.to_string_lossy(), + "ambient", + "run-visible", + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + }) + .await + } + + async fn run_cycle_with_visible_launcher( + &self, + provider: &Arc, + visible: bool, + launch_visible: F, + ) -> anyhow::Result + where + F: FnOnce() -> std::io::Result + Send, + { let started_at = Utc::now(); - let visible = config().ambient.visible; self.set_running_detail("gathering context").await; let (system_prompt, initial_message) = self.build_cycle_context(provider).await?; // Visible mode: spawn a full TUI instead of running headlessly if visible { - return self - .run_cycle_visible(started_at, system_prompt, initial_message) - .await; + match self + .run_cycle_visible( + started_at, + system_prompt.clone(), + initial_message.clone(), + launch_visible, + ) + .await? + { + VisibleCycleOutcome::Completed(result) => return Ok(result), + VisibleCycleOutcome::FallBackHeadless => {} + } } // Headless mode: run agent directly @@ -986,12 +1024,16 @@ impl AmbientRunnerHandle { } /// Run a visible ambient cycle by spawning a full TUI in a kitty window. - async fn run_cycle_visible( + async fn run_cycle_visible( &self, started_at: chrono::DateTime, system_prompt: String, initial_message: String, - ) -> anyhow::Result { + launch_visible: F, + ) -> anyhow::Result + where + F: FnOnce() -> std::io::Result + Send, + { use crate::ambient::VisibleCycleContext; self.set_running_detail("launching visible TUI").await; @@ -1008,25 +1050,9 @@ impl AmbientRunnerHandle { let _ = std::fs::remove_file(&result_path); } - // Find the jcode binary - let jcode_bin = - std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode")); - // Spawn kitty with `jcode ambient run-visible` logging::info("Ambient visible: spawning kitty with jcode TUI"); - let child = std::process::Command::new("kitty") - .args([ - "--title", - "🤖 jcode ambient cycle", - "-e", - &jcode_bin.to_string_lossy(), - "ambient", - "run-visible", - ]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn(); + let child = launch_visible(); match child { Ok(mut child) => { @@ -1046,15 +1072,15 @@ impl AmbientRunnerHandle { crate::storage::read_json::(&result_path) { let _ = std::fs::remove_file(&result_path); - return Ok(AmbientCycleResult { + return Ok(VisibleCycleOutcome::Completed(AmbientCycleResult { started_at, ended_at: Utc::now(), ..result - }); + })); } // No result file — user closed the window without end_ambient_cycle - Ok(AmbientCycleResult { + Ok(VisibleCycleOutcome::Completed(AmbientCycleResult { summary: "Visible cycle ended (user closed window)".to_string(), memories_modified: 0, compactions: 0, @@ -1064,7 +1090,7 @@ impl AmbientRunnerHandle { ended_at: Utc::now(), status: CycleStatus::Incomplete, conversation: None, - }) + })) } Err(e) => { logging::warn(&format!( @@ -1072,12 +1098,17 @@ impl AmbientRunnerHandle { e )); // Fall back to headless mode - Err(anyhow::anyhow!("Failed to spawn visible TUI: {}", e)) + Ok(VisibleCycleOutcome::FallBackHeadless) } } } } +enum VisibleCycleOutcome { + Completed(AmbientCycleResult), + FallBackHeadless, +} + // --------------------------------------------------------------------------- #[cfg(test)] diff --git a/crates/jcode-app-core/src/ambient/runner_tests.rs b/crates/jcode-app-core/src/ambient/runner_tests.rs index 94146aaa0d..867e77b99c 100644 --- a/crates/jcode-app-core/src/ambient/runner_tests.rs +++ b/crates/jcode-app-core/src/ambient/runner_tests.rs @@ -7,6 +7,7 @@ use anyhow::Result; use async_stream::stream; use async_trait::async_trait; use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; @@ -121,6 +122,45 @@ async fn runner_stays_alive_to_service_schedules_when_ambient_disabled() { let _ = task.await; } +async fn assert_visible_launch_error_falls_back(error_kind: std::io::ErrorKind) { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + + let provider: Arc = Arc::new(StreamingTestProvider::default()); + let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new())); + let launch_attempted = Arc::new(AtomicBool::new(false)); + let launch_attempted_in_callback = launch_attempted.clone(); + + let result = runner + .run_cycle_with_visible_launcher(&provider, true, move || { + launch_attempted_in_callback.store(true, Ordering::SeqCst); + Err(std::io::Error::from(error_kind)) + }) + .await + .expect("failed visible launch should continue as a headless cycle"); + + assert!(launch_attempted.load(Ordering::SeqCst)); + assert!( + result.conversation.is_some(), + "headless fallback should capture an agent conversation" + ); + assert!( + result.summary.contains("forced end after 2 attempts"), + "headless fallback should return the headless agent result" + ); +} + +#[tokio::test] +async fn unsupported_visible_launch_falls_back_to_headless() { + assert_visible_launch_error_falls_back(std::io::ErrorKind::Unsupported).await; +} + +#[tokio::test] +async fn missing_visible_launcher_falls_back_to_headless() { + assert_visible_launch_error_falls_back(std::io::ErrorKind::NotFound).await; +} + #[tokio::test] async fn spawn_target_creates_one_child_session_and_runs_task() { let _guard = crate::storage::lock_test_env(); From b18cf642bb7c54b0d8f6fb20b0b09158237475b7 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:04:58 -0700 Subject: [PATCH 087/107] fix: label inline diff previews with file paths (fixes #985) --- crates/jcode-tui/src/tui/ui_diff.rs | 91 ++++++++++++++++++- crates/jcode-tui/src/tui/ui_messages.rs | 32 +++++-- crates/jcode-tui/src/tui/ui_messages/tests.rs | 74 +++++++++++++++ 3 files changed, 188 insertions(+), 9 deletions(-) diff --git a/crates/jcode-tui/src/tui/ui_diff.rs b/crates/jcode-tui/src/tui/ui_diff.rs index 5a5ec80ad6..c5dd831c12 100644 --- a/crates/jcode-tui/src/tui/ui_diff.rs +++ b/crates/jcode-tui/src/tui/ui_diff.rs @@ -20,6 +20,7 @@ pub(super) struct ParsedDiffLine { pub kind: DiffLineKind, pub prefix: String, pub content: String, + pub file_path: Option, } pub(super) fn diff_change_counts(content: &str) -> (usize, usize) { @@ -235,6 +236,7 @@ fn generate_diff_lines_from_strings(old: &str, new: &str) -> Vec kind: DiffLineKind::Del, prefix: format!("{}- ", change.old_index().unwrap_or(0) + 1), content: content.to_string(), + file_path: None, }); } ChangeTag::Insert => { @@ -242,6 +244,7 @@ fn generate_diff_lines_from_strings(old: &str, new: &str) -> Vec kind: DiffLineKind::Add, prefix: format!("{}+ ", change.new_index().unwrap_or(0) + 1), content: content.to_string(), + file_path: None, }); } ChangeTag::Equal => {} @@ -252,7 +255,66 @@ fn generate_diff_lines_from_strings(old: &str, new: &str) -> Vec } pub(super) fn collect_diff_lines(content: &str) -> Vec { - content.lines().filter_map(parse_diff_line).collect() + let mut file_path = None; + let mut lines = Vec::new(); + + for raw_line in content.lines() { + if let Some(path) = diff_file_path(raw_line) { + file_path = Some(path); + continue; + } + if let Some(mut line) = parse_diff_line(raw_line) { + line.file_path = file_path.clone(); + lines.push(line); + } + } + + lines +} + +fn diff_file_path(raw_line: &str) -> Option { + let trimmed = raw_line.trim(); + if let Some(path) = trimmed + .strip_prefix("*** Add File: ") + .or_else(|| trimmed.strip_prefix("*** Update File: ")) + .or_else(|| trimmed.strip_prefix("*** Delete File: ")) + { + return non_empty_diff_path(path); + } + + if let Some(path) = trimmed.strip_prefix("+++ ") { + return unified_diff_path(path); + } + if let Some(path) = trimmed.strip_prefix("--- ") { + return unified_diff_path(path); + } + + let status = trimmed + .strip_prefix('✓') + .or_else(|| trimmed.strip_prefix('✗'))? + .trim_start(); + let (path, _) = status.split_once(": ")?; + non_empty_diff_path(path) +} + +fn unified_diff_path(raw_path: &str) -> Option { + let path = raw_path + .split('\t') + .next() + .unwrap_or(raw_path) + .split_whitespace() + .next() + .unwrap_or(""); + let path = path + .strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path); + non_empty_diff_path(path) +} + +fn non_empty_diff_path(path: &str) -> Option { + let path = path.trim(); + (!path.is_empty() && path != "/dev/null").then(|| path.to_string()) } fn parse_diff_line(raw_line: &str) -> Option { @@ -277,6 +339,7 @@ fn parse_diff_line(raw_line: &str) -> Option { kind: DiffLineKind::Del, prefix: prefix.to_string(), content: trim_diff_content(content), + file_path: None, }); } } @@ -287,6 +350,7 @@ fn parse_diff_line(raw_line: &str) -> Option { kind: DiffLineKind::Add, prefix: prefix.to_string(), content: trim_diff_content(content), + file_path: None, }); } } @@ -296,6 +360,7 @@ fn parse_diff_line(raw_line: &str) -> Option { kind: DiffLineKind::Add, prefix: "+".to_string(), content: trim_diff_content(rest), + file_path: None, }); } if let Some(rest) = raw_line.strip_prefix('-') { @@ -303,6 +368,7 @@ fn parse_diff_line(raw_line: &str) -> Option { kind: DiffLineKind::Del, prefix: "-".to_string(), content: trim_diff_content(rest), + file_path: None, }); } @@ -338,8 +404,8 @@ pub(super) fn tint_span_with_diff_color(span: Span<'static>, diff_color: Color) #[cfg(test)] mod tests { use super::{ - DiffLineKind, diff_change_counts_for_tool, diff_counts_from_apply_patch_input, - generate_diff_lines_from_strings, + DiffLineKind, collect_diff_lines, diff_change_counts_for_tool, + diff_counts_from_apply_patch_input, generate_diff_lines_from_strings, }; use crate::message::ToolCall; use serde_json::json; @@ -353,6 +419,25 @@ mod tests { assert_eq!(diff_counts_from_apply_patch_input(&input), Some((1, 1))); } + #[test] + fn collected_apply_patch_lines_retain_file_boundaries() { + let patch = "*** Begin Patch\n*** Update File: a.txt\n@@\n-old a\n+new a\n*** Update File: b.txt\n@@\n-old b\n+new b\n*** End Patch\n"; + + let lines = collect_diff_lines(patch); + + assert_eq!(lines.len(), 4); + assert!( + lines[..2] + .iter() + .all(|line| line.file_path.as_deref() == Some("a.txt")) + ); + assert!( + lines[2..] + .iter() + .all(|line| line.file_path.as_deref() == Some("b.txt")) + ); + } + #[test] fn write_tool_falls_back_to_content_diff_counts() { let tool = ToolCall { diff --git a/crates/jcode-tui/src/tui/ui_messages.rs b/crates/jcode-tui/src/tui/ui_messages.rs index 86a4fd6788..c13773000a 100644 --- a/crates/jcode-tui/src/tui/ui_messages.rs +++ b/crates/jcode-tui/src/tui/ui_messages.rs @@ -4318,11 +4318,6 @@ pub(crate) fn render_tool_message( _ => None, }) }); - let file_ext = file_path_for_ext - .as_deref() - .and_then(|p| std::path::Path::new(p).extension()) - .and_then(|e| e.to_str()); - const MAX_DIFF_LINES: usize = MAX_INLINE_DIFF_LINES; let total_changes = change_lines.len(); let additions = change_lines @@ -4346,15 +4341,24 @@ pub(crate) fn render_tool_message( let pad_str = ""; + let diff_file_path = + |line: &ParsedDiffLine| line.file_path.clone().or_else(|| file_path_for_ext.clone()); + let first_file_path = display_lines.first().and_then(|line| diff_file_path(line)); + let diff_header = |prefix: &str, file_path: Option<&str>| match file_path { + Some(path) => format!("{pad_str}{prefix} diff · {path}"), + None => format!("{pad_str}{prefix} diff"), + }; + lines.push( Line::from(Span::styled( - format!("{}┌─ diff", pad_str), + diff_header("┌─", first_file_path.as_deref()), Style::default().fg(dim_color()), )) .alignment(ratatui::layout::Alignment::Left), ); let mut shown_truncation = false; + let mut previous_file_path = first_file_path; for (i, line) in display_lines.iter().enumerate() { if truncated && !shown_truncation && i >= half_point { @@ -4369,6 +4373,18 @@ pub(crate) fn render_tool_message( shown_truncation = true; } + let current_file_path = diff_file_path(line); + if i > 0 && current_file_path != previous_file_path { + lines.push( + Line::from(Span::styled( + diff_header("├─", current_file_path.as_deref()), + Style::default().fg(dim_color()), + )) + .alignment(ratatui::layout::Alignment::Left), + ); + } + previous_file_path = current_file_path.clone(); + let base_color = if line.kind == DiffLineKind::Add { diff_add_color() } else { @@ -4387,6 +4403,10 @@ pub(crate) fn render_tool_message( if !line.content.is_empty() { let content = &line.content; + let file_ext = current_file_path + .as_deref() + .and_then(|p| std::path::Path::new(p).extension()) + .and_then(|e| e.to_str()); let content_vis_width = unicode_width::UnicodeWidthStr::width(content.as_str()); if !full_inline && max_content_width > 1 && content_vis_width > max_content_width { let mut end = 0; diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index a381e6a0f0..a6ede8cb2b 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -2716,6 +2716,80 @@ fn render_tool_message_shows_inline_diff_for_pascal_case_multiedit() { assert!(plain.contains("new line"), "plain={plain}"); } +#[test] +fn render_tool_message_labels_single_file_apply_patch_diff() { + let msg = DisplayMessage { + role: "tool".to_string(), + content: "✓ src/example.rs: modified (1 hunks)".to_string(), + tool_calls: Vec::new(), + duration_secs: None, + title: Some("src/example.rs".to_string()), + tool_data: Some(crate::message::ToolCall { + id: "call_apply_patch_single".to_string(), + name: "apply_patch".to_string(), + input: serde_json::json!({ + "intent": "Update example behavior", + "patch_text": "*** Begin Patch\n*** Update File: src/example.rs\n@@\n-old_value\n+new_value\n*** End Patch\n" + }), + intent: Some("Update example behavior".to_string()), + thought_signature: None, + }), + }; + + let lines = render_tool_message(&msg, 100, crate::config::DiffDisplayMode::Inline); + let plain = lines + .iter() + .map(extract_line_text) + .collect::>() + .join("\n"); + + assert!(plain.contains("┌─ diff · src/example.rs"), "plain={plain}"); + assert!(plain.contains("old_value"), "plain={plain}"); + assert!(plain.contains("new_value"), "plain={plain}"); +} + +#[test] +fn render_tool_message_preserves_multi_file_apply_patch_boundaries() { + let msg = DisplayMessage { + role: "tool".to_string(), + content: "✓ a.txt: modified (1 hunks)\n1- old a\n1+ new a\n✓ b.txt: modified (1 hunks)\n1- old b\n1+ new b\n".to_string(), + tool_calls: Vec::new(), + duration_secs: None, + title: Some("2 files".to_string()), + tool_data: Some(crate::message::ToolCall { + id: "call_apply_patch_multi".to_string(), + name: "apply_patch".to_string(), + input: serde_json::json!({ + "intent": "Update both examples", + "patch_text": "*** Begin Patch\n*** Update File: a.txt\n@@\n-old a\n+new a\n*** Update File: b.txt\n@@\n-old b\n+new b\n*** End Patch\n" + }), + intent: Some("Update both examples".to_string()), + thought_signature: None, + }), + }; + + let lines = render_tool_message(&msg, 100, crate::config::DiffDisplayMode::Inline); + let plain = lines + .iter() + .map(extract_line_text) + .collect::>() + .join("\n"); + + let a_header = plain.find("┌─ diff · a.txt").expect("missing a.txt header"); + let old_a = plain.find("old a").expect("missing a.txt deletion"); + let new_a = plain.find("new a").expect("missing a.txt addition"); + let b_header = plain + .find("├─ diff · b.txt") + .expect("missing b.txt boundary"); + let old_b = plain.find("old b").expect("missing b.txt deletion"); + let new_b = plain.find("new b").expect("missing b.txt addition"); + assert!( + a_header < old_a && old_a < new_a && new_a < b_header, + "plain={plain}" + ); + assert!(b_header < old_b && old_b < new_b, "plain={plain}"); +} + #[test] fn render_tool_message_shows_numbered_write_result_diff_after_input_compaction() { let msg = DisplayMessage { From 83efc48d3bc35859b6e22946405d8cf19195d31b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:04:58 -0700 Subject: [PATCH 088/107] fix: close one-shot sessions after completion (fixes #988) --- src/cli/commands.rs | 56 ++++++++---- src/cli/commands_tests.rs | 180 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 16 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index a423d42d72..6fe6231316 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -2450,25 +2450,49 @@ pub async fn run_single_message_command( wait_for_cold_cache_mcp_tools(®istry).await; } let mut agent = crate::agent::Agent::new(provider.clone(), registry); - restore_agent_session_if_requested(&mut agent, resume_session)?; + if let Err(error) = restore_agent_session_if_requested(&mut agent, resume_session) { + agent.mark_closed(); + return Err(error); + } - if emit_json { - let text = run_single_message_command_capture_with_auto_poke(&mut agent, message).await?; - let report = RunCommandReport { - session_id: agent.session_id().to_string(), - provider: provider.name().to_string(), - model: provider.model(), - text, - usage: agent.last_usage().clone(), - }; - println!("{}", serde_json::to_string_pretty(&report)?); - } else if emit_ndjson { - run_single_message_command_ndjson(&mut agent, provider.clone(), message).await?; - } else { - run_single_message_command_plain_with_auto_poke(&mut agent, message).await?; + run_single_message_with_agent(&mut agent, provider, message, emit_json, emit_ndjson).await +} + +async fn run_single_message_with_agent( + agent: &mut crate::agent::Agent, + provider: std::sync::Arc, + message: &str, + emit_json: bool, + emit_ndjson: bool, +) -> Result<()> { + let result: Result<()> = async { + if emit_json { + let text = run_single_message_command_capture_with_auto_poke(agent, message).await?; + let report = RunCommandReport { + session_id: agent.session_id().to_string(), + provider: provider.name().to_string(), + model: provider.model(), + text, + usage: agent.last_usage().clone(), + }; + println!("{}", serde_json::to_string_pretty(&report)?); + } else if emit_ndjson { + run_single_message_command_ndjson(agent, provider, message).await?; + } else { + run_single_message_command_plain_with_auto_poke(agent, message).await?; + } + Ok(()) } + .await; - Ok(()) + // `Agent::new` and session restore both register this process as the active + // owner. Unlike the interactive lifecycle, `jcode run` has no later quit + // path to close the session. Finalize after output has been emitted, while + // returning the original command result unchanged. This prevents a normal + // one-shot exit from looking like a stale-PID crash on the next startup + // (issue #988). + agent.mark_closed(); + result } fn run_command_auto_poke_enabled() -> bool { diff --git a/src/cli/commands_tests.rs b/src/cli/commands_tests.rs index b324417136..442e04085b 100644 --- a/src/cli/commands_tests.rs +++ b/src/cli/commands_tests.rs @@ -70,6 +70,29 @@ impl Provider for TestProvider { } } +struct FailingTestProvider; + +#[async_trait] +impl Provider for FailingTestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + Err(anyhow::anyhow!("one-shot sentinel failure")) + } + + fn name(&self) -> &str { + "failing-test" + } + + fn fork(&self) -> Arc { + Arc::new(Self) + } +} + fn spawn_single_response_http_server(status: u16, body: &str) -> String { spawn_single_response_http_server_on_host("127.0.0.1", status, body) } @@ -1300,3 +1323,160 @@ async fn restore_agent_session_if_requested_restores_resumed_session() { assert_eq!(resumed.session_id(), original_session_id); } + +#[tokio::test] +async fn one_shot_output_modes_close_sessions_and_clear_active_pid_markers() { + let _guard = crate::storage::lock_test_env(); + let _saved = SavedEnv::capture(&["JCODE_HOME", "JCODE_RUN_AUTO_POKE"]); + let temp = tempfile::tempdir().expect("tempdir"); + crate::env::set_var("JCODE_HOME", temp.path()); + crate::env::set_var("JCODE_RUN_AUTO_POKE", "0"); + + for (mode, emit_json, emit_ndjson) in [ + ("plain", false, false), + ("json", true, false), + ("ndjson", false, true), + ] { + let provider: Arc = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = crate::agent::Agent::new(provider.clone(), registry); + let session_id = agent.session_id().to_string(); + let marker = crate::storage::active_pids_dir() + .expect("active PID directory") + .join(&session_id); + assert!(marker.exists(), "{mode} session should start active"); + + run_single_message_with_agent( + &mut agent, + provider, + "Return the test response.", + emit_json, + emit_ndjson, + ) + .await + .unwrap_or_else(|error| panic!("{mode} run failed: {error:#}")); + + assert!( + !marker.exists(), + "{mode} run left an active PID marker behind" + ); + let persisted = crate::session::Session::load(&session_id) + .unwrap_or_else(|error| panic!("load {mode} session: {error:#}")); + assert!( + matches!(persisted.status, crate::session::SessionStatus::Closed), + "{mode} run persisted status {:?}", + persisted.status + ); + assert!( + !crate::session::find_recent_crashed_sessions() + .iter() + .any(|(id, _)| id == &session_id), + "{mode} run was rediscovered as a stale-PID crash" + ); + } +} + +#[tokio::test] +async fn resumed_one_shot_closes_the_restored_session() { + let _guard = crate::storage::lock_test_env(); + let _saved = SavedEnv::capture(&["JCODE_HOME", "JCODE_RUN_AUTO_POKE"]); + let temp = tempfile::tempdir().expect("tempdir"); + crate::env::set_var("JCODE_HOME", temp.path()); + crate::env::set_var("JCODE_RUN_AUTO_POKE", "0"); + + let provider: Arc = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + let mut original = crate::agent::Agent::new(provider.clone(), registry); + original + .run_once_capture("Seed the resumed one-shot session.") + .await + .expect("seed resumed session"); + let session_id = original.session_id().to_string(); + original.mark_closed(); + + let registry = Registry::new(provider.clone()).await; + let mut resumed = crate::agent::Agent::new(provider.clone(), registry); + restore_agent_session_if_requested(&mut resumed, Some(&session_id)) + .expect("restore one-shot session"); + let marker = crate::storage::active_pids_dir() + .expect("active PID directory") + .join(&session_id); + assert!( + marker.exists(), + "restored session should be active while running" + ); + + run_single_message_with_agent( + &mut resumed, + provider, + "Finish the resumed one-shot session.", + true, + false, + ) + .await + .expect("run resumed one-shot session"); + + assert!(!marker.exists(), "resumed run left its active PID marker"); + let persisted = crate::session::Session::load(&session_id).expect("load resumed session"); + assert!(matches!( + persisted.status, + crate::session::SessionStatus::Closed + )); + assert!( + !crate::session::find_recent_crashed_sessions() + .iter() + .any(|(id, _)| id == &session_id), + "resumed run was rediscovered as a stale-PID crash" + ); +} + +#[tokio::test] +async fn one_shot_cleanup_preserves_the_original_command_error() { + let _guard = crate::storage::lock_test_env(); + let _saved = SavedEnv::capture(&["JCODE_HOME", "JCODE_RUN_AUTO_POKE"]); + let temp = tempfile::tempdir().expect("tempdir"); + crate::env::set_var("JCODE_HOME", temp.path()); + crate::env::set_var("JCODE_RUN_AUTO_POKE", "0"); + + for (mode, emit_json, emit_ndjson) in [ + ("plain", false, false), + ("json", true, false), + ("ndjson", false, true), + ] { + let provider: Arc = Arc::new(FailingTestProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = crate::agent::Agent::new(provider.clone(), registry); + let session_id = agent.session_id().to_string(); + let marker = crate::storage::active_pids_dir() + .expect("active PID directory") + .join(&session_id); + + let error = match run_single_message_with_agent( + &mut agent, + provider, + "Fail this run.", + emit_json, + emit_ndjson, + ) + .await + { + Ok(()) => panic!("{mode} provider failure should remain the command result"), + Err(error) => error, + }; + + assert!( + error.to_string().contains("one-shot sentinel failure"), + "{mode} changed the original error: {error:#}" + ); + assert!( + !marker.exists(), + "failed {mode} run left an active PID marker" + ); + let persisted = crate::session::Session::load(&session_id) + .unwrap_or_else(|error| panic!("load failed {mode} session: {error:#}")); + assert!(matches!( + persisted.status, + crate::session::SessionStatus::Closed + )); + } +} From cfe88cc02e4a1f862d9b83e7de6c37b2919a9d3a Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:29:10 -0700 Subject: [PATCH 089/107] style: satisfy workspace formatting checks --- crates/jcode-base/src/background/tests.rs | 12 +++++++++--- .../jcode-tui/src/tui/app/auth_account_picker.rs | 2 +- crates/jcode-tui/src/tui/ui_messages/tests.rs | 15 ++++++++++++--- crates/jcode-tui/src/tui/ui_viewport.rs | 3 +-- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/jcode-base/src/background/tests.rs b/crates/jcode-base/src/background/tests.rs index 2489a3a92c..ac9158fd5f 100644 --- a/crates/jcode-base/src/background/tests.rs +++ b/crates/jcode-base/src/background/tests.rs @@ -185,7 +185,9 @@ async fn update_progress_keeps_the_determinate_high_water_mark() -> Result<()> { source: BackgroundTaskProgressSource::Reported, }; - manager.update_progress(&info.task_id, progress(60.0)).await?; + manager + .update_progress(&info.task_id, progress(60.0)) + .await?; let status = manager .update_progress(&info.task_id, progress(25.0)) .await? @@ -224,7 +226,9 @@ async fn update_progress_preserves_high_water_mark_through_reported_checkpoint() updated_at: Utc::now().to_rfc3339(), source: BackgroundTaskProgressSource::Reported, }; - manager.update_progress(&info.task_id, progress.clone()).await?; + manager + .update_progress(&info.task_id, progress.clone()) + .await?; progress.kind = BackgroundTaskProgressKind::Indeterminate; progress.percent = None; @@ -248,7 +252,9 @@ async fn update_progress_preserves_high_water_mark_through_reported_checkpoint() #[tokio::test] async fn concurrent_progress_updates_cannot_overwrite_the_high_water_mark() -> Result<()> { let tmp = tempdir()?; - let manager = Arc::new(BackgroundTaskManager::with_output_dir(tmp.path().to_path_buf())); + let manager = Arc::new(BackgroundTaskManager::with_output_dir( + tmp.path().to_path_buf(), + )); let info = manager .spawn_with_notify( "bash", diff --git a/crates/jcode-tui/src/tui/app/auth_account_picker.rs b/crates/jcode-tui/src/tui/app/auth_account_picker.rs index 0c316fea15..8791f7741b 100644 --- a/crates/jcode-tui/src/tui/app/auth_account_picker.rs +++ b/crates/jcode-tui/src/tui/app/auth_account_picker.rs @@ -1,5 +1,5 @@ -use super::*; use super::auth_account_picker_saved_accounts::{account_display_name, anthropic_account_use}; +use super::*; impl App { pub(crate) fn open_account_center(&mut self, provider_filter: Option<&str>) { diff --git a/crates/jcode-tui/src/tui/ui_messages/tests.rs b/crates/jcode-tui/src/tui/ui_messages/tests.rs index a6ede8cb2b..d4acc9f8b6 100644 --- a/crates/jcode-tui/src/tui/ui_messages/tests.rs +++ b/crates/jcode-tui/src/tui/ui_messages/tests.rs @@ -732,10 +732,19 @@ fn render_todos_message_uses_readable_semantic_colors() { #[test] fn render_todos_message_color_codes_every_intent_state() { let cases = [ - (crate::todo::IntentUnderstanding::Uncertain, todo_failure_color()), - (crate::todo::IntentUnderstanding::Partial, todo_warning_color()), + ( + crate::todo::IntentUnderstanding::Uncertain, + todo_failure_color(), + ), + ( + crate::todo::IntentUnderstanding::Partial, + todo_warning_color(), + ), (crate::todo::IntentUnderstanding::Clear, todo_score_color()), - (crate::todo::IntentUnderstanding::Complete, todo_score_color()), + ( + crate::todo::IntentUnderstanding::Complete, + todo_score_color(), + ), ]; for (state, expected_color) in cases { diff --git a/crates/jcode-tui/src/tui/ui_viewport.rs b/crates/jcode-tui/src/tui/ui_viewport.rs index 3101af95cc..805d2025b9 100644 --- a/crates/jcode-tui/src/tui/ui_viewport.rs +++ b/crates/jcode-tui/src/tui/ui_viewport.rs @@ -1442,8 +1442,7 @@ fn active_background_task_line(task: &crate::tui::BackgroundTaskRow, width: u16) format!("{}%", rounded_percent) }; let filled = ((percent / 100.0) * BAR_WIDTH as f32).round() as usize; - let (active_bar, remaining_bar) = if task.status - == crate::tui::BackgroundTaskRowStatus::Failed + let (active_bar, remaining_bar) = if task.status == crate::tui::BackgroundTaskRowStatus::Failed { ( "━".repeat(filled.min(BAR_WIDTH)), From f5d48a883f7fa1e1afe0334af9e69db68a12cc0a Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:47:14 -0700 Subject: [PATCH 090/107] style: satisfy anthropic runtime clippy checks --- crates/jcode-provider-anthropic-runtime/src/lib.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/jcode-provider-anthropic-runtime/src/lib.rs b/crates/jcode-provider-anthropic-runtime/src/lib.rs index 2d9096acf4..fbc6207c0b 100644 --- a/crates/jcode-provider-anthropic-runtime/src/lib.rs +++ b/crates/jcode-provider-anthropic-runtime/src/lib.rs @@ -135,9 +135,7 @@ fn active_anthropic_profile_models() -> Option> { let Ok(profile_name) = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") else { return None; }; - let Some(profile) = jcode_base::config::config().providers.get(&profile_name) else { - return None; - }; + let profile = jcode_base::config::config().providers.get(&profile_name)?; if !matches!( profile.provider_type, jcode_base::config::NamedProviderType::AnthropicCompatible @@ -1969,6 +1967,10 @@ async fn force_refresh_oauth_token( } /// Stream the response from Anthropic API +#[expect( + clippy::too_many_arguments, + reason = "streaming requires transport, authentication, request, event, and session context" +)] async fn stream_response( client: Client, token: String, From a987a3509f2a0e8b36254227af59e51c4782ec4d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:48:49 -0700 Subject: [PATCH 091/107] fix: gate unused bedrock token helper --- crates/jcode-provider-bedrock/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/jcode-provider-bedrock/src/lib.rs b/crates/jcode-provider-bedrock/src/lib.rs index 6402c75d04..82487edbc5 100644 --- a/crates/jcode-provider-bedrock/src/lib.rs +++ b/crates/jcode-provider-bedrock/src/lib.rs @@ -250,6 +250,7 @@ impl BedrockProvider { jcode_provider_env::load_api_key_from_env_or_config(API_KEY_ENV, ENV_FILE) } + #[cfg(any(feature = "aws-sdk", test))] fn configured_bearer_token_for_runtime() -> Option { Self::configured_profile() .is_none() From 35c51adeced042708cda37b17ab5c04c628e3295 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:51:03 -0700 Subject: [PATCH 092/107] chore(release): prepare v0.77.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- changelog/index.json | 4 ++++ changelog/v0.77.2.json | 13 +++++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 changelog/v0.77.2.json diff --git a/Cargo.lock b/Cargo.lock index e9477c8ced..af5b422088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3385,7 +3385,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.77.1" +version = "0.77.2" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index afb1cd8c03..6e4368fb71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.77.1" +version = "0.77.2" description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false diff --git a/changelog/index.json b/changelog/index.json index 26f2e174a0..5943b3f560 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,5 +1,9 @@ { "entries": [ + { + "version": "0.77.2", + "date": "2026-08-18" + }, { "version": "0.77.1", "date": "2026-08-17" diff --git a/changelog/v0.77.2.json b/changelog/v0.77.2.json new file mode 100644 index 0000000000..cd51675abc --- /dev/null +++ b/changelog/v0.77.2.json @@ -0,0 +1,13 @@ +{ + "version": "0.77.2", + "date": "2026-08-18", + "improvements": [ + "Inline diff previews now show the affected file path for clearer review context" + ], + "fixes": [ + "One-shot sessions now close automatically after completing their response", + "Ambient launches now fall back gracefully when opening a visible terminal fails", + "Spawned agents keep their prompt when an explicitly blank initial message is supplied", + "MiniMax authentication now uses the correct API key variable while preserving existing credentials" + ] +} From 64c9ec07c81c4e9ec82f9b7d9456c82bf7e50ce7 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:06:59 -0700 Subject: [PATCH 093/107] fix: box visible ambient cycle results --- crates/jcode-app-core/src/ambient/runner.rs | 40 +++++++++++---------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/crates/jcode-app-core/src/ambient/runner.rs b/crates/jcode-app-core/src/ambient/runner.rs index fcba987a5c..eb1e9e10b0 100644 --- a/crates/jcode-app-core/src/ambient/runner.rs +++ b/crates/jcode-app-core/src/ambient/runner.rs @@ -933,7 +933,7 @@ impl AmbientRunnerHandle { ) .await? { - VisibleCycleOutcome::Completed(result) => return Ok(result), + VisibleCycleOutcome::Completed(result) => return Ok(*result), VisibleCycleOutcome::FallBackHeadless => {} } } @@ -1072,25 +1072,29 @@ impl AmbientRunnerHandle { crate::storage::read_json::(&result_path) { let _ = std::fs::remove_file(&result_path); - return Ok(VisibleCycleOutcome::Completed(AmbientCycleResult { - started_at, - ended_at: Utc::now(), - ..result - })); + return Ok(VisibleCycleOutcome::Completed(Box::new( + AmbientCycleResult { + started_at, + ended_at: Utc::now(), + ..result + }, + ))); } // No result file — user closed the window without end_ambient_cycle - Ok(VisibleCycleOutcome::Completed(AmbientCycleResult { - summary: "Visible cycle ended (user closed window)".to_string(), - memories_modified: 0, - compactions: 0, - proactive_work: None, - next_schedule: None, - started_at, - ended_at: Utc::now(), - status: CycleStatus::Incomplete, - conversation: None, - })) + Ok(VisibleCycleOutcome::Completed(Box::new( + AmbientCycleResult { + summary: "Visible cycle ended (user closed window)".to_string(), + memories_modified: 0, + compactions: 0, + proactive_work: None, + next_schedule: None, + started_at, + ended_at: Utc::now(), + status: CycleStatus::Incomplete, + conversation: None, + }, + ))) } Err(e) => { logging::warn(&format!( @@ -1105,7 +1109,7 @@ impl AmbientRunnerHandle { } enum VisibleCycleOutcome { - Completed(AmbientCycleResult), + Completed(Box), FallBackHeadless, } From 094b9f9fdb884d7a85150009eadaa49d25675880 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:14:06 -0700 Subject: [PATCH 094/107] fix: satisfy todo view clippy guardrail --- crates/jcode-tui/src/tui/app/todos_view.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/todos_view.rs b/crates/jcode-tui/src/tui/app/todos_view.rs index c93b66a118..2d8dacbd0f 100644 --- a/crates/jcode-tui/src/tui/app/todos_view.rs +++ b/crates/jcode-tui/src/tui/app/todos_view.rs @@ -580,15 +580,14 @@ fn format_goal_markdown(goals: &[crate::todo::TodoGoal], group: Option<&str>) -> /// Plan-level assessment lines, shown once for the whole todo list. fn format_plan_markdown(plan: &crate::todo::TodoPlan) -> String { let mut markdown = String::new(); - if !crate::todo::intent_understanding_passes(plan.understands_user_intent) { - if let Some(intention) = plan + if !crate::todo::intent_understanding_passes(plan.understands_user_intent) + && let Some(intention) = plan .user_intention .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - { - markdown.push_str(&format!("- User intention: {}\n", intention)); - } + { + markdown.push_str(&format!("- User intention: {}\n", intention)); } if let Some(state) = plan.understands_user_intent { markdown.push_str(&format!( From 6991029b9a6b443e170b29db5a94c3fd3c7b2fac Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:26:57 -0700 Subject: [PATCH 095/107] fix: reap completed hook observers (fixes #935) --- crates/jcode-base/src/hooks.rs | 56 ++++++++++++++++++++++++++++--- crates/jcode-base/src/platform.rs | 20 +++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/jcode-base/src/hooks.rs b/crates/jcode-base/src/hooks.rs index 2bf5e9d010..32a02938b6 100644 --- a/crates/jcode-base/src/hooks.rs +++ b/crates/jcode-base/src/hooks.rs @@ -233,10 +233,13 @@ pub fn dispatch_observer(event: HookEvent) { .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); match crate::platform::spawn_detached(&mut cmd) { - Ok(_) => crate::logging::debug(&format!( - "Hook '{event_name}' dispatched to '{command_line}' (session={:?})", - event.session_id - )), + Ok(child) => { + crate::platform::reap_detached(child); + crate::logging::debug(&format!( + "Hook '{event_name}' dispatched to '{command_line}' (session={:?})", + event.session_id + )); + } Err(error) => crate::logging::warn(&format!( "Hook '{event_name}' command '{command_line}' failed to start: {error}" )), @@ -619,6 +622,51 @@ mod tests { assert_eq!(recorded, "turn_end|ses_obs|ok|1"); } + #[cfg(target_os = "linux")] + #[test] + fn observer_dispatch_reaps_completed_hook() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let record = temp.path().join("pid.txt"); + let script = write_executable_script( + temp.path(), + "record-pid.sh", + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$$\" > {}\n", + crate::terminal_launch::sh_escape(&record.to_string_lossy()) + ), + ); + + let previous = std::env::var_os("JCODE_HOOK_TURN_END"); + crate::env::set_var("JCODE_HOOK_TURN_END", script.to_string_lossy().to_string()); + dispatch_observer(HookEvent::new("turn_end").session_id("ses_reap")); + + let mut pid: Option = None; + for _ in 0..100 { + pid = std::fs::read_to_string(&record) + .ok() + .and_then(|value| value.strip_suffix('\n').and_then(|pid| pid.parse().ok())); + if pid.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + match previous { + Some(value) => crate::env::set_var("JCODE_HOOK_TURN_END", value), + None => crate::env::remove_var("JCODE_HOOK_TURN_END"), + } + + let pid = pid.expect("hook should record its pid"); + let process = std::path::PathBuf::from(format!("/proc/{pid}")); + for _ in 0..100 { + if !process.exists() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("completed hook process {pid} was not reaped"); + } + #[cfg(unix)] #[test] fn observer_dispatch_runs_each_configured_command() { diff --git a/crates/jcode-base/src/platform.rs b/crates/jcode-base/src/platform.rs index cef0380f81..1d6f42a659 100644 --- a/crates/jcode-base/src/platform.rs +++ b/crates/jcode-base/src/platform.rs @@ -424,6 +424,26 @@ pub fn spawn_detached(cmd: &mut std::process::Command) -> std::io::Result Date: Tue, 18 Aug 2026 03:58:38 +0000 Subject: [PATCH 096/107] docs: update star history chart --- docs/images/star-history.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg index bbe837ef7d..2b7d97e45d 100644 --- a/docs/images/star-history.svg +++ b/docs/images/star-history.svg @@ -1,6 +1,6 @@ 1jehuang/jcode star history -GitHub stars over time, currently 17,763 +GitHub stars over time, currently 17,868 From 135f915f0ef3e8b21ed402e5f18972d3ced98b93 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:23:04 -0700 Subject: [PATCH 097/107] feat(api): expose transcript images to clients --- .../jcode-harness-api-server/src/translate.rs | 10 ++++++ crates/jcode-harness-api/src/events.rs | 35 +++++++++++++++++++ crates/jcode-sdk/src/client.rs | 12 ++++++- crates/jcode-sdk/src/lib.rs | 4 +-- sdk/typescript/src/protocol.ts | 21 ++++++++++- 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/crates/jcode-harness-api-server/src/translate.rs b/crates/jcode-harness-api-server/src/translate.rs index 6f1295b6b6..ff3d3752ae 100644 --- a/crates/jcode-harness-api-server/src/translate.rs +++ b/crates/jcode-harness-api-server/src/translate.rs @@ -421,6 +421,7 @@ impl BridgeState { ApiEvent::History { session_id: session_id.to_string(), messages: Self::stored_tail(session_id, limit), + images: Vec::new(), }, ))] } @@ -855,6 +856,13 @@ impl BridgeState { output: event["output"].as_str().unwrap_or("").to_string(), error: event["error"].as_str().map(str::to_string), })], + "side_pane_images" => vec![ServerFrame::event(ApiEvent::SidePaneImages { + session_id: event["session_id"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| session(self)), + images: serde_json::from_value(event["images"].clone()).unwrap_or_default(), + })], "tokens" => vec![ServerFrame::event(ApiEvent::TokenUsage { session_id: session(self), input: event["input"].as_u64().unwrap_or(0), @@ -931,11 +939,13 @@ impl BridgeState { .collect() }) .unwrap_or_default(); + let images = serde_json::from_value(event["images"].clone()).unwrap_or_default(); vec![ServerFrame::reply( api_id, ApiEvent::History { session_id: session(self), messages, + images, }, )] } diff --git a/crates/jcode-harness-api/src/events.rs b/crates/jcode-harness-api/src/events.rs index fe43a4dba3..5399455814 100644 --- a/crates/jcode-harness-api/src/events.rs +++ b/crates/jcode-harness-api/src/events.rs @@ -32,6 +32,9 @@ pub enum ApiEvent { History { session_id: String, messages: Vec, + /// Images anchored to user prompts or tool calls in this transcript. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + images: Vec, }, /// Reply to `Ping`. @@ -76,6 +79,13 @@ pub enum ApiEvent { error: Option, }, + /// Images the model just received from a tool result or image generator. + /// Clients should render these at their transcript anchor immediately. + SidePaneImages { + session_id: String, + images: Vec, + }, + /// Token usage update for the attached session. TokenUsage { session_id: String, @@ -304,3 +314,28 @@ pub struct HistoryMessage { pub role: String, pub content: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RenderedImageSource { + UserInput, + ToolResult { tool_name: String }, + Other { role: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RenderedImageAnchor { + ToolCall { id: String }, + UserPrompt { ordinal: usize }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RenderedImage { + pub media_type: String, + pub data: String, + pub label: Option, + pub source: RenderedImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor: Option, +} diff --git a/crates/jcode-sdk/src/client.rs b/crates/jcode-sdk/src/client.rs index 1f6d7afe0b..12b198d171 100644 --- a/crates/jcode-sdk/src/client.rs +++ b/crates/jcode-sdk/src/client.rs @@ -694,13 +694,23 @@ impl JcodeClient { } pub fn get_history(&self, session_id: &str) -> Result> { + self.get_history_with_images(session_id) + .map(|(messages, _)| messages) + } + + pub fn get_history_with_images( + &self, + session_id: &str, + ) -> Result<(Vec, Vec)> { match self .request_ok(ApiRequest::GetHistory { session_id: session_id.to_string(), })? .event { - ApiEvent::History { messages, .. } => Ok(messages), + ApiEvent::History { + messages, images, .. + } => Ok((messages, images)), other => Err(unexpected("history", &other)), } } diff --git a/crates/jcode-sdk/src/lib.rs b/crates/jcode-sdk/src/lib.rs index 10618a63e6..e531a08ed2 100644 --- a/crates/jcode-sdk/src/lib.rs +++ b/crates/jcode-sdk/src/lib.rs @@ -50,6 +50,6 @@ pub use structured::{ /// The protocol types, re-exported so a client needs one dependency, not two. pub use jcode_harness_api as api; pub use jcode_harness_api::{ - ApiEvent, ApiRequest, HistoryMessage, ModelRouteInfo, PermissionDecision, SessionInfo, - TextMatch, api_socket_path, + ApiEvent, ApiRequest, HistoryMessage, ModelRouteInfo, PermissionDecision, RenderedImage, + RenderedImageAnchor, RenderedImageSource, SessionInfo, TextMatch, api_socket_path, }; diff --git a/sdk/typescript/src/protocol.ts b/sdk/typescript/src/protocol.ts index 830daf473d..37c0007059 100644 --- a/sdk/typescript/src/protocol.ts +++ b/sdk/typescript/src/protocol.ts @@ -51,6 +51,23 @@ export interface HistoryMessage { content: string; } +export type RenderedImageSource = + | { kind: "user_input" } + | { kind: "tool_result"; tool_name: string } + | { kind: "other"; role: string }; + +export type RenderedImageAnchor = + | { kind: "tool_call"; id: string } + | { kind: "user_prompt"; ordinal: number }; + +export interface RenderedImage { + media_type: string; + data: string; + label?: string; + source: RenderedImageSource; + anchor?: RenderedImageAnchor; +} + /** Base64 image attachment: [mediaType, base64Data]. */ export type ImageAttachment = [string, string]; @@ -109,7 +126,7 @@ export type ApiEvent = | { ev: "error"; code: ErrorCode; message: string } | { ev: "sessions"; sessions: SessionInfo[] } | { ev: "attached"; session: SessionInfo } - | { ev: "history"; session_id: string; messages: HistoryMessage[] } + | { ev: "history"; session_id: string; messages: HistoryMessage[]; images?: RenderedImage[] } | { ev: "pong" } | { ev: "text_delta"; session_id: string; text: string } | { ev: "reasoning_delta"; session_id: string; text: string } @@ -125,6 +142,7 @@ export type ApiEvent = output: string; error?: string; } + | { ev: "side_pane_images"; session_id: string; images: RenderedImage[] } | { ev: "token_usage"; session_id: string; @@ -234,6 +252,7 @@ export const KNOWN_EVENT_KINDS = [ "sessions", "attached", "history", + "side_pane_images", "pong", "text_delta", "reasoning_delta", From be9242e8de7f453104e917e2dbf1dc67c14f73ec Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:02:01 -0700 Subject: [PATCH 098/107] Reframe confidence spike gate around evidence --- crates/jcode-base/src/todo.rs | 21 ++++++++++++------- crates/jcode-tui/src/tui/app/input.rs | 2 +- .../tests/remote_events_reload_01/part_02.rs | 4 ++-- .../src/tui/app/tests/state_model_poke_03.rs | 8 +++++-- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index 3296abbae3..0581f05a21 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -280,9 +280,9 @@ const LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated todo comple /// Model-facing continuation for private completion-confidence checks. pub const TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Do more validation on the work below. Keep the todo up to date; do not reply or wait for the user."; -/// Model-facing continuation requesting an independent recheck without saying -/// why the private evaluator selected it. -pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Independently recheck the work below. Keep the todo up to date; do not reply or wait for the user."; +/// Model-facing continuation requesting evidence-backed verification without +/// exposing why the private evaluator selected it. +pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Verify the work below with concrete evidence from relevant checks, not an unsupported assessment. Keep the todo up to date; do not reply or wait for the user."; /// Final synthetic turn after every todo completion check has passed. Gate /// continuations tell the model not to reply, so without this handoff a cycle @@ -521,6 +521,9 @@ const LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "Your completion confidence is missing or not high enough."; const LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "Your completion confidence rose too sharply to count as independently validated."; +/// Wording used immediately before the evidence-backed framing. Persisted +/// sessions can still contain it and must keep treating it as a hidden gate. +const PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Independently recheck the work below. Keep the todo up to date; do not reply or wait for the user."; fn normalized_group(group: Option<&str>) -> Option { group @@ -712,12 +715,12 @@ pub fn build_todo_completion_continuation_message(todos: &[TodoItem]) -> String message } -/// Spike-gate continuation naming the completed todos whose confidence jumped, -/// so the recheck targets those items. +/// Spike-gate continuation naming the completed todos that need evidence-backed +/// verification, so the recheck targets those items. pub fn build_todo_confidence_spike_continuation_message(todos: &[TodoItem]) -> String { let spiked = spike_completed_todos(todos); let mut message = String::from(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE); - append_named_todos(&mut message, "Recheck:", &spiked); + append_named_todos(&mut message, "Verify with evidence:", &spiked); message } @@ -746,6 +749,7 @@ pub fn is_auto_poke_message(message: &str) -> bool { || trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX) || trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) || trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) @@ -764,8 +768,9 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { } if trimmed.starts_with(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) { - return Some("🔍 Double-checking a confidence jump for you..."); + return Some("🔍 Checking that completion is backed by evidence..."); } if trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) { return Some("✅ Preparing the final response..."); @@ -1377,7 +1382,7 @@ mod tests { (TODO_COMPLETION_CONTINUATION_MESSAGE, "more validation"), ( TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, - "independently recheck", + "concrete evidence", ), ] { let lower = message.to_ascii_lowercase(); diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 9ac4131d70..fbf53766cc 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1685,7 +1685,7 @@ impl App { crate::telemetry::record_todo_gate( crate::telemetry::TodoGateKind::ConfidenceSpike, ); - "🔍 Double-checking a confidence jump for you..." + "🔍 Checking that completion is backed by evidence..." }; self.push_display_message(DisplayMessage::system(notice)); // User-role content: reminder-only turns read as empty user diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs index f4e5bad646..a0fe00dafd 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs @@ -179,12 +179,12 @@ fn test_remote_auto_poke_challenges_abrupt_confidence_increase() { app.queued_messages[0] .starts_with(crate::todo::TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) ); - // The continuation names the specific todo whose confidence jumped. + // The continuation names the specific todo that needs evidence-backed verification. assert!(app.queued_messages[0].contains("Finished work")); assert!( app.display_messages() .iter() - .any(|msg| { msg.content.contains("Double-checking a confidence jump") }) + .any(|msg| { msg.content.contains("completion is backed by evidence") }) ); }); } diff --git a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs index ce459ff803..721e787b98 100644 --- a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs +++ b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs @@ -2674,7 +2674,7 @@ fn test_finish_turn_challenges_confidence_spike_once() { assert!( app.display_messages() .iter() - .any(|msg| { msg.content.contains("Double-checking a confidence jump") }) + .any(|msg| { msg.content.contains("completion is backed by evidence") }) ); app.queued_messages.clear(); @@ -2687,7 +2687,11 @@ fn test_finish_turn_challenges_confidence_spike_once() { assert!(!app.auto_poke_incomplete_todos); assert!(!app.todo_confidence_spike_challenged); - assert!(!app.pending_queued_dispatch); + assert!(app.pending_queued_dispatch); + assert_eq!( + app.queued_messages, + vec![crate::todo::TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE.to_string()] + ); }); } From ffa6454ff4fa04d6245af9997ff2f7e62bbbc8eb Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:03:58 -0700 Subject: [PATCH 099/107] Prevent confidence gate final-response loop --- crates/jcode-tui/src/tui/app/input.rs | 5 ++++- .../src/tui/app/tests/state_model_poke_03.rs | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index fbf53766cc..7a089626ee 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1723,7 +1723,6 @@ impl App { // it stays armed so the next batch of work is covered too; only an // explicit /poke off (or a circuit breaker above) disarms it. self.auto_poke_incomplete_todos = self.auto_poke_default_on; - self.todo_confidence_spike_challenged = false; // A finished cycle re-arms the review for whatever work comes next; // without this a session could only ever deliver one digest. self.todo_gate_digest_delivered = false; @@ -1745,6 +1744,10 @@ impl App { let poke_message = super::commands::build_poke_message(&incomplete); self.todo_final_response_requested = false; + // Open work begins a new completion cycle. Keep the prior spike check + // latched until this point so the synthetic final-response turn cannot + // retrigger the same evidence gate against unchanged completed todos. + self.todo_confidence_spike_challenged = false; let fingerprint = serde_json::to_string(&incomplete).unwrap_or_else(|_| poke_message.clone()); if self.last_auto_poke_fingerprint.as_ref() == Some(&fingerprint) { diff --git a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs index 721e787b98..85fdc588ea 100644 --- a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs +++ b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs @@ -2680,18 +2680,27 @@ fn test_finish_turn_challenges_confidence_spike_once() { app.queued_messages.clear(); app.pending_queued_dispatch = false; app.is_processing = true; - // Pin the default so the clean second cycle disarms; this test is - // about challenging the spike exactly once. - app.auto_poke_default_on = false; super::local::finish_turn(&mut app); - assert!(!app.auto_poke_incomplete_todos); - assert!(!app.todo_confidence_spike_challenged); + assert!(app.auto_poke_incomplete_todos); + assert!(app.todo_confidence_spike_challenged); assert!(app.pending_queued_dispatch); assert_eq!( app.queued_messages, vec![crate::todo::TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE.to_string()] ); + + // Finishing the synthetic final-response turn must not challenge the + // same unchanged confidence history again. + app.queued_messages.clear(); + app.pending_queued_dispatch = false; + app.is_processing = true; + super::local::finish_turn(&mut app); + + assert!(app.auto_poke_incomplete_todos); + assert!(app.todo_confidence_spike_challenged); + assert!(!app.pending_queued_dispatch); + assert!(app.queued_messages.is_empty()); }); } From f6c7a1b656d48986b4b415efc0c408fb2a982e11 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:05:42 -0700 Subject: [PATCH 100/107] Clarify confidence jump follow-up --- crates/jcode-base/src/todo.rs | 21 +++++++++++-------- crates/jcode-tui/src/tui/app/input.rs | 2 +- .../tests/remote_events_reload_01/part_02.rs | 4 ++-- .../src/tui/app/tests/state_model_poke_03.rs | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index 0581f05a21..3096187fce 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -280,9 +280,9 @@ const LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated todo comple /// Model-facing continuation for private completion-confidence checks. pub const TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Do more validation on the work below. Keep the todo up to date; do not reply or wait for the user."; -/// Model-facing continuation requesting evidence-backed verification without -/// exposing why the private evaluator selected it. -pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Verify the work below with concrete evidence from relevant checks, not an unsupported assessment. Keep the todo up to date; do not reply or wait for the user."; +/// Model-facing continuation identifying the items whose confidence jumped and +/// asking for one explicit double-check without exposing scores or thresholds. +pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] You had a confidence jump in the items below. Double-check that these are correct. Keep the todo up to date; do not reply or wait for the user."; /// Final synthetic turn after every todo completion check has passed. Gate /// continuations tell the model not to reply, so without this handoff a cycle @@ -715,12 +715,12 @@ pub fn build_todo_completion_continuation_message(todos: &[TodoItem]) -> String message } -/// Spike-gate continuation naming the completed todos that need evidence-backed -/// verification, so the recheck targets those items. +/// Spike-gate continuation naming the completed todos whose confidence jumped, +/// so the double-check targets those items. pub fn build_todo_confidence_spike_continuation_message(todos: &[TodoItem]) -> String { let spiked = spike_completed_todos(todos); let mut message = String::from(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE); - append_named_todos(&mut message, "Verify with evidence:", &spiked); + append_named_todos(&mut message, "Confidence jumped:", &spiked); message } @@ -770,7 +770,7 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) { - return Some("🔍 Checking that completion is backed by evidence..."); + return Some("🔍 Double-checking confidence jumps..."); } if trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) { return Some("✅ Preparing the final response..."); @@ -1382,7 +1382,7 @@ mod tests { (TODO_COMPLETION_CONTINUATION_MESSAGE, "more validation"), ( TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, - "concrete evidence", + "confidence jump", ), ] { let lower = message.to_ascii_lowercase(); @@ -1419,7 +1419,6 @@ mod tests { for message in [ TODO_OWNERSHIP_CONTINUATION_MESSAGE, TODO_COMPLETION_CONTINUATION_MESSAGE, - TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, ] { let lower = message.to_ascii_lowercase(); for evaluator_term in ["gate", "flagged", "failed", "threshold", "confidence"] { @@ -1429,6 +1428,10 @@ mod tests { ); } } + let spike = TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE.to_ascii_lowercase(); + for evaluator_term in ["gate", "flagged", "failed", "threshold", "score"] { + assert!(!spike.contains(evaluator_term), "disclosed {evaluator_term}"); + } } /// The model must be told which items it should recheck, otherwise the diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 7a089626ee..b24c75ce1b 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1685,7 +1685,7 @@ impl App { crate::telemetry::record_todo_gate( crate::telemetry::TodoGateKind::ConfidenceSpike, ); - "🔍 Checking that completion is backed by evidence..." + "🔍 Double-checking confidence jumps..." }; self.push_display_message(DisplayMessage::system(notice)); // User-role content: reminder-only turns read as empty user diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs index a0fe00dafd..54b35176ce 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_02.rs @@ -179,12 +179,12 @@ fn test_remote_auto_poke_challenges_abrupt_confidence_increase() { app.queued_messages[0] .starts_with(crate::todo::TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) ); - // The continuation names the specific todo that needs evidence-backed verification. + // The continuation names the specific todo whose confidence jumped. assert!(app.queued_messages[0].contains("Finished work")); assert!( app.display_messages() .iter() - .any(|msg| { msg.content.contains("completion is backed by evidence") }) + .any(|msg| { msg.content.contains("Double-checking confidence jumps") }) ); }); } diff --git a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs index 85fdc588ea..ccd093f2cb 100644 --- a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs +++ b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs @@ -2674,7 +2674,7 @@ fn test_finish_turn_challenges_confidence_spike_once() { assert!( app.display_messages() .iter() - .any(|msg| { msg.content.contains("completion is backed by evidence") }) + .any(|msg| { msg.content.contains("Double-checking confidence jumps") }) ); app.queued_messages.clear(); From 00a283d1ece12516585e19d8d9b0bb091805bb10 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:09:25 -0700 Subject: [PATCH 101/107] Simplify intent quality gate --- crates/jcode-base/src/todo.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index 3096187fce..cb2b4cf14b 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -167,8 +167,12 @@ pub fn delivery_state_passes(goal: &TodoGoal) -> bool { const LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE: &str = "Your alignment score is not high enough. Build a requirement inventory from the user's request, including outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through. Revise the plan and its stated user intention to represent every material item. Then map each item to an explicit observation or check in a feedback loop. Generic instructions to run tests, verify, or review count only for requirements those checks actually enforce; add separate checks for non-testable requirements. Reassess the weaker link before continuing the task."; /// Model-facing continuation for the private intent-understanding check. -/// Deliberately small: think more about the user's intent, do not ask the user. -pub const TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "Your understanding of the user's intent is not high enough. Re-read the request and think harder about what the user actually wants and left implicit, using the conversation and codebase as evidence. Form a requirement inventory covering outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through, and check the plan represents every material item. Do not ask the user; resolve the ambiguity yourself, then update the plan's user intention and understands_user_intent."; +pub const TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = + "Understand the user's intent better. Try to avoid asking the user."; + +/// Previous verbose wording, retained so persisted sessions still classify it +/// as a hidden quality-gate message after the concise rewrite. +const PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "Your understanding of the user's intent is not high enough. Re-read the request and think harder about what the user actually wants and left implicit, using the conversation and codebase as evidence. Form a requirement inventory covering outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through, and check the plan represents every material item. Do not ask the user; resolve the ambiguity yourself, then update the plan's user intention and understands_user_intent."; /// Model-facing continuation for the private closed-feedback-loop check. Names /// the assessment category without disclosing the score or threshold. @@ -742,6 +746,7 @@ pub fn is_auto_poke_message(message: &str) -> bool { || trimmed.starts_with(LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) @@ -792,7 +797,9 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { { return Some("🔍 Checking the delivery state of the finished work..."); } - if trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) { + if trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + { return Some("🔍 Re-checking the request was understood..."); } if trimmed.starts_with(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) @@ -1376,7 +1383,7 @@ mod tests { ), ( TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, - "understanding of the user's intent", + "understand the user's intent better", ), (TODO_OWNERSHIP_CONTINUATION_MESSAGE, "continue the work"), (TODO_COMPLETION_CONTINUATION_MESSAGE, "more validation"), @@ -1410,12 +1417,7 @@ mod tests { assert!( TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("before continuing the task") ); - // Deliberately terse: think harder about intent, never block on the user. - assert!(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("think harder")); - assert!( - TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("what the user actually wants") - ); - assert!(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("Do not ask the user")); + assert!(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("Try to avoid asking")); for message in [ TODO_OWNERSHIP_CONTINUATION_MESSAGE, TODO_COMPLETION_CONTINUATION_MESSAGE, From febb1ec11300995086e596e6d10be14f9bc5762d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:11:44 -0700 Subject: [PATCH 102/107] Cap quality gate prompt length --- crates/jcode-base/src/todo.rs | 57 ++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index cb2b4cf14b..93e15142cb 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -6,7 +6,13 @@ use std::path::PathBuf; /// Generic mid-task reassessment prompt. The elapsed-time policy that triggers /// it is intentionally private so the model reassesses from evidence rather /// than targeting a timer or evaluator boundary. -pub const TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the original request and reconsider the current todo plan and every goal assessment using the evidence gathered during the work so far. Correct anything stale or overstated, including intent understanding, feedback-loop relevance and coverage, autonomy, difficulty, delivery, confidence, iteration maturity, and stopping evidence. Do not reply conversationally or wait for the user. Continue the work after saving an honest updated assessment."; +pub const TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the request. Update the todo plan and goal assessments from the evidence gathered so far. Correct anything stale or overstated, then continue the work. Do not reply or wait for the user."; +const PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the original request and reconsider the current todo plan and every goal assessment using the evidence gathered during the work so far. Correct anything stale or overstated, including intent understanding, feedback-loop relevance and coverage, autonomy, difficulty, delivery, confidence, iteration maturity, and stopping evidence. Do not reply conversationally or wait for the user. Continue the work after saving an honest updated assessment."; + +/// Static quality-gate instructions should stay short enough to be read as a +/// nudge, not a replacement system prompt. Dynamic todo/goal details are added +/// separately and have their own list-size limits. +pub const TODO_QUALITY_GATE_MAX_APPROX_TOKENS: usize = 64; /// Private policy. Do not include this duration in model-facing schemas or /// continuation text. @@ -176,7 +182,8 @@ const PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "Your u /// Model-facing continuation for the private closed-feedback-loop check. Names /// the assessment category without disclosing the score or threshold. -pub const TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Your feedback loop is not closed. First, improve the goal's objective and name the observation that reports back on each requirement, so progress can be measured across iterations. Generic phrases such as run tests, verify, or review count only for requirements those named checks demonstrably enforce; add separate explicit checks for non-testable requirements. Then call the todo tool again with the revised goal before continuing the task. The goal is to create a strong feedback loop you can iterate against."; +pub const TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Improve the goal's feedback loop. Name a concrete check for each requirement and what result will show it passed. Update the goal, then continue the work."; +const PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Your feedback loop is not closed. First, improve the goal's objective and name the observation that reports back on each requirement, so progress can be measured across iterations. Generic phrases such as run tests, verify, or review count only for requirements those named checks demonstrably enforce; add separate explicit checks for non-testable requirements. Then call the todo tool again with the revised goal before continuing the task. The goal is to create a strong feedback loop you can iterate against."; /// Pre-rename ("hill-climbability") version of the closed-feedback-loop /// continuation. Kept only so persisted transcripts still classify it as a @@ -291,7 +298,8 @@ pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow- /// Final synthetic turn after every todo completion check has passed. Gate /// continuations tell the model not to reply, so without this handoff a cycle /// can end on a bare tool call or an internal-looking validation response. -pub const TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] All work and quality checks are complete. Give the user the final response now. Default to fewer than 5 lines unless the user's request requires more detail. Summarize the outcome clearly; do not call the todo tool or perform more work."; +pub const TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Quality checks passed. Give the user a concise final response now. Do not call the todo tool or do more work."; +const PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] All work and quality checks are complete. Give the user the final response now. Default to fewer than 5 lines unless the user's request requires more detail. Summarize the outcome clearly; do not call the todo tool or perform more work."; /// A completed todo is considered spike-finished when its final recorded /// confidence step jumps this many levels or more (e.g. speculative straight @@ -743,6 +751,7 @@ pub fn is_auto_poke_message(message: &str) -> bool { && trimmed.contains(" incomplete todo") && trimmed.ends_with("update the todo tool.")) || trimmed.starts_with(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) @@ -752,12 +761,14 @@ pub fn is_auto_poke_message(message: &str) -> bool { || trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX) || trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) || trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE) } /// Short, user-facing stand-in for a synthetic auto-poke/gate continuation. @@ -777,7 +788,9 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { { return Some("🔍 Double-checking confidence jumps..."); } - if trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) { + if trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + { return Some("✅ Preparing the final response..."); } if trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) @@ -789,7 +802,9 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { if trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) { return Some("🔍 Reviewing the weak points of this turn for you..."); } - if trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) { + if trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE) + { return Some("🔍 Rechecking the plan and assessments after extended work..."); } if trimmed.starts_with(TODO_OWNERSHIP_CONTINUATION_MESSAGE) @@ -803,6 +818,7 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { return Some("🔍 Re-checking the request was understood..."); } if trimmed.starts_with(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE) { @@ -1379,7 +1395,7 @@ mod tests { for (message, category) in [ ( TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE, - "feedback loop is not closed", + "concrete check for each requirement", ), ( TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, @@ -1409,14 +1425,7 @@ mod tests { } } - assert!(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("strong feedback loop")); - assert!(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("First, improve")); - assert!( - TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("call the todo tool again") - ); - assert!( - TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("before continuing the task") - ); + assert!(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("Update the goal")); assert!(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("Try to avoid asking")); for message in [ TODO_OWNERSHIP_CONTINUATION_MESSAGE, @@ -1436,6 +1445,26 @@ mod tests { } } + #[test] + fn static_quality_gate_messages_stay_within_token_budget() { + for (name, message) in [ + ("long session review", TODO_LONG_SESSION_REVIEW_MESSAGE), + ("intent understanding", TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE), + ("closed feedback loop", TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE), + ("ownership", TODO_OWNERSHIP_CONTINUATION_MESSAGE), + ("completion", TODO_COMPLETION_CONTINUATION_MESSAGE), + ("confidence jump", TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE), + ("final response", TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE), + ("turn digest", TODO_GATE_DIGEST_PREFIX), + ] { + let tokens = jcode_core::util::estimate_tokens(message); + assert!( + tokens <= TODO_QUALITY_GATE_MAX_APPROX_TOKENS, + "{name} quality-gate message is about {tokens} tokens; budget is {TODO_QUALITY_GATE_MAX_APPROX_TOKENS}: {message}" + ); + } + } + /// The model must be told which items it should recheck, otherwise the /// nudge is a guess-the-weak-spot puzzle. Scores stay private. #[test] From 79de395cb00ac548f9d2368b2b314c65c8def61c Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:17:45 -0700 Subject: [PATCH 103/107] Streamline todo quality gate messages --- crates/jcode-base/src/todo.rs | 80 ++++++++++++++++--- crates/jcode-tui/src/tui/app/input.rs | 8 +- .../src/tui/app/tests/state_model_poke_03.rs | 7 ++ 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index 93e15142cb..a833035136 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -6,7 +6,8 @@ use std::path::PathBuf; /// Generic mid-task reassessment prompt. The elapsed-time policy that triggers /// it is intentionally private so the model reassesses from evidence rather /// than targeting a timer or evaluator boundary. -pub const TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the request. Update the todo plan and goal assessments from the evidence gathered so far. Correct anything stale or overstated, then continue the work. Do not reply or wait for the user."; +pub const TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[auto] Re-read the request. Update the todo plan and goal assessments from the evidence gathered so far. Correct anything stale or overstated, then continue the work. Do not reply or wait for the user."; +const PRE_COMPACT_TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the request. Update the todo plan and goal assessments from the evidence gathered so far. Correct anything stale or overstated, then continue the work. Do not reply or wait for the user."; const PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE: &str = "[automated todo assessment review - not a user message] Re-read the original request and reconsider the current todo plan and every goal assessment using the evidence gathered during the work so far. Correct anything stale or overstated, including intent understanding, feedback-loop relevance and coverage, autonomy, difficulty, delivery, confidence, iteration maturity, and stopping evidence. Do not reply conversationally or wait for the user. Continue the work after saving an honest updated assessment."; /// Static quality-gate instructions should stay short enough to be read as a @@ -174,15 +175,21 @@ const LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE: &str = "Your alignment score i /// Model-facing continuation for the private intent-understanding check. pub const TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = - "Understand the user's intent better. Try to avoid asking the user."; + "[auto] Understand the user's intent better. Try to avoid asking the user. Make sure the todo is up to date."; +const PRE_COMPACT_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = + "Understand the user's intent better. Try to avoid asking the user. Make sure the todo is up to date."; /// Previous verbose wording, retained so persisted sessions still classify it /// as a hidden quality-gate message after the concise rewrite. const PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "Your understanding of the user's intent is not high enough. Re-read the request and think harder about what the user actually wants and left implicit, using the conversation and codebase as evidence. Form a requirement inventory covering outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through, and check the plan represents every material item. Do not ask the user; resolve the ambiguity yourself, then update the plan's user intention and understands_user_intent."; +const PRE_TODO_REMINDER_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = + "Understand the user's intent better. Try to avoid asking the user."; /// Model-facing continuation for the private closed-feedback-loop check. Names /// the assessment category without disclosing the score or threshold. -pub const TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Improve the goal's feedback loop. Name a concrete check for each requirement and what result will show it passed. Update the goal, then continue the work."; +pub const TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "[auto] Your feedback loop isn't good enough. Think about what feedback loops you need. Make sure the todo is up to date."; +const PRE_COMPACT_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Improve the goal's feedback loop. Name a concrete check for each requirement and what result will show it passed. Update the todo, then continue the work."; +const PRE_TODO_REMINDER_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Improve the goal's feedback loop. Name a concrete check for each requirement and what result will show it passed. Update the goal, then continue the work."; const PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE: &str = "Your feedback loop is not closed. First, improve the goal's objective and name the observation that reports back on each requirement, so progress can be measured across iterations. Generic phrases such as run tests, verify, or review count only for requirements those named checks demonstrably enforce; add separate explicit checks for non-testable requirements. Then call the todo tool again with the revised goal before continuing the task. The goal is to create a strong feedback loop you can iterate against."; /// Pre-rename ("hill-climbability") version of the closed-feedback-loop @@ -192,7 +199,8 @@ const LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE: &str = "Your hill-clim /// Model-facing continuation for the private end-to-end ownership check. It /// asks for more work without revealing that an evaluator triggered it. -pub const TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; +pub const TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[auto] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; +const PRE_COMPACT_TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; /// Build an ownership continuation that directs work toward each affected goal /// without exposing fields, scores, thresholds, or pass/fail language. @@ -289,16 +297,19 @@ pub fn build_todo_ownership_continuation_message(todos: &[TodoItem], goals: &[To const LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated todo completion gate - not a user message] Your end-to-end ownership is not high enough to finish this goal."; /// Model-facing continuation for private completion-confidence checks. -pub const TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Do more validation on the work below. Keep the todo up to date; do not reply or wait for the user."; +pub const TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[auto] Do more validation on the work below. Keep the todo up to date; do not reply or wait for the user."; +const PRE_COMPACT_TODO_COMPLETION_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Do more validation on the work below. Keep the todo up to date; do not reply or wait for the user."; /// Model-facing continuation identifying the items whose confidence jumped and /// asking for one explicit double-check without exposing scores or thresholds. -pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] You had a confidence jump in the items below. Double-check that these are correct. Keep the todo up to date; do not reply or wait for the user."; +pub const TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[auto] You had a confidence jump in the items below. Double-check that these are correct. Keep the todo up to date; do not reply or wait for the user."; +const PRE_COMPACT_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] You had a confidence jump in the items below. Double-check that these are correct. Keep the todo up to date; do not reply or wait for the user."; /// Final synthetic turn after every todo completion check has passed. Gate /// continuations tell the model not to reply, so without this handoff a cycle /// can end on a bare tool call or an internal-looking validation response. -pub const TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Quality checks passed. Give the user a concise final response now. Do not call the todo tool or do more work."; +pub const TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[auto] Quality checks passed. Give the user a concise final response now. Do not call the todo tool or do more work."; +const PRE_COMPACT_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Quality checks passed. Give the user a concise final response now. Do not call the todo tool or do more work."; const PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] All work and quality checks are complete. Give the user the final response now. Default to fewer than 5 lines unless the user's request requires more detail. Summarize the outcome clearly; do not call the todo tool or perform more work."; /// A completed todo is considered spike-finished when its final recorded @@ -350,7 +361,9 @@ pub struct GateObservation { /// Deliberately framed as "double-check these" rather than as a refusal: by /// turn end the work is done, so the useful action is verification, not /// replanning. Names categories without disclosing scores or thresholds. -pub const TODO_GATE_DIGEST_PREFIX: &str = "[automated todo quality review - not a user message] Before you treat this turn as finished, double-check the weak points it surfaced. Do not reply conversationally or wait for the user."; +pub const TODO_GATE_DIGEST_PREFIX: &str = "[auto] Before you treat this turn as finished, double-check the weak points it surfaced. Keep the todo up to date. Do not reply or wait for the user."; +const PRE_COMPACT_TODO_GATE_DIGEST_PREFIX: &str = "Before you treat this turn as finished, double-check the weak points it surfaced. Keep the todo up to date. Do not reply or wait for the user."; +const LABELED_TODO_GATE_DIGEST_PREFIX: &str = "[automated todo quality review - not a user message] Before you treat this turn as finished, double-check the weak points it surfaced. Do not reply conversationally or wait for the user."; /// Whether the state behind this observation has since reached its bar. /// @@ -751,23 +764,34 @@ pub fn is_auto_poke_message(message: &str) -> bool { && trimmed.contains(" incomplete todo") && trimmed.ends_with("update the todo tool.")) || trimmed.starts_with(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_TODO_REMINDER_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_TODO_REMINDER_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_OWNERSHIP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX) || trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) + || trimmed.starts_with(PRE_COMPACT_TODO_GATE_DIGEST_PREFIX) + || trimmed.starts_with(LABELED_TODO_GATE_DIGEST_PREFIX) || trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_LONG_SESSION_REVIEW_MESSAGE) || trimmed.starts_with(PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE) } @@ -783,41 +807,53 @@ pub fn auto_poke_display_summary(message: &str) -> Option<&'static str> { return None; } if trimmed.starts_with(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_EVIDENCE_TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE) { return Some("🔍 Double-checking confidence jumps..."); } if trimmed.starts_with(TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_BUDGET_TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE) { return Some("✅ Preparing the final response..."); } if trimmed.starts_with(TODO_COMPLETION_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_COMPLETION_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX) { return Some("🔍 Double-checking confidence for you..."); } - if trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) { + if trimmed.starts_with(TODO_GATE_DIGEST_PREFIX) + || trimmed.starts_with(PRE_COMPACT_TODO_GATE_DIGEST_PREFIX) + || trimmed.starts_with(LABELED_TODO_GATE_DIGEST_PREFIX) + { return Some("🔍 Reviewing the weak points of this turn for you..."); } if trimmed.starts_with(TODO_LONG_SESSION_REVIEW_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_LONG_SESSION_REVIEW_MESSAGE) || trimmed.starts_with(PRE_BUDGET_TODO_LONG_SESSION_REVIEW_MESSAGE) { return Some("🔍 Rechecking the plan and assessments after extended work..."); } if trimmed.starts_with(TODO_OWNERSHIP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_OWNERSHIP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_OWNERSHIP_CONTINUATION_MESSAGE) { return Some("🔍 Checking the delivery state of the finished work..."); } if trimmed.starts_with(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_TODO_REMINDER_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_CONCISE_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE) { return Some("🔍 Re-checking the request was understood..."); } if trimmed.starts_with(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_COMPACT_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) + || trimmed.starts_with(PRE_TODO_REMINDER_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) || trimmed.starts_with(PRE_BUDGET_TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE) || trimmed.starts_with(LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE) @@ -1388,6 +1424,7 @@ mod tests { Some("✅ Preparing the final response...") ); assert!(is_auto_poke_message(LEGACY_TODO_CONFIDENCE_SUMMARY_PREFIX)); + assert!(is_auto_poke_message(LABELED_TODO_GATE_DIGEST_PREFIX)); } #[test] @@ -1395,7 +1432,7 @@ mod tests { for (message, category) in [ ( TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE, - "concrete check for each requirement", + "feedback loop isn't good enough", ), ( TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, @@ -1425,7 +1462,7 @@ mod tests { } } - assert!(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("Update the goal")); + assert!(TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE.contains("Think about")); assert!(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.contains("Try to avoid asking")); for message in [ TODO_OWNERSHIP_CONTINUATION_MESSAGE, @@ -1457,6 +1494,10 @@ mod tests { ("final response", TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE), ("turn digest", TODO_GATE_DIGEST_PREFIX), ] { + assert!( + message.starts_with("[auto] "), + "{name} quality gate does not use the compact automation prefix: {message}" + ); let tokens = jcode_core::util::estimate_tokens(message); assert!( tokens <= TODO_QUALITY_GATE_MAX_APPROX_TOKENS, @@ -1465,6 +1506,23 @@ mod tests { } } + #[test] + fn working_quality_gates_remind_the_model_to_update_todos() { + for (name, message) in [ + ("long session review", TODO_LONG_SESSION_REVIEW_MESSAGE), + ("intent understanding", TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE), + ("closed feedback loop", TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE), + ("ownership", TODO_OWNERSHIP_CONTINUATION_MESSAGE), + ("completion", TODO_COMPLETION_CONTINUATION_MESSAGE), + ("confidence jump", TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE), + ] { + assert!( + message.to_ascii_lowercase().contains("todo"), + "{name} quality gate does not remind the model to update the todo: {message}" + ); + } + } + /// The model must be told which items it should recheck, otherwise the /// nudge is a guess-the-weak-spot puzzle. Scores stay private. #[test] diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index b24c75ce1b..2e1f739592 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1727,12 +1727,12 @@ impl App { // without this a session could only ever deliver one digest. self.todo_gate_digest_delivered = false; self.todo_completion_gate_attempts = 0; - self.push_display_message(DisplayMessage::system(format!( - "✅ All todos done. Completion confidence: {}.", - confidence_label - ))); if !self.todo_final_response_requested { self.todo_final_response_requested = true; + self.push_display_message(DisplayMessage::system(format!( + "✅ All todos done. Completion confidence: {}.", + confidence_label + ))); self.queued_messages .push(crate::todo::TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE.to_string()); self.pending_queued_dispatch = true; diff --git a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs index ccd093f2cb..05a547c824 100644 --- a/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs +++ b/crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs @@ -2701,6 +2701,13 @@ fn test_finish_turn_challenges_confidence_spike_once() { assert!(app.todo_confidence_spike_challenged); assert!(!app.pending_queued_dispatch); assert!(app.queued_messages.is_empty()); + assert_eq!( + app.display_messages() + .iter() + .filter(|message| message.content.contains("All todos done")) + .count(), + 1 + ); }); } From 33be6c066fd6bfc01f2aceee5f4ad409a0001e79 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:27:56 -0700 Subject: [PATCH 104/107] style(todo): apply rustfmt --- crates/jcode-base/src/todo.rs | 44 +++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index a833035136..9c13b51ac9 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -174,10 +174,8 @@ pub fn delivery_state_passes(goal: &TodoGoal) -> bool { const LEGACY_TODO_ALIGNMENT_CONTINUATION_MESSAGE: &str = "Your alignment score is not high enough. Build a requirement inventory from the user's request, including outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through. Revise the plan and its stated user intention to represent every material item. Then map each item to an explicit observation or check in a feedback loop. Generic instructions to run tests, verify, or review count only for requirements those checks actually enforce; add separate checks for non-testable requirements. Reassess the weaker link before continuing the task."; /// Model-facing continuation for the private intent-understanding check. -pub const TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = - "[auto] Understand the user's intent better. Try to avoid asking the user. Make sure the todo is up to date."; -const PRE_COMPACT_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = - "Understand the user's intent better. Try to avoid asking the user. Make sure the todo is up to date."; +pub const TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "[auto] Understand the user's intent better. Try to avoid asking the user. Make sure the todo is up to date."; +const PRE_COMPACT_TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE: &str = "Understand the user's intent better. Try to avoid asking the user. Make sure the todo is up to date."; /// Previous verbose wording, retained so persisted sessions still classify it /// as a hidden quality-gate message after the concise rewrite. @@ -199,7 +197,8 @@ const LEGACY_TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE: &str = "Your hill-clim /// Model-facing continuation for the private end-to-end ownership check. It /// asks for more work without revealing that an evaluator triggered it. -pub const TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[auto] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; +pub const TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = + "[auto] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; const PRE_COMPACT_TODO_OWNERSHIP_CONTINUATION_MESSAGE: &str = "[automated follow-up - not a user message] Continue the work below. Keep the todo up to date; do not reply or wait for the user."; /// Build an ownership continuation that directs work toward each affected goal @@ -1478,7 +1477,10 @@ mod tests { } let spike = TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE.to_ascii_lowercase(); for evaluator_term in ["gate", "flagged", "failed", "threshold", "score"] { - assert!(!spike.contains(evaluator_term), "disclosed {evaluator_term}"); + assert!( + !spike.contains(evaluator_term), + "disclosed {evaluator_term}" + ); } } @@ -1486,11 +1488,20 @@ mod tests { fn static_quality_gate_messages_stay_within_token_budget() { for (name, message) in [ ("long session review", TODO_LONG_SESSION_REVIEW_MESSAGE), - ("intent understanding", TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE), - ("closed feedback loop", TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE), + ( + "intent understanding", + TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, + ), + ( + "closed feedback loop", + TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE, + ), ("ownership", TODO_OWNERSHIP_CONTINUATION_MESSAGE), ("completion", TODO_COMPLETION_CONTINUATION_MESSAGE), - ("confidence jump", TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE), + ( + "confidence jump", + TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, + ), ("final response", TODO_FINAL_RESPONSE_CONTINUATION_MESSAGE), ("turn digest", TODO_GATE_DIGEST_PREFIX), ] { @@ -1510,11 +1521,20 @@ mod tests { fn working_quality_gates_remind_the_model_to_update_todos() { for (name, message) in [ ("long session review", TODO_LONG_SESSION_REVIEW_MESSAGE), - ("intent understanding", TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE), - ("closed feedback loop", TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE), + ( + "intent understanding", + TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, + ), + ( + "closed feedback loop", + TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE, + ), ("ownership", TODO_OWNERSHIP_CONTINUATION_MESSAGE), ("completion", TODO_COMPLETION_CONTINUATION_MESSAGE), - ("confidence jump", TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE), + ( + "confidence jump", + TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE, + ), ] { assert!( message.to_ascii_lowercase().contains("todo"), From 6f098bc511a2af6bdec7d441f5f123a0aef8b952 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:29:40 -0700 Subject: [PATCH 105/107] fix(sdk): keep transcript image history in parity --- crates/jcode-sdk/src/sdk_tests/parity.rs | 1 + sdk/typescript/src/client.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/jcode-sdk/src/sdk_tests/parity.rs b/crates/jcode-sdk/src/sdk_tests/parity.rs index 36e7400454..e7cb61c5ce 100644 --- a/crates/jcode-sdk/src/sdk_tests/parity.rs +++ b/crates/jcode-sdk/src/sdk_tests/parity.rs @@ -39,6 +39,7 @@ const CAPABILITIES: &[Capability] = &[ cap("cancel", "cancel"), cap("soft_interrupt", "softInterrupt"), cap("get_history", "getHistory"), + cap("get_history_with_images", "getHistoryWithImages"), cap("peek_session", "peekSession"), cap("clear", "clear"), cap("rewind", "rewind"), diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 5f3989f2ff..a6e5f7a0e3 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -27,6 +27,7 @@ import { type ImageAttachment, type ModelRouteInfo, type PermissionDecision, + type RenderedImage, type ServerFrame, type SessionInfo, type TextMatch, @@ -544,11 +545,18 @@ export class JcodeClient extends EventEmitter { } async getHistory(sessionId: string): Promise { + const { messages } = await this.getHistoryWithImages(sessionId); + return messages; + } + + async getHistoryWithImages( + sessionId: string, + ): Promise<{ messages: HistoryMessage[]; images: RenderedImage[] }> { const frame = await this.expectReply( { req: "get_history", session_id: sessionId }, "history", ); - return frame.messages ?? []; + return { messages: frame.messages ?? [], images: frame.images ?? [] }; } async peekSession(sessionId: string, limit?: number): Promise { From 2f7e3bcc6d030f3875e5292827b8461ca000e378 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:29:53 -0700 Subject: [PATCH 106/107] chore(release): prepare v0.78.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- changelog/index.json | 4 ++++ changelog/v0.78.0.json | 13 +++++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 changelog/v0.78.0.json diff --git a/Cargo.lock b/Cargo.lock index af5b422088..ec7b736f87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3385,7 +3385,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.77.2" +version = "0.78.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 6e4368fb71..bc6fbd7da8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.77.2" +version = "0.78.0" description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false diff --git a/changelog/index.json b/changelog/index.json index 5943b3f560..b00f25c0e6 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,5 +1,9 @@ { "entries": [ + { + "version": "0.78.0", + "date": "2026-08-18" + }, { "version": "0.77.2", "date": "2026-08-18" diff --git a/changelog/v0.78.0.json b/changelog/v0.78.0.json new file mode 100644 index 0000000000..05e7b5a9fa --- /dev/null +++ b/changelog/v0.78.0.json @@ -0,0 +1,13 @@ +{ + "version": "0.78.0", + "date": "2026-08-18", + "highlights": [ + "Harness API and SDK clients can now receive images embedded in transcript messages" + ], + "improvements": [ + "Todo quality checks now give shorter, clearer guidance and avoid repeatedly blocking final responses" + ], + "fixes": [ + "Completed hook observers are now cleaned up reliably instead of accumulating over time" + ] +} From 37272c9150c5759575acf16c892bb3458439dc7a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:58:59 +0000 Subject: [PATCH 107/107] docs: update star history chart --- docs/images/star-history.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg index 2b7d97e45d..6ca461c92f 100644 --- a/docs/images/star-history.svg +++ b/docs/images/star-history.svg @@ -1,6 +1,6 @@ 1jehuang/jcode star history -GitHub stars over time, currently 17,868 +GitHub stars over time, currently 17,951