diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index 45ce92e077..bd3469d786 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -131,6 +131,7 @@ const CONFIG_ENV_KEYS: &[&str] = &[ "JCODE_DISCORD_CHANNEL_ID", "JCODE_DISCORD_REPLY_ENABLED", "JCODE_DISPLAY_CENTERED", + "JCODE_SINGLE_OVERVIEW", "JCODE_EFFORT_DECREASE_KEY", "JCODE_EFFORT_INCREASE_KEY", "JCODE_SPEED_DECREASE_KEY", @@ -173,6 +174,7 @@ const CONFIG_ENV_KEYS: &[&str] = &[ "JCODE_MEMORY_EMBEDDING_DIM", "JCODE_MEMORY_EMBEDDING_MODEL", "JCODE_MEMORY_ENABLED", + "JCODE_MEMORY_EFFORT", "JCODE_MEMORY_JEV_PROVIDER", "JCODE_ENABLE_MERMAID", "JCODE_MEMORY_MODEL", diff --git a/crates/jcode-base/src/config/config_file.rs b/crates/jcode-base/src/config/config_file.rs index aa95db9c6e..397498d4bc 100644 --- a/crates/jcode-base/src/config/config_file.rs +++ b/crates/jcode-base/src/config/config_file.rs @@ -264,6 +264,18 @@ impl Config { Ok(()) } + /// Update the persisted single-overview preference. + pub fn set_single_overview(single: bool) -> anyhow::Result<()> { + let mut cfg = Self::load_for_update()?; + cfg.display.single_overview = single; + cfg.save()?; + crate::logging::info(&format!( + "Saved display.single_overview to config: {}", + single + )); + Ok(()) + } + /// Update the persisted show-agentgrep-output preference. pub fn set_show_agentgrep_output(show: bool) -> anyhow::Result<()> { let mut cfg = Self::load_for_update()?; diff --git a/crates/jcode-base/src/config/default_file.rs b/crates/jcode-base/src/config/default_file.rs index 226fa8a813..3496f89f03 100644 --- a/crates/jcode-base/src/config/default_file.rs +++ b/crates/jcode-base/src/config/default_file.rs @@ -185,6 +185,13 @@ emoji = true # Usage percentage wording: "left" (default) or "used". usage_display = "left" +# Show the consolidated single Overview panel in the transcript margins +# instead of the separate per-section info widgets (default: true). +# Set false for the classic layout: Todos, Swarm, Background, Compaction, +# Runtime, Usage, KV cache, Git and Commits each dock on their own. +# Env override: JCODE_SINGLE_OVERVIEW. +single_overview = true + # Show thinking/reasoning content (default: true) show_thinking = true @@ -561,9 +568,16 @@ swarm_stall_after_secs = 600 # Optional text-generating extraction is separate from recall. Disable it to # learn only through the main agent's explicit memory writes. # (OpenAI defaults to gpt-5.6-luna with reasoning effort "none"). -# Env overrides: JCODE_MEMORY_SIDECAR_ENABLED, JCODE_MEMORY_MODEL +# Env overrides: JCODE_MEMORY_SIDECAR_ENABLED, JCODE_MEMORY_MODEL, +# JCODE_MEMORY_EFFORT # memory_sidecar_enabled = true # memory_model = "gpt-5.6-luna" +# Reasoning effort for the memory extraction sidecar on any backend: +# OpenAI effort, Claude thinking budget or output_config effort +# (model-dependent), or the live provider's effort knob +# ("none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | ...). +# Leave unset for the per-model default. +# memory_effort = "none" # Legacy memory_rerank_* and memory_embedding_* settings are accepted for # backwards compatibility, but have no effect on Jev recall. diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index 5ad7f03017..6767e44d4a 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -211,6 +211,11 @@ impl Config { self.display.centered = parsed; } } + if let Ok(v) = std::env::var("JCODE_SINGLE_OVERVIEW") { + if let Some(parsed) = parse_env_bool(&v) { + self.display.single_overview = parsed; + } + } if let Ok(v) = std::env::var("JCODE_QUEUE_MODE") { if let Some(parsed) = parse_env_bool(&v) { self.display.queue_mode = parsed; @@ -464,6 +469,14 @@ impl Config { Some(trimmed.to_string()) }; } + if let Ok(v) = std::env::var("JCODE_MEMORY_EFFORT") { + let trimmed = v.trim(); + self.agents.memory_effort = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } if let Ok(v) = std::env::var("JCODE_MEMORY_SIDECAR_ENABLED") { if let Some(parsed) = parse_env_bool(&v) { self.agents.memory_sidecar_enabled = parsed; diff --git a/crates/jcode-base/src/config_tests.rs b/crates/jcode-base/src/config_tests.rs index d5d5035b7b..e1affd912e 100644 --- a/crates/jcode-base/src/config_tests.rs +++ b/crates/jcode-base/src/config_tests.rs @@ -376,6 +376,39 @@ fn swarm_effort_parses_from_toml_and_env_override() { restore_env_var("JCODE_SWARM_EFFORT", prev); } +#[test] +fn memory_effort_parses_from_toml_and_env_override() { + let _guard = crate::storage::lock_test_env(); + let prev = std::env::var_os("JCODE_MEMORY_EFFORT"); + restore_env_var("JCODE_MEMORY_EFFORT", None); + + // Public config-file interface, mirroring swarm_effort. + let cfg: Config = + toml::from_str("[agents]\nmemory_model = \"gpt-5.6-luna\"\nmemory_effort = \"none\"\n") + .expect("config with memory_effort parses"); + assert_eq!(cfg.agents.memory_effort.as_deref(), Some("none")); + assert_eq!(Config::default().agents.memory_effort, None); + + // Older config files without the key still parse (backward compat). + let cfg: Config = toml::from_str("[agents]\nmemory_model = \"gpt-5.6-luna\"\n") + .expect("config without memory_effort parses"); + assert_eq!(cfg.agents.memory_effort, None); + + crate::env::set_var("JCODE_MEMORY_EFFORT", "low"); + let mut cfg = Config::default(); + cfg.apply_env_overrides(); + assert_eq!(cfg.agents.memory_effort.as_deref(), Some("low")); + + // A blank env value clears a preset (unset semantics, like swarm_effort). + crate::env::set_var("JCODE_MEMORY_EFFORT", " "); + let mut cfg = Config::default(); + cfg.agents.memory_effort = Some("preset".to_string()); + cfg.apply_env_overrides(); + assert_eq!(cfg.agents.memory_effort, None); + + restore_env_var("JCODE_MEMORY_EFFORT", prev); +} + #[test] fn wake_mode_defaults_parses_and_env_overrides() { let _guard = crate::storage::lock_test_env(); @@ -564,17 +597,21 @@ fn test_memory_sidecar_enabled_defaults_true() { fn test_env_override_memory_sidecar() { let _guard = crate::storage::lock_test_env(); let prev_model = std::env::var_os("JCODE_MEMORY_MODEL"); + let prev_effort = std::env::var_os("JCODE_MEMORY_EFFORT"); let prev_enabled = std::env::var_os("JCODE_MEMORY_SIDECAR_ENABLED"); crate::env::set_var("JCODE_MEMORY_MODEL", "claude-haiku-4"); + crate::env::set_var("JCODE_MEMORY_EFFORT", "low"); crate::env::set_var("JCODE_MEMORY_SIDECAR_ENABLED", "true"); let mut cfg = Config::default(); cfg.apply_env_overrides(); assert_eq!(cfg.agents.memory_model.as_deref(), Some("claude-haiku-4")); + assert_eq!(cfg.agents.memory_effort.as_deref(), Some("low")); assert!(cfg.agents.memory_sidecar_enabled); restore_env_var("JCODE_MEMORY_MODEL", prev_model); + restore_env_var("JCODE_MEMORY_EFFORT", prev_effort); restore_env_var("JCODE_MEMORY_SIDECAR_ENABLED", prev_enabled); } @@ -820,6 +857,10 @@ fn test_generated_default_config_has_expected_user_defaults() { jcode_config_types::ReasoningDisplayMode::Full, "freshly created user config should show the full reasoning trace" ); + assert!( + parsed.display.single_overview, + "freshly created user config should use the consolidated overview" + ); if let Some(prev) = prev_home { crate::env::set_var("JCODE_HOME", prev); @@ -1750,3 +1791,45 @@ fn removed_overscroll_status_key_still_loads_config() { assert!(config.display.centered); assert_eq!(config.display.usage_display, "used"); } + +/// `display.single_overview = false` must reach the live config cache, and +/// `JCODE_SINGLE_OVERVIEW=0` must override the file value. The TUI's Overview +/// placement gates on this flag, so a stale value would resurrect the +/// consolidated panel after the user opted out. +#[test] +fn single_overview_setting_and_env_override_load() { + let _guard = crate::storage::lock_test_env(); + let prev_home = std::env::var_os("JCODE_HOME"); + let prev_env = std::env::var_os("JCODE_SINGLE_OVERVIEW"); + let dir = tempfile::TempDir::new().expect("tempdir"); + crate::env::set_var("JCODE_HOME", dir.path()); + crate::env::remove_var("JCODE_SINGLE_OVERVIEW"); + Config::invalidate_cache(); + + let path = Config::path().expect("config path"); + std::fs::create_dir_all(path.parent().expect("config parent")).expect("create config parent"); + std::fs::write(&path, "[display]\nsingle_overview = false\n").expect("write config"); + + assert!( + !crate::config::config().display.single_overview, + "config file value false must disable the consolidated overview" + ); + + crate::env::set_var("JCODE_SINGLE_OVERVIEW", "1"); + Config::invalidate_cache(); + assert!( + crate::config::config().display.single_overview, + "JCODE_SINGLE_OVERVIEW=1 must override the file value" + ); + + crate::env::set_var("JCODE_SINGLE_OVERVIEW", "0"); + Config::invalidate_cache(); + assert!( + !crate::config::config().display.single_overview, + "JCODE_SINGLE_OVERVIEW=0 must override the file value" + ); + + restore_env_var("JCODE_SINGLE_OVERVIEW", prev_env); + restore_env_var("JCODE_HOME", prev_home); + Config::invalidate_cache(); +} diff --git a/crates/jcode-base/src/mcp/remote_tests.rs b/crates/jcode-base/src/mcp/remote_tests.rs index a50d13803f..c99b255c58 100644 --- a/crates/jcode-base/src/mcp/remote_tests.rs +++ b/crates/jcode-base/src/mcp/remote_tests.rs @@ -761,8 +761,15 @@ async fn remote_handshake_deadline_is_independent_of_long_tool_timeout() { open_stream(&mut socket, "text/event-stream").await.unwrap(); started_tx.send(()).unwrap(); // Never send a response. The client must close the socket on deadline. + // The client can close with unread SSE bytes still in flight, so the + // peer may send RST instead of FIN; both orderly EOF and reset are a + // valid close from the client's side. let mut bytes = Vec::new(); - socket.read_to_end(&mut bytes).await.unwrap(); + match socket.read_to_end(&mut bytes).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {} + Err(e) => panic!("server read failed: {e}"), + } }); let config: McpServerConfig = serde_json::from_value(json!({ "type": "http", "url": url, "timeout_secs": 3600 diff --git a/crates/jcode-base/src/sidecar.rs b/crates/jcode-base/src/sidecar.rs index d538121733..3f6e484a4b 100644 --- a/crates/jcode-base/src/sidecar.rs +++ b/crates/jcode-base/src/sidecar.rs @@ -35,8 +35,11 @@ const CLAUDE_API_URL: &str = "https://api.anthropic.com/v1/messages?beta=true"; /// Claude Messages API endpoint for direct API-key access (no OAuth beta flag). const CLAUDE_API_KEY_URL: &str = "https://api.anthropic.com/v1/messages"; -/// Beta headers required for OAuth -const OAUTH_BETA_HEADERS: &str = "oauth-2025-04-20,claude-code-20250219"; +/// Beta headers required for OAuth. When `agents.memory_effort` adds +/// `output_config` effort or manual thinking, the request also needs the +/// effort/thinking betas (mirrors the runtime's header handling). +const OAUTH_BETA_HEADERS: &str = + "oauth-2025-04-20,claude-code-20250219,effort-2025-11-24,interleaved-thinking-2025-05-14"; /// Claude Code identity block required for OAuth direct API access const CLAUDE_CODE_IDENTITY: &str = "You are Claude Code, Anthropic's official CLI for Claude."; @@ -44,6 +47,8 @@ const CLAUDE_CODE_JCODE_NOTICE: &str = "You are jcode, powered by Claude Code. Y /// Maximum tokens for sidecar responses (keep small for speed/cost) const DEFAULT_MAX_TOKENS: u32 = 1024; +const CLAUDE_MIN_THINKING_BUDGET: u32 = 1_024; +const CLAUDE_THINKING_ANSWER_HEADROOM: u32 = 2_048; /// Whether retrying a failed sidecar request can reasonably succeed without a /// configuration or credential change. @@ -156,7 +161,18 @@ impl Sidecar { /// Prefers OpenAI (GPT-5.6 Luna with no reasoning) if creds exist, falls back to Claude. pub fn new() -> Self { let configured_model = crate::config::config().agents.memory_model.clone(); - Self::with_configured_model(configured_model) + // An empty/whitespace effort from the config file (the env override + // already trims) must mean "unset", not an invalid `""` effort pin. + // Surrounding spaces would flow into the request as `" low "`, so trim + // before storing: the OpenAI pin and the Claude budget lookup both + // match exact strings. + let configured_effort = crate::config::config() + .agents + .memory_effort + .clone() + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()); + Self::with_configured_model(configured_model, configured_effort) } pub fn for_session(session: &crate::session::Session) -> Self { @@ -168,6 +184,18 @@ impl Sidecar { } else { model }; + // Read the configured effort up front: the provider fork branch below + // returns early, and skipping this read there meant `for_session` + // ignored `agents.memory_effort` whenever a live provider could serve + // the configured model (greptile). The fork pin happens after the + // fork's model is selected, so `set_model_with_auth_refresh` never + // clobbers the effort. + let configured_effort = crate::config::Config::load() + .agents + .memory_effort + .clone() + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()); if let Some(model) = model.as_deref() && let Some(provider) = crate::provider::active_provider_fork() { @@ -181,13 +209,24 @@ impl Sidecar { model.to_string() }; if crate::provider::set_model_with_auth_refresh(provider.as_ref(), &request).is_ok() { + // The fork is independent of the main agent (per the Provider + // fork contract), so pinning `agents.memory_effort` on it + // never disturbs the user's live session effort. Providers + // without an effort knob reject; keep their default. + if let Some(effort) = configured_effort.as_deref() + && let Err(err) = provider.set_reasoning_effort(effort) + { + crate::logging::warn(&format!( + "Ignoring memory effort '{effort}' for provider sidecar: {err}" + )); + } return Self { client: crate::provider::shared_http_client(), model: provider.model(), max_tokens: DEFAULT_MAX_TOKENS, backend: SidecarBackend::Provider, provider: Some(provider), - reasoning_override: None, + reasoning_override: configured_effort, }; } } @@ -200,10 +239,13 @@ impl Sidecar { .map(|(_, id)| id.to_string()) .unwrap_or(model) }); - Self::with_configured_model(model) + Self::with_configured_model(model, configured_effort) } - fn with_configured_model(configured_model: Option) -> Self { + fn with_configured_model( + configured_model: Option, + configured_effort: Option, + ) -> Self { let (backend, model, provider) = if let Some(model) = configured_model { match crate::provider::provider_for_model(&model) { Some("openai") => (SidecarBackend::OpenAI, model, None), @@ -225,8 +267,23 @@ impl Sidecar { model, max_tokens: DEFAULT_MAX_TOKENS, backend, - provider, - reasoning_override: None, + provider: provider.inspect(|fork| { + // The fork is independent of the main agent (per the Provider + // fork contract), so pinning `agents.memory_effort` on it + // never disturbs the user's live session effort. Providers + // without an effort knob reject; keep their default. + if let Some(effort) = configured_effort.as_deref() + && let Err(err) = fork.set_reasoning_effort(effort) + { + crate::logging::warn(&format!( + "Ignoring memory effort '{effort}' for provider sidecar: {err}" + )); + } + }), + // `agents.memory_effort` pins the reasoning effort on the OpenAI + // sidecar path; the Claude path derives its thinking budget from + // it per request (see `claude_reasoning_parts`). + reasoning_override: configured_effort, } } @@ -378,11 +435,7 @@ impl Sidecar { .context("Failed to load OpenAI/Codex credentials for sidecar")?; let is_chatgpt_mode = !creds.refresh_token.is_empty() || creds.id_token.is_some(); - let base = if is_chatgpt_mode { - CHATGPT_API_BASE - } else { - OPENAI_API_BASE - }; + let base = openai_responses_base(is_chatgpt_mode); let url = format!("{}/{}", base.trim_end_matches('/'), OPENAI_RESPONSES_PATH); let (primary_model, resolved_reasoning) = @@ -425,6 +478,15 @@ impl Sidecar { reason )); + // The retry keeps the configured effort (`agents.memory_effort`): + // an override must survive the fallback, and with `none` + // configured the replacement request must not start thinking. + // Only without an override does the fallback model keep its + // own low-effort default. + let fallback_reasoning: Option<&str> = self + .reasoning_override + .as_deref() + .or(Some(SIDECAR_OPENAI_OAUTH_FALLBACK_REASONING)); let fallback = self .complete_openai_with_model( &url, @@ -434,7 +496,7 @@ impl Sidecar { system, user_message, SIDECAR_OPENAI_OAUTH_FALLBACK_MODEL, - Some(SIDECAR_OPENAI_OAUTH_FALLBACK_REASONING), + fallback_reasoning, ) .await; @@ -588,14 +650,21 @@ impl Sidecar { let creds = auth::claude::load_credentials() .context("Failed to load Claude credentials for sidecar")?; + let (thinking, output_config, request_max_tokens) = claude_reasoning_parts( + &self.model, + self.reasoning_override.as_deref(), + self.max_tokens, + ); let request = ClaudeMessagesRequest { model: &self.model, - max_tokens: self.max_tokens, + max_tokens: request_max_tokens, system: build_claude_system_param(system), messages: vec![ClaudeMessage { role: "user", content: user_message, }], + thinking, + output_config, }; let response = crate::provider::anthropic::apply_oauth_attribution_headers( @@ -630,22 +699,36 @@ impl Sidecar { user_message: &str, api_key: &str, ) -> Result { + let (thinking, output_config, request_max_tokens) = claude_reasoning_parts( + &self.model, + self.reasoning_override.as_deref(), + self.max_tokens, + ); let request = ClaudeMessagesRequest { model: &self.model, - max_tokens: self.max_tokens, + max_tokens: request_max_tokens, system: build_claude_api_key_system_param(system), messages: vec![ClaudeMessage { role: "user", content: user_message, }], + thinking, + output_config, }; + // Manual/adaptive thinking needs the interleaved-thinking beta + // (mirrors the runtime's `anthropic_beta_header_with_thinking`). + let api_key_betas = if request.thinking.is_some() { + "prompt-caching-2024-07-31,interleaved-thinking-2025-05-14" + } else { + "prompt-caching-2024-07-31" + }; let response = self .client .post(CLAUDE_API_KEY_URL) .header("x-api-key", api_key) .header("anthropic-version", "2023-06-01") - .header("anthropic-beta", "prompt-caching-2024-07-31") + .header("anthropic-beta", api_key_betas) .header("content-type", "application/json") .json(&request) .send() @@ -852,6 +935,25 @@ impl Default for Sidecar { #[cfg(test)] pub const SIDECAR_FAST_MODEL: &str = SIDECAR_OPENAI_MODEL; +/// Resolve the Responses API base for the sidecar: the ChatGPT backend in +/// OAuth mode, the standard API base in API-key mode. A test-only override +/// (`JCODE_SIDECAR_TEST_API_BASE`) redirects both to a loopback fixture so +/// retry/fallback behavior can be asserted against real HTTP requests +/// instead of constants; it is ignored outside `cfg(test)`. +fn openai_responses_base(is_chatgpt_mode: bool) -> String { + #[cfg(test)] + if let Ok(base) = std::env::var("JCODE_SIDECAR_TEST_API_BASE") + && !base.trim().is_empty() + { + return base.trim().trim_end_matches('/').to_string(); + } + if is_chatgpt_mode { + CHATGPT_API_BASE.to_string() + } else { + OPENAI_API_BASE.to_string() + } +} + fn resolve_openai_request_model( preferred_model: &str, is_chatgpt_mode: bool, @@ -1112,6 +1214,10 @@ struct ClaudeMessagesRequest<'a> { #[serde(skip_serializing_if = "Option::is_none")] system: Option>, messages: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + thinking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output_config: Option, } #[derive(Serialize)] @@ -1120,6 +1226,136 @@ struct ClaudeMessage<'a> { content: &'a str, } +/// Reasoning thinking control for the Claude sidecar path, derived from +/// `agents.memory_effort` and the model's capability set (single source of +/// truth: `jcode_provider_core::anthropic_reasoning_caps`). +/// +/// Mirrors the runtime's `build_reasoning_request_parts_with_effort`: +/// - `output_config` effort when the model supports it, +/// - a manual thinking budget for manual-thinking models, +/// - adaptive thinking for adaptive-capable models (Sonnet 4.6 style: the +/// request must carry `thinking` for the configured effort to take +/// effect), +/// - nothing when the model has no reasoning control or the effort is `none`. +/// +/// Claude requires `thinking.budget_tokens` to be strictly smaller than +/// `max_tokens`. When the configured response limit would clamp the effort's +/// budget, the budget wins and the request limit is raised so memory +/// extraction can still think at the requested level and return text. +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case", tag = "type")] +enum ClaudeThinking { + Enabled { + budget_tokens: u32, + }, + Adaptive { + #[serde(skip_serializing_if = "Option::is_none")] + display: Option<&'static str>, + }, +} + +/// Effort control for models that accept `output_config: {effort}`. +#[derive(Serialize)] +struct ClaudeOutputConfig { + effort: String, +} + +/// Reasoning request parts for the Claude sidecar path, derived from +/// `agents.memory_effort` and the model's capability set (single source of +/// truth: `jcode_provider_core::anthropic_reasoning_caps`). +/// +/// Mirrors the runtime's `build_reasoning_request_parts_with_effort`: +/// - `output_config` effort when the model supports it, +/// - a manual thinking budget otherwise, +/// - nothing when the model has no reasoning control or the effort is `none`. +/// +/// Claude requires `thinking.budget_tokens` to be strictly smaller than +/// `max_tokens`. When a small configured response limit would leave no answer +/// room above the minimum thinking budget, the request limit is raised so memory +/// extraction can still use manual thinking and return text. +fn claude_reasoning_parts( + model: &str, + effort: Option<&str>, + max_tokens: u32, +) -> (Option, Option, u32) { + let Some(effort) = effort else { + return (None, None, max_tokens); + }; + let caps = jcode_provider_core::anthropic::anthropic_reasoning_caps(model); + // Always-on thinking models (Opus 5.5, Fable 5.1) cannot disable thinking, + // so `none` means the lowest supported effort, same mapping as the main + // Claude runtime (`build_reasoning_request_parts_for_budget`). Without + // this the early return below would drop the effort control entirely and + // leave the model at its default (higher) effort. + let effort = if effort == "none" + && jcode_provider_core::anthropic::anthropic_thinking_always_on(model) + { + "low" + } else { + effort + }; + if !caps.supports_reasoning_effort() || effort == "none" { + return (None, None, max_tokens); + } + // Both controls are independent, like the runtime: models with both + // (Opus 4.5) send output_config effort AND a manual budget. + let output_config = caps.output_effort.then(|| { + // Clamp levels the model doesn't accept, same ladder as the runtime. + let resolved = if effort == "minimal" { + "low" + } else if matches!(effort, "max" | "xhigh") && !caps.xhigh_effort && !caps.max_effort { + "high" + } else if effort == "max" && !caps.max_effort { + "xhigh" + } else if effort == "xhigh" && !caps.xhigh_effort { + "high" + } else { + effort + }; + ClaudeOutputConfig { + effort: resolved.to_string(), + } + }); + // Manual-thinking models need a concrete budget; adaptive-capable models + // need the `thinking` field present (without it the request runs without + // thinking and `output_config.effort` alone does nothing); others rely on + // output_config above. + let thinking = if caps.manual_thinking { + let budget = match effort { + "minimal" | "low" => CLAUDE_MIN_THINKING_BUDGET, + "medium" => 4_096, + "high" => 8_192, + "xhigh" | "max" => 16_384, + _ => return (None, output_config, max_tokens), + }; + // The effort-selected budget wins over the configured response limit: + // clamping it down to `max_tokens - 1` collapses every effort above + // `low` into the 1,024 minimum (greptile). Keep the requested budget + // and raise the request limit instead so the answer keeps room. + Some(ClaudeThinking::Enabled { + budget_tokens: budget, + }) + } else if caps.adaptive_thinking { + // Mirrors the runtime's `reasoning_request::adaptive_thinking`: the + // request carries `thinking` so the configured effort actually + // enables adaptive thinking (greptile: without this branch Sonnet 4.6 + // ran memory extraction with no thinking at all). + Some(ClaudeThinking::Adaptive { + display: Some("summarized"), + }) + } else { + None + }; + let request_max_tokens = match &thinking { + Some(ClaudeThinking::Enabled { budget_tokens }) => { + max_tokens.max(budget_tokens + CLAUDE_THINKING_ANSWER_HEADROOM) + } + // Adaptive thinking has no budget: the request limit is unchanged. + _ => max_tokens, + }; + (thinking, output_config, request_max_tokens) +} + #[derive(Serialize)] #[serde(untagged)] enum ClaudeApiSystem<'a> { @@ -1357,13 +1593,327 @@ mod tests { }) .expect("write Claude test auth"); - let sidecar = Sidecar::with_configured_model(None); + let sidecar = Sidecar::with_configured_model(None, None); assert_eq!(sidecar.backend, SidecarBackend::OpenAI); assert_eq!(sidecar.model, SIDECAR_OPENAI_MODEL); codex::set_active_account_override(None); crate::auth::claude::set_active_account_override(None); } + #[test] + fn configured_memory_effort_pins_reasoning_override() { + // The effort pin is backend-independent: no credential isolation is + // needed, only the reasoning override itself matters. + let sidecar = Sidecar::with_configured_model(None, Some("low".to_string())); + assert_eq!(sidecar.reasoning_override.as_deref(), Some("low")); + + // Unset effort leaves the per-model default in place. + let sidecar = Sidecar::with_configured_model(None, None); + assert_eq!(sidecar.reasoning_override, None); + } + + #[test] + fn sidecar_new_ignores_empty_memory_effort() { + // A blank effort in the config file must mean "unset", never an + // invalid `""`/whitespace pin that would reach the OpenAI request. + // Padded values must trim to the bare token: providers match exact + // strings, so a raw `" low "` would break the pin lookup. + // Drives the real config file parse via the global config cache. + let _guard = crate::storage::lock_test_env(); + let saved_home = std::env::var_os("JCODE_HOME"); + let dir = tempfile::TempDir::new().expect("tempdir"); + crate::env::set_var("JCODE_HOME", dir.path()); + std::fs::write( + dir.path().join("config.toml"), + "[agents]\nmemory_effort = \" \"\n", + ) + .expect("write config"); + let sidecar = Sidecar::new(); + assert_eq!(sidecar.reasoning_override, None); + + std::fs::write( + dir.path().join("config.toml"), + "[agents]\nmemory_effort = \" low \"\n", + ) + .expect("write config"); + let sidecar = Sidecar::new(); + assert_eq!( + sidecar.reasoning_override.as_deref(), + Some("low"), + "padded effort must trim, not pin the raw spaced string" + ); + + std::fs::write( + dir.path().join("config.toml"), + "[agents]\nmemory_effort = \"low\"\n", + ) + .expect("write config"); + let sidecar = Sidecar::new(); + assert_eq!(sidecar.reasoning_override.as_deref(), Some("low")); + + // Restore the saved home: the temp dir dies with this test, and a + // process-wide JCODE_HOME pointing at a deleted directory poisons + // later tests. + if let Some(home) = saved_home { + crate::env::set_var("JCODE_HOME", home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } + + #[test] + fn claude_reasoning_parts_follow_model_caps() { + // Manual-thinking model (Opus 4.5): concrete budget + output_config. + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts("claude-opus-4-5", Some("low"), 8_192); + assert_eq!(request_max_tokens, 8_192); + assert_eq!(output_config.expect("effort").effort, "low"); + match thinking { + Some(ClaudeThinking::Enabled { budget_tokens }) => assert_eq!(budget_tokens, 1_024), + other => panic!("expected manual thinking budget, got {other:?}"), + } + + // Modern ladder model (Opus 4.7): output_config effort AND adaptive + // thinking (FULL caps; pre-fix the thinking field was omitted). + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts("claude-opus-4-7", Some("medium"), DEFAULT_MAX_TOKENS); + assert!( + matches!(&thinking, Some(ClaudeThinking::Adaptive { .. })), + "expected adaptive thinking, got {thinking:?}" + ); + assert_eq!(request_max_tokens, DEFAULT_MAX_TOKENS); + assert_eq!(output_config.expect("effort").effort, "medium"); + + // Opus 4.6 has no xhigh: clamps down to high. + let (_, output_config, _) = + claude_reasoning_parts("claude-opus-4-6", Some("xhigh"), DEFAULT_MAX_TOKENS); + assert_eq!(output_config.expect("effort").effort, "high"); + + // `minimal` maps to `low` on the output_config ladder. + let (_, output_config, _) = + claude_reasoning_parts("claude-opus-4-7", Some("minimal"), DEFAULT_MAX_TOKENS); + assert_eq!(output_config.expect("effort").effort, "low"); + + // `none` disables reasoning on models that support a real off switch. + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts("claude-opus-4-7", Some("none"), DEFAULT_MAX_TOKENS); + assert!(thinking.is_none() && output_config.is_none()); + assert_eq!(request_max_tokens, DEFAULT_MAX_TOKENS); + + // Always-on thinking models (Opus 5.5, Fable 5.1) cannot disable + // thinking, so `none` maps to the lowest supported effort, same as + // the main Claude runtime. The request must carry the effort control + // and the adaptive `thinking` field (without `thinking` the request + // runs without reasoning and the effort control alone does nothing). + for model in ["claude-opus-5-5", "claude-fable-5-1"] { + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts(model, Some("none"), DEFAULT_MAX_TOKENS); + assert_eq!(request_max_tokens, DEFAULT_MAX_TOKENS); + assert_eq!( + output_config + .as_ref() + .expect("always-on model must keep the effort control") + .effort, + "low", + "{model}: `none` must map to `low`" + ); + match &thinking { + Some(ClaudeThinking::Adaptive { display }) => { + assert_eq!(*display, Some("summarized"), "{model}"); + } + other => panic!("{model}: expected adaptive thinking, got {other:?}"), + } + } + + // Sonnet 4.6 (adaptive + output effort, no xhigh): the request must + // carry BOTH `thinking: {type: adaptive}` and `output_config.effort`. + // Pre-fix, `thinking` was omitted entirely and Sonnet 4.6 ran memory + // extraction without any thinking (greptile). + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts("claude-sonnet-4-6", Some("medium"), DEFAULT_MAX_TOKENS); + assert_eq!( + request_max_tokens, DEFAULT_MAX_TOKENS, + "adaptive thinking adds no budget" + ); + assert_eq!( + output_config.expect("Sonnet 4.6 must keep effort").effort, + "medium" + ); + match &thinking { + Some(ClaudeThinking::Adaptive { display }) => { + assert_eq!(*display, Some("summarized")); + } + other => panic!("expected adaptive thinking for Sonnet 4.6, got {other:?}"), + } + + // `none` on Sonnet 4.6: nothing is added (it has a real off switch). + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts("claude-sonnet-4-6", Some("none"), DEFAULT_MAX_TOKENS); + assert!(thinking.is_none() && output_config.is_none()); + assert_eq!(request_max_tokens, DEFAULT_MAX_TOKENS); + + // No effort: nothing added, request stays minimal. + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts(SIDECAR_CLAUDE_MODEL, None, 1_024); + assert!(thinking.is_none() && output_config.is_none()); + assert_eq!(request_max_tokens, 1_024); + + // Small configured limit must NOT clamp the effort budget (greptile: + // the old `min(max_tokens - 1)` collapsed every effort above `low` to + // the 1,024 minimum). The budget wins and the request limit rises. + let (thinking, _, request_max_tokens) = + claude_reasoning_parts("claude-sonnet-3-7", Some("high"), 2_000); + match thinking { + Some(ClaudeThinking::Enabled { budget_tokens }) => { + assert_eq!(budget_tokens, 8_192, "effort budget must survive"); + assert!(budget_tokens < request_max_tokens); + assert_eq!(request_max_tokens, 8_192 + CLAUDE_THINKING_ANSWER_HEADROOM); + } + other => panic!("expected preserved budget, got {other:?}"), + } + + // Sonnet 3.7 (manual-only) at the sidecar's default 1,024-token + // limit: the configured budget is preserved and the request limit + // rises to leave answer room. + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts("claude-sonnet-3-7", Some("low"), DEFAULT_MAX_TOKENS); + assert!(output_config.is_none(), "Sonnet 3.7 has no output_config"); + match thinking { + Some(ClaudeThinking::Enabled { budget_tokens }) => { + assert_eq!(budget_tokens, CLAUDE_MIN_THINKING_BUDGET); + assert!(budget_tokens < request_max_tokens); + assert!(request_max_tokens >= budget_tokens + CLAUDE_THINKING_ANSWER_HEADROOM); + } + other => panic!("expected minimum thinking budget, got {other:?}"), + } + + // Higher efforts on Sonnet 3.7 must scale the budget, not collapse + // to the minimum (the pre-fix bug: medium/high/max all sent 1,024). + for (effort, want_budget) in [("medium", 4_096u32), ("high", 8_192), ("max", 16_384)] { + let (thinking, _, request_max_tokens) = + claude_reasoning_parts("claude-sonnet-3-7", Some(effort), DEFAULT_MAX_TOKENS); + match thinking { + Some(ClaudeThinking::Enabled { budget_tokens }) => { + assert_eq!(budget_tokens, want_budget, "effort {effort}"); + assert!(budget_tokens < request_max_tokens, "effort {effort}"); + } + other => panic!("expected budget for effort {effort}, got {other:?}"), + } + } + + let (thinking, _, request_max_tokens) = + claude_reasoning_parts("claude-sonnet-3-7", Some("low"), 8_192); + match thinking { + Some(ClaudeThinking::Enabled { budget_tokens }) => { + assert_eq!(budget_tokens, CLAUDE_MIN_THINKING_BUDGET); + assert!(budget_tokens < request_max_tokens); + } + other => panic!("expected minimum thinking budget, got {other:?}"), + } + assert_eq!(request_max_tokens, 8_192); + + // Sidecar default (Haiku 4.5) and unknown families: no reasoning + // control, nothing added. + for model in [SIDECAR_CLAUDE_MODEL, "totally-unknown-model"] { + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts(model, Some("high"), DEFAULT_MAX_TOKENS); + assert!(thinking.is_none() && output_config.is_none(), "{model}"); + assert_eq!(request_max_tokens, DEFAULT_MAX_TOKENS); + } + } + + #[test] + fn claude_request_omits_reasoning_fields_when_unset() { + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts(SIDECAR_CLAUDE_MODEL, None, 1_024); + let request = ClaudeMessagesRequest { + model: SIDECAR_CLAUDE_MODEL, + max_tokens: request_max_tokens, + system: None, + messages: vec![ClaudeMessage { + role: "user", + content: "hi", + }], + thinking, + output_config, + }; + let json = serde_json::to_value(&request).expect("serialize"); + assert!(json.get("thinking").is_none(), "{json}"); + assert!(json.get("output_config").is_none(), "{json}"); + } + + #[test] + fn claude_request_serializes_manual_thinking() { + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts("claude-opus-4-5", Some("low"), 8_192); + let request = ClaudeMessagesRequest { + model: "claude-opus-4-5", + max_tokens: request_max_tokens, + system: None, + messages: vec![ClaudeMessage { + role: "user", + content: "hi", + }], + thinking, + output_config, + }; + let json = serde_json::to_value(&request).expect("serialize"); + assert_eq!( + json.get("thinking").and_then(|t| t.get("type")), + Some(&serde_json::json!("enabled")), + "{json}" + ); + assert_eq!( + json.pointer("/thinking/budget_tokens"), + Some(&serde_json::json!(1_024)), + "{json}" + ); + } + + #[test] + fn claude_request_raises_manual_thinking_max_tokens() { + let (thinking, output_config, request_max_tokens) = + claude_reasoning_parts("claude-sonnet-3-7", Some("low"), DEFAULT_MAX_TOKENS); + let request = ClaudeMessagesRequest { + model: "claude-sonnet-3-7", + max_tokens: request_max_tokens, + system: None, + messages: vec![ClaudeMessage { + role: "user", + content: "hi", + }], + thinking, + output_config, + }; + let json = serde_json::to_value(&request).expect("serialize"); + let budget_tokens = json + .pointer("/thinking/budget_tokens") + .and_then(|value| value.as_u64()) + .expect("thinking budget"); + let max_tokens = json + .get("max_tokens") + .and_then(|value| value.as_u64()) + .expect("max tokens"); + + assert!(budget_tokens < max_tokens, "{json}"); + assert!( + max_tokens >= budget_tokens + u64::from(CLAUDE_THINKING_ANSWER_HEADROOM), + "{json}" + ); + assert_eq!(json.get("output_config"), None, "{json}"); + } + + #[test] + fn sidecar_unsupported_model_override_falls_back_to_auto_select() { + let sidecar = Sidecar::with_configured_model( + Some("totally-unknown-model".to_string()), + Some("low".to_string()), + ); + // The configured effort survives the model fallback: it pins the + // OpenAI sidecar path whichever model it lands on. + assert_eq!(sidecar.reasoning_override.as_deref(), Some("low")); + } + #[test] fn test_chatgpt_oauth_uses_luna_with_no_reasoning_when_available() { let _guard = crate::storage::lock_test_env(); @@ -1437,6 +1987,208 @@ mod tests { assert_eq!(reasoning, Some(SIDECAR_OPENAI_REASONING)); } + // ---- ChatGPT Luna-unavailable retry (greptile P2: memory retries ignored effort) + + /// Loopback Responses-API fixture: denies `gpt-5.6-luna` with the + /// model-unavailable body the production classifier recognizes, then + /// accepts the fallback model. Records every request body so tests can + /// assert what BOTH the first request and its replacement actually sent. + /// Success replies are SSE (ChatGPT OAuth mode requires streaming). + struct LunaUnavailableFixture { + requests: std::sync::Arc>>, + } + + impl LunaUnavailableFixture { + /// Bind a loopback port, return the fixture plus its base URL (known + /// before the handler thread takes ownership of the listener). + fn spawn() -> (Self, String) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + listener.set_nonblocking(true).unwrap(); + let requests: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let recorder = std::sync::Arc::clone(&requests); + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let mut served = 0; + while served < 2 && std::time::Instant::now() < deadline { + let (mut stream, _) = match listener.accept() { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(5)); + continue; + } + Err(error) => panic!("{error}"), + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .unwrap(); + let mut bytes = Vec::new(); + let (header_end, length) = loop { + let mut chunk = [0; 4096]; + let n = stream.read(&mut chunk).unwrap(); + assert!(n > 0); + bytes.extend_from_slice(&chunk[..n]); + if let Some(offset) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..offset]); + let length = headers + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + break (offset + 4, length); + } + }; + while bytes.len() < header_end + length { + let mut chunk = [0; 4096]; + let n = stream.read(&mut chunk).unwrap(); + assert!(n > 0); + bytes.extend_from_slice(&chunk[..n]); + } + let body: serde_json::Value = + serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap(); + recorder.lock().unwrap().push(body.clone()); + let response = if body["model"] == SIDECAR_OPENAI_MODEL { + // Same denial the production classifier recognizes + // ("model ... not available" + 404). + served += 1; + ( + "HTTP/1.1 404 Not Found", + serde_json::json!({ + "error": { + "message": format!( + "Model '{}' is not available for your account", + SIDECAR_OPENAI_MODEL + ) + } + }) + .to_string(), + ) + } else { + served += 1; + let sse = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n\ + data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}}}\n\n\ + data: [DONE]\n\n" + .to_string(); + ("HTTP/1.1 200 OK", sse) + }; + let (status, payload) = response; + write!( + stream, + "{status}\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ) + .unwrap(); + stream.write_all(payload.as_bytes()).unwrap(); + } + }); + (Self { requests }, base_url) + } + + fn recorded_requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + } + + /// Drive the sidecar through the real ChatGPT OAuth retry path against a + /// loopback fixture: the first request targets Luna, gets the + /// model-unavailable 404, and the replacement runs against the fallback + /// model. Asserts BOTH HTTP requests carry the expected reasoning effort: + /// the configured `agents.memory_effort` must survive the retry (greptile: + /// the retry used to hardcode `low`, re-enabling thinking under `none`). + fn assert_retry_preserves_effort( + configured_effort: Option<&str>, + expected_first: &str, + expected_retry: &str, + ) { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("create temp jcode home"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + let _openai = EnvVarGuard::unset("OPENAI_API_KEY"); + + let (fixture, base_url) = LunaUnavailableFixture::spawn(); + let _base = EnvVarGuard::set_path( + "JCODE_SIDECAR_TEST_API_BASE", + std::path::Path::new(&base_url), + ); + + // ChatGPT OAuth credentials: a non-empty refresh token selects + // chatgpt mode (streaming SSE), matching greptile's repro scenario. + codex::upsert_account_from_tokens( + "openai-chatgpt-1", + "sidecar-test-access", + "sidecar-test-refresh", + None, + None, + ) + .expect("write ChatGPT test auth"); + codex::set_active_account_override(Some("openai-chatgpt-1".to_string())); + crate::provider::clear_all_model_unavailability_for_account(); + // Luna available: the first request targets Luna so the retry runs. + crate::provider::populate_account_models(vec![SIDECAR_OPENAI_MODEL.to_string()]); + + let sidecar = Sidecar::with_configured_model(None, configured_effort.map(str::to_string)); + assert_eq!(sidecar.backend_name(), "openai"); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let out = rt + .block_on(sidecar.complete("be brief", "say ok")) + .expect("sidecar completion should succeed via retry"); + assert_eq!(out, "ok"); + + let requests = fixture.recorded_requests(); + assert_eq!( + requests.len(), + 2, + "expected the Luna request plus its replacement, got: {requests:?}" + ); + let first = &requests[0]; + let retry = &requests[1]; + assert_eq!(first["model"], SIDECAR_OPENAI_MODEL); + assert_eq!(retry["model"], SIDECAR_OPENAI_OAUTH_FALLBACK_MODEL); + for (label, request, expected) in [ + ("first", first, expected_first), + ("retry", retry, expected_retry), + ] { + assert_eq!( + request["reasoning"]["effort"], + serde_json::json!(expected), + "{label} request must carry effort {expected:?}, got: {request}" + ); + } + + codex::set_active_account_override(None); + crate::provider::clear_all_model_unavailability_for_account(); + } + + #[test] + fn luna_unavailable_retry_preserves_configured_effort() { + // `agents.memory_effort = "low"`: the retry keeps low (which happens + // to match the fallback default, but must come from the config). + assert_retry_preserves_effort(Some("low"), "low", "low"); + // `agents.memory_effort = "medium"`: without the fix the retry sent + // "low" instead of the configured medium. + assert_retry_preserves_effort(Some("medium"), "medium", "medium"); + // `agents.memory_effort = "none"`: without the fix the retry sent + // "low", silently re-enabling reasoning the user disabled. + assert_retry_preserves_effort(Some("none"), "none", "none"); + } + + #[test] + fn luna_unavailable_retry_without_override_keeps_fallback_default() { + // No `agents.memory_effort`: the per-model defaults apply. Luna's + // `none` on the first request, the fallback's `low` on the retry. + assert_retry_preserves_effort(None, "none", "low"); + } + // ---- Provider-backed sidecar (works on ALL providers) ------------------- /// Minimal provider stub that echoes a fixed reply for `complete`, so the @@ -1545,6 +2297,102 @@ mod tests { ); } + /// A stub whose set_reasoning_effort records the request, so tests can + /// verify the sidecar pins `agents.memory_effort` on the fork. The fork + /// shares the recorder so the test observes what the sidecar's fork got. + #[derive(Default)] + struct EffortRecordingStub { + set_effort: std::sync::Arc>>, + rejected: std::sync::atomic::AtomicBool, + } + + #[async_trait::async_trait] + impl crate::provider::Provider for EffortRecordingStub { + async fn complete( + &self, + _messages: &[crate::message::Message], + _tools: &[crate::message::ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + let stream = futures::stream::once(async { + Ok(jcode_message_types::StreamEvent::TextDelta( + "ok".to_string(), + )) + }); + Ok(Box::pin(stream)) + } + + fn name(&self) -> &str { + "effort-stub" + } + + fn model(&self) -> String { + "effort-stub-model".to_string() + } + + fn fork(&self) -> std::sync::Arc { + std::sync::Arc::new(EffortRecordingStub { + set_effort: std::sync::Arc::clone(&self.set_effort), + rejected: std::sync::atomic::AtomicBool::new( + self.rejected.load(std::sync::atomic::Ordering::Relaxed), + ), + }) + } + + fn set_reasoning_effort(&self, effort: &str) -> Result<()> { + if self.rejected.load(std::sync::atomic::Ordering::Relaxed) { + anyhow::bail!("stub rejects efforts"); + } + *self.set_effort.lock().unwrap() = Some(effort.to_string()); + Ok(()) + } + } + + impl EffortRecordingStub { + fn recorded_effort(&self) -> Option { + self.set_effort.lock().unwrap().clone() + } + } + + #[test] + fn sidecar_pins_memory_effort_on_provider_fork() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("create temp jcode home"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + let _openai = EnvVarGuard::unset("OPENAI_API_KEY"); + + let stub = std::sync::Arc::new(EffortRecordingStub::default()); + crate::provider::set_active_provider(stub.clone()); + + let sidecar = Sidecar::with_configured_model(None, Some("low".to_string())); + assert_eq!(sidecar.backend_name(), "provider"); + assert_eq!( + stub.recorded_effort(), + Some("low".to_string()), + "the fork must receive the configured memory effort" + ); + } + + #[test] + fn sidecar_ignores_rejected_memory_effort_on_provider_fork() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("create temp jcode home"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + let _openai = EnvVarGuard::unset("OPENAI_API_KEY"); + + let stub = std::sync::Arc::new(EffortRecordingStub { + set_effort: std::sync::Arc::new(std::sync::Mutex::new(None)), + rejected: std::sync::atomic::AtomicBool::new(true), + }); + crate::provider::set_active_provider(stub.clone()); + + // Must construct without panicking; the rejection is a logged warning. + let sidecar = Sidecar::with_configured_model(None, Some("low".to_string())); + assert_eq!(sidecar.backend_name(), "provider"); + assert_eq!(stub.recorded_effort(), None); + } + /// With NO OpenAI/Claude credentials, the sidecar must select the live /// agent provider (the universal path) instead of failing. This is the core /// guarantee that memory features work on every provider, not just two. @@ -1561,7 +2409,7 @@ mod tests { reply: "[2,1]".to_string(), })); - let sidecar = Sidecar::with_configured_model(None); + let sidecar = Sidecar::with_configured_model(None, None); assert_eq!( sidecar.backend_name(), "provider", @@ -1589,7 +2437,7 @@ mod tests { name: "configured-profile", reply: "configured-profile-response".to_string(), })); - let sidecar = Sidecar::with_configured_model(None); + let sidecar = Sidecar::with_configured_model(None, None); // Model switches and catalog refreshes can replace the process-global // provider while a background memory request is queued. Dispatch must @@ -1640,7 +2488,7 @@ mod tests { name: provider, reply: "[1]".to_string(), })); - let sidecar = Sidecar::with_configured_model(None); + let sidecar = Sidecar::with_configured_model(None, None); assert_eq!( sidecar.backend_name(), "provider", diff --git a/crates/jcode-config-types/src/display.rs b/crates/jcode-config-types/src/display.rs index 829bd0f84e..2a1dc8d1fe 100644 --- a/crates/jcode-config-types/src/display.rs +++ b/crates/jcode-config-types/src/display.rs @@ -124,6 +124,12 @@ pub struct DisplayConfig { pub resume_project_scope: bool, /// Usage percentage wording: "left" (default) or "used". pub usage_display: String, + /// Show the consolidated single Overview panel in the transcript margins + /// instead of the separate per-section info widgets (default: true). + /// Set false for the classic layout: Todos, Swarm, Background, Compaction, + /// Runtime, Usage, KV cache, Git and Commits each dock on their own. + #[serde(default = "default_true")] + pub single_overview: bool, } impl Default for DisplayConfig { fn default() -> Self { @@ -163,6 +169,7 @@ impl Default for DisplayConfig { external_sessions: true, resume_project_scope: false, usage_display: "left".to_string(), + single_overview: true, } } } diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 415be522c1..574c4cd9db 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -599,6 +599,12 @@ pub struct AgentsConfig { pub memory_jev_threshold: f32, /// Optional model override for memory extraction only, never recall. pub memory_model: Option, + /// Optional reasoning effort override for the memory extraction sidecar + /// (`"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, ...). + /// Effective on every backend: OpenAI effort, Claude thinking budget or + /// `output_config` effort (model-dependent), or the live provider's effort + /// knob. Leave unset for the per-model default. + pub memory_effort: Option, /// Whether optional automatic memory extraction may use a text-generating /// sidecar. Recall always uses Jev and is independent of this setting. #[serde(default = "default_memory_sidecar_enabled")] @@ -699,6 +705,7 @@ impl Default for AgentsConfig { memory_jev_provider: default_memory_jev_provider(), memory_jev_threshold: default_memory_jev_threshold(), memory_model: None, + memory_effort: None, memory_sidecar_enabled: default_memory_sidecar_enabled(), memory_rerank_cadence: default_memory_rerank_cadence(), memory_rerank_votes: default_memory_rerank_votes(), diff --git a/crates/jcode-provider-copilot-runtime/src/copilot_tests.rs b/crates/jcode-provider-copilot-runtime/src/copilot_tests.rs index 070bd521e6..d375ed72e4 100644 --- a/crates/jcode-provider-copilot-runtime/src/copilot_tests.rs +++ b/crates/jcode-provider-copilot-runtime/src/copilot_tests.rs @@ -762,6 +762,23 @@ fn fork_preserves_reasoning_effort() { assert_eq!(forked.reasoning_effort().as_deref(), Some("xhigh")); } +#[test] +fn fork_effort_writes_do_not_reach_live_provider() { + // The memory sidecar pins `agents.memory_effort` on a fork. That write + // must stay inside the fork: sharing the effort lock with the live + // provider would change the effort of main-agent requests. + let provider = sonnet5_provider(); + Provider::set_reasoning_effort(&provider, "high").unwrap(); + let forked = Provider::fork(&provider); + Provider::set_reasoning_effort(forked.as_ref(), "low").unwrap(); + assert_eq!(forked.reasoning_effort().as_deref(), Some("low")); + assert_eq!( + provider.reasoning_effort().as_deref(), + Some("high"), + "fork effort writes must not change the live provider effort" + ); +} + #[test] fn catalog_reasoning_efforts_parse_from_models_payload() { let info: copilot_auth::CopilotModelInfo = serde_json::from_value(json!({ diff --git a/crates/jcode-provider-copilot-runtime/src/lib.rs b/crates/jcode-provider-copilot-runtime/src/lib.rs index e875ca73c3..6af549bed6 100644 --- a/crates/jcode-provider-copilot-runtime/src/lib.rs +++ b/crates/jcode-provider-copilot-runtime/src/lib.rs @@ -1150,7 +1150,15 @@ impl Provider for CopilotApiProvider { init_done: self.init_done.clone(), premium_mode: self.premium_mode.clone(), user_turn_count: self.user_turn_count.clone(), - reasoning_effort: self.reasoning_effort.clone(), + // Copy the stored effort into a fresh lock so the fork's knob + // (e.g. the memory sidecar pinning `agents.memory_effort`) never + // writes through to the live provider's setting. + reasoning_effort: Arc::new(RwLock::new( + self.reasoning_effort + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(), + )), model_efforts: self.model_efforts.clone(), created_at: self.created_at, }) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 67c943e57c..53771076b2 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -38,9 +38,8 @@ use std::cell::RefCell; use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; use std::sync::mpsc; +use std::sync::{Arc, atomic::AtomicBool}; use std::time::{Duration, Instant}; use tokio::sync::RwLock; @@ -67,6 +66,7 @@ mod commands_desktop; mod commands_dispatch; mod commands_improve; mod commands_overnight; +mod commands_overview; mod commands_plan; mod commands_remote; mod commands_review; diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 38df968c9f..4ce8e813d6 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -2879,7 +2879,7 @@ fn parse_alignment_value(raw: &str) -> Option { } } -fn parse_on_off_value(raw: &str) -> Option { +pub(super) fn parse_on_off_value(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "on" | "compact" | "true" | "1" | "yes" | "enable" | "enabled" => Some(true), "off" | "full" | "false" | "0" | "no" | "disable" | "disabled" => Some(false), @@ -3586,6 +3586,10 @@ pub(super) fn handle_config_command(app: &mut App, trimmed: &str) -> bool { return true; } + if super::commands_overview::handle_overview_command(app, trimmed) { + return true; + } + if handle_tool_call_details_command(app, trimmed) { return true; } diff --git a/crates/jcode-tui/src/tui/app/commands_overview.rs b/crates/jcode-tui/src/tui/app/commands_overview.rs new file mode 100644 index 0000000000..ce002fc10a --- /dev/null +++ b/crates/jcode-tui/src/tui/app/commands_overview.rs @@ -0,0 +1,87 @@ +//! `/overview`: toggle the consolidated single Overview panel against the +//! classic separate widgets. +//! +//! Every form persists to config.toml, and the confirmation reports the +//! effective live layout: when `JCODE_SINGLE_OVERVIEW` pins the session to +//! the opposite of the saved choice, the message says so instead of +//! claiming the choice applied immediately (a saved choice with no visible +//! effect is worse than a clear notice). + +use super::commands::parse_on_off_value; +use super::{App, DisplayMessage}; + +pub(super) fn handle_overview_command(app: &mut App, trimmed: &str) -> bool { + if trimmed != "/overview" && !trimmed.starts_with("/overview ") { + return false; + } + + // The guard above ensures the prefix is present; let-else keeps the + // strip total without a swallowed-error default. + let Some(rest) = trimmed.strip_prefix("/overview") else { + return false; + }; + let rest = rest.trim(); + + let enabled = match rest { + "" => !crate::config::config().display.single_overview, + "show" | "status" => { + let current = crate::config::config().display.single_overview; + app.push_display_message(DisplayMessage::system(format!( + "Info panel is currently {}.\n\nOn: one consolidated Overview panel in the transcript margins.\nOff: separate widgets (Todos, Swarm, Background, Compaction, Runtime, Usage, KV cache, Git, Commits) each dock on their own.\n\nUse /overview, /overview on, or /overview off to change it.", + if current { "single overview (on)" } else { "separate widgets (off)" } + ))); + return true; + } + _ => match parse_on_off_value(rest) { + Some(value) => value, + None => { + app.push_display_message(DisplayMessage::error( + "Usage: /overview (toggle), /overview status, /overview on, or /overview off", + )); + return true; + } + }, + }; + + app.set_status_notice(if enabled { + "Info panel: single overview" + } else { + "Info panel: separate widgets" + }); + match crate::config::Config::set_single_overview(enabled) { + Ok(()) => { + // The env override (JCODE_SINGLE_OVERVIEW) can pin the live + // layout to the opposite of the saved choice, so report the + // effective setting instead of claiming the choice applied. + let effective = crate::config::config().display.single_overview; + app.push_display_message(DisplayMessage::system(format!( + "Saved {}: {}. {}", + if enabled { + "single overview" + } else { + "separate widgets" + }, + if enabled { "on" } else { "off" }, + if effective == enabled { + "Applied to this session immediately.".to_string() + } else { + format!( + "This session keeps the layout from JCODE_SINGLE_OVERVIEW ({}); the saved default applies to fresh sessions.", + if effective { "overview on" } else { "overview off" } + ) + }, + ))) + } + Err(error) => app.push_display_message(DisplayMessage::error(format!( + "Applied {} for this session, but failed to save it as the default: {}", + if enabled { + "single overview" + } else { + "separate widgets" + }, + error + ))), + } + + true +} diff --git a/crates/jcode-tui/src/tui/app/input_help.rs b/crates/jcode-tui/src/tui/app/input_help.rs index 270bb6af83..be65cc9cd8 100644 --- a/crates/jcode-tui/src/tui/app/input_help.rs +++ b/crates/jcode-tui/src/tui/app/input_help.rs @@ -198,6 +198,9 @@ impl App { "show-agentgrep-output" => { "/show-agentgrep-output\nShow whether full agentgrep search output renders inline in the transcript.\n\n/show-agentgrep-output on\nRender the full agentgrep search results inline beneath each agentgrep call instead of just the one-line summary.\n\n/show-agentgrep-output off\nShow only the compact one-line agentgrep summary." } + "overview" => { + "/overview\nToggle between the consolidated single Overview panel and the classic separate info widgets, and save the choice to config.toml.\n\n/overview status\nShow the current style.\n\n/overview on\nOne consolidated Overview panel in the transcript margins.\n\n/overview off\nSeparate widgets (Todos, Swarm, Background, Compaction, Runtime, Usage, KV cache, Git, Commits) each dock on their own.\n\nThe same choice can be set in config.toml as display.single_overview, or overridden with JCODE_SINGLE_OVERVIEW=0/1." + } "tool-call-details" => { "/tool-call-details\nShow whether the dimmed technical detail (command, path, args) renders next to the model-provided intent on tool rows.\n\n/tool-call-details on\nShow the technical detail after the intent, e.g. `bash · Run tests · $ cargo test`.\n\n/tool-call-details off\nShow only the intent on tool rows that have one. Rows without an intent still show the technical detail, and error summaries always render." } diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 67900c6576..5cd827635e 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -115,6 +115,10 @@ const REGISTERED_COMMANDS: &[RegisteredCommand] = &[ "/show-agentgrep-output", "Show/toggle full agentgrep search output inline in chat", ), + RegisteredCommand::public( + "/overview", + "Toggle the consolidated info panel vs separate widgets", + ), RegisteredCommand::public( "/tool-call-details", "Show/toggle dimmed technical details on tool rows with an intent", @@ -1183,6 +1187,26 @@ impl App { ); } + if prefix.starts_with("/overview ") { + return self.rank_suggestions( + input, + vec![ + ( + "/overview status".into(), + "Show the current info panel style", + ), + ( + "/overview on".into(), + "One consolidated Overview panel in the margins", + ), + ( + "/overview off".into(), + "Separate widgets each dock on their own", + ), + ], + ); + } + if prefix.starts_with("/config ") { return self.rank_suggestions( input, diff --git a/crates/jcode-tui/src/tui/app/tests/cache_prompt_accounting.rs b/crates/jcode-tui/src/tui/app/tests/cache_prompt_accounting.rs index 45e5bb9f7d..8460090ec8 100644 --- a/crates/jcode-tui/src/tui/app/tests/cache_prompt_accounting.rs +++ b/crates/jcode-tui/src/tui/app/tests/cache_prompt_accounting.rs @@ -277,3 +277,58 @@ fn cache_report_exposes_actual_expiry_notification_policy() { "{stats}" ); } + +#[test] +fn replay_runtime_suppresses_local_agent_model_override_rows() { + // Replay playback shows the recording's own model; today's local + // `agents.swarm_model`/`agents.memory_model` overrides belong to a live + // session and would mislead if rendered against a recording. Both remote + // and replay suppress the rows, so drive the config through a temp home + // and assert each runtime mode in isolation. + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + crate::config::invalidate_config_cache(); + let config_path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(config_path.parent().expect("config parent")) + .expect("create config parent"); + std::fs::write( + &config_path, + "[agents]\nswarm_model = \"glm-5.2\"\nswarm_effort = \"low\"\nmemory_model = \"gemini-3.8-flash\"\nmemory_effort = \"none\"\n", + ) + .expect("write config"); + crate::config::invalidate_config_cache(); + + let overrides_visible = |app: &App| { + let data = crate::tui::TuiState::info_widget_data(app); + ( + data.swarm_model_override.is_some(), + data.swarm_model_effort.is_some(), + data.memory_model_override.is_some(), + data.memory_model_effort.is_some(), + ) + }; + + // Local runtime: rows render from config. + let app = create_test_app(); + assert_eq!(overrides_visible(&app), (true, true, true, true)); + + // Replay runtime: the recording is the truth; overrides stay hidden. + let mut replay = create_test_app(); + replay.runtime_mode = AppRuntimeMode::Replay; + assert_eq!(overrides_visible(&replay), (false, false, false, false)); + + // Remote client: server owns the settings (pre-existing behavior). + let mut remote = create_test_app(); + remote.is_remote = true; + remote.runtime_mode = AppRuntimeMode::RemoteClient; + assert_eq!(overrides_visible(&remote), (false, false, false, false)); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + crate::config::invalidate_config_cache(); +} 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 13c85eef95..5ab8ee3e37 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 @@ -398,6 +398,19 @@ fn test_remote_catalog_activity_notification_upserts_compact_row() { #[test] fn test_model_picker_copilot_models_have_copilot_route() { + // An ambient named provider profile (a session started with a corporate + // OpenAI-compatible gateway) makes the picker include the built-in + // openai-compatible static catalogs, so firmware claims grok-code-fast-1 + // and the expected copilot route never appears. The test asserts pure + // copilot routing, so clear the profile lock for its duration. + let _env_guard = EnvRestoreGuard::capture([ + "JCODE_NAMED_PROVIDER_PROFILE", + "JCODE_PROVIDER_PROFILE_ACTIVE", + "JCODE_PROVIDER_PROFILE_NAME", + ]); + crate::env::remove_var("JCODE_NAMED_PROVIDER_PROFILE"); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_ACTIVE"); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_NAME"); let mut app = create_test_app(); configure_test_remote_models_with_copilot(&mut app); diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index b1165593c5..60b9e85f7a 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1,5 +1,17 @@ #[test] fn test_model_picker_copilot_selection_prefixes_model() { + // Same ambient-profile hazard as + // test_model_picker_copilot_models_have_copilot_route: the firmware + // static catalog claims grok-code-fast-1 under an active named profile, + // so selection would produce a firmware: prefix instead of copilot:. + let _env_guard = EnvRestoreGuard::capture([ + "JCODE_NAMED_PROVIDER_PROFILE", + "JCODE_PROVIDER_PROFILE_ACTIVE", + "JCODE_PROVIDER_PROFILE_NAME", + ]); + crate::env::remove_var("JCODE_NAMED_PROVIDER_PROFILE"); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_ACTIVE"); + crate::env::remove_var("JCODE_PROVIDER_PROFILE_NAME"); let mut app = create_test_app(); configure_test_remote_models_with_copilot(&mut app); diff --git a/crates/jcode-tui/src/tui/app/tests/slash_command_boundaries.rs b/crates/jcode-tui/src/tui/app/tests/slash_command_boundaries.rs index 27a3167a06..8666c87a87 100644 --- a/crates/jcode-tui/src/tui/app/tests/slash_command_boundaries.rs +++ b/crates/jcode-tui/src/tui/app/tests/slash_command_boundaries.rs @@ -155,3 +155,127 @@ fn parser_handles_unicode_and_fenced_code_boundaries() { }] ); } + +/// `/overview` must persist the choice to config.toml, not just flip the +/// session: the whole point of the command (vs the Alt+I widget toggle) is +/// that the style survives the next launch. +#[test] +fn overview_command_persists_choice_to_config() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + crate::config::Config::invalidate_cache(); + + let mut app = create_test_app(); + + assert!(super::commands_dispatch::dispatch_local_command( + &mut app, "/overview off" + )); + let last = app.display_messages().last().expect("overview message"); + assert!( + last.content.contains("Saved separate widgets"), + "expected save confirmation, got: {last:?}" + ); + assert!( + !crate::config::config().display.single_overview, + "live config must flip immediately" + ); + + // Persisted on disk, so a cold reload (fresh process) sees the same value. + let reloaded = crate::config::Config::load(); + assert!( + !reloaded.display.single_overview, + "config.toml on disk must carry single_overview = false" + ); + + assert!(super::commands_dispatch::dispatch_local_command( + &mut app, "/overview on" + )); + assert!(crate::config::config().display.single_overview); + assert!(crate::config::Config::load().display.single_overview); + + // No-argument form flips the current value and persists it too. + assert!(super::commands_dispatch::dispatch_local_command( + &mut app, "/overview" + )); + assert!( + !crate::config::config().display.single_overview, + "bare /overview must toggle to the opposite and persist" + ); + assert!(!crate::config::Config::load().display.single_overview); + + // Status form reports without changing anything. + let before = crate::config::config().display.single_overview; + assert!(super::commands_dispatch::dispatch_local_command( + &mut app, "/overview status" + )); + let last = app.display_messages().last().expect("status message"); + assert!(last.content.contains("separate widgets (off)")); + assert_eq!( + crate::config::config().display.single_overview, + before, + "status must not change the value" + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + crate::config::Config::invalidate_cache(); +} + +/// With `JCODE_SINGLE_OVERVIEW` pinning the live layout, the save +/// confirmation must say the session keeps the pinned layout instead of +/// claiming the choice applied immediately. +#[test] +fn overview_command_reports_pinned_layout_when_env_overrides() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + let prev_overview = std::env::var_os("JCODE_SINGLE_OVERVIEW"); + crate::env::set_var("JCODE_SINGLE_OVERVIEW", "1"); + crate::config::Config::invalidate_cache(); + + let mut app = create_test_app(); + + assert!(super::commands_dispatch::dispatch_local_command( + &mut app, "/overview off" + )); + let last = app.display_messages().last().expect("overview message"); + assert!( + last.content.contains("Saved separate widgets"), + "expected save confirmation, got: {last:?}" + ); + assert!( + last.content.contains("JCODE_SINGLE_OVERVIEW"), + "the confirmation must disclose the pinned layout, got: {last:?}" + ); + assert!( + !last.content.contains("Applied to this session immediately"), + "must not claim the choice applied while the env pins the layout, got: {last:?}" + ); + // The choice is still persisted for future sessions: read the file + // directly, since Config::load() applies the env override we just set. + let saved = std::fs::read_to_string(temp_home.path().join("config.toml")) + .expect("config.toml written"); + assert!( + saved.contains("single_overview = false"), + "config.toml on disk must carry single_overview = false, got: {saved}" + ); + // And the live session still shows the pinned layout. + assert!(crate::config::config().display.single_overview); + + crate::env::remove_var("JCODE_SINGLE_OVERVIEW"); + if let Some(prev_overview) = prev_overview { + crate::env::set_var("JCODE_SINGLE_OVERVIEW", prev_overview); + } + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + crate::config::Config::invalidate_cache(); +} diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 3c917fa47b..d54795b5fc 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -1657,12 +1657,30 @@ impl crate::tui::TuiState for App { todos, todo_goals, todos_are_swarm_plan, + overview_disabled: !crate::config::config().display.single_overview, context_info, context_info_stale: !context_snapshot.fresh, queue_mode: Some(self.queue_mode), context_limit: Some(self.context_limit as usize), model, reasoning_effort, + // Agent model overrides (agents.swarm_model / agents.memory_model). + // None means inherit, which renders no row. In remote sessions + // the server owns these settings, and in replay the recording's + // model is the truth: today's local overrides would mislead in + // both cases, so keep the rows unset instead of guessing. + swarm_model_override: (!uses_remote_widget_metadata) + .then(|| crate::config::config().agents.swarm_model.clone()) + .flatten(), + swarm_model_effort: (!uses_remote_widget_metadata) + .then(|| crate::config::config().agents.swarm_effort.clone()) + .flatten(), + memory_model_override: (!uses_remote_widget_metadata) + .then(|| crate::config::config().agents.memory_model.clone()) + .flatten(), + memory_model_effort: (!uses_remote_widget_metadata) + .then(|| crate::config::config().agents.memory_effort.clone()) + .flatten(), service_tier, native_compaction_mode, native_compaction_threshold_tokens, diff --git a/crates/jcode-tui/src/tui/info_widget.rs b/crates/jcode-tui/src/tui/info_widget.rs index e5ecd858c1..16b3d990d8 100644 --- a/crates/jcode-tui/src/tui/info_widget.rs +++ b/crates/jcode-tui/src/tui/info_widget.rs @@ -32,7 +32,9 @@ mod tips; mod todos_render; #[path = "info_widget_usage.rs"] mod usage_render; -use super::info_widget_overview::{InfoPageKind, MAX_TODO_LINES, compute_page_layout}; +use super::info_widget_overview::{ + InfoPageKind, MAX_TODO_LINES, compute_page_layout, section_visibility, +}; use super::workspace_map::VisibleWorkspaceRow; use crate::ambient::AmbientStatus; pub use crate::memory_types::{ @@ -54,7 +56,8 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthStr; -use commits::{commits_has_data, render_commits_widget}; +pub(crate) use commits::{OVERVIEW_COMMITS_MAX_ROWS, commits_has_data}; +use commits::{render_commits_compact, render_commits_widget}; use frame::Framed; use git::{ changes_has_data, changes_height, changes_legend, render_changes_framed, render_git_widget, @@ -64,6 +67,8 @@ pub use graph::{GraphEdge, GraphNode, build_graph_topology, graph_node_score}; pub(crate) use memory_utils::is_traceworthy_memory_event; use memory_utils::{memory_active_summary, memory_last_trace_summary, memory_state_detail}; use model::{render_model_info, render_model_widget, runtime_has_data, runtime_height}; +pub(crate) use swarm_background::OVERVIEW_SWARM_MAX_ROWS; +use swarm_background::render_swarm_compact; use swarm_background::{render_background_compact, render_background_widget, render_swarm_widget}; use text::{truncate_smart, truncate_with_ellipsis}; pub(crate) use tips::occasional_status_tip; @@ -241,6 +246,9 @@ impl Side { } pub(crate) fn is_overview_mergeable(kind: WidgetKind) -> bool { + // MemoryActivity is deliberately NOT here: the overview strips + // `memory_info` (the dedicated MemoryActivity widget owns that content), + // so marking it mergeable would suppress the only renderer of it. matches!( kind, WidgetKind::Todos @@ -251,6 +259,7 @@ pub(crate) fn is_overview_mergeable(kind: WidgetKind) -> bool { | WidgetKind::UsageLimits | WidgetKind::KvCache | WidgetKind::GitStatus + | WidgetKind::Commits ) } @@ -661,6 +670,12 @@ const PAGE_SWITCH_SECONDS: u64 = 30; #[derive(Debug, Default, Clone)] pub struct InfoWidgetData { pub todos: Vec, + /// True when `display.single_overview = false`: the Overview never places + /// and each mergeable section docks on its own (classic layout). Inverted + /// so the derived default (false) keeps the Overview for test fixtures + /// built with `..Default::default()`. Snapshotted per frame, not read + /// live in `has_data_for`, so placement stays pure in the data. + pub overview_disabled: bool, /// Goal-level assessments (closed feedback loop and objective) /// keyed by todo group (`group: None` covers the ungrouped list). Empty /// when the session has no recorded goals or `todos` is a swarm-plan @@ -678,6 +693,13 @@ pub struct InfoWidgetData { pub context_limit: Option, pub model: Option, pub reasoning_effort: Option, + /// Agent-specific model overrides from config: `agents.swarm_model` and + /// `agents.memory_model` (plus their efforts). `None` means "inherit the + /// coordinator/default model", which is not shown. + pub swarm_model_override: Option, + pub swarm_model_effort: Option, + pub memory_model_override: Option, + pub memory_model_effort: Option, pub service_tier: Option, pub native_compaction_mode: Option, pub native_compaction_threshold_tokens: Option, @@ -766,6 +788,11 @@ impl InfoWidgetData { WidgetKind::Diagrams => !self.diagrams.is_empty(), WidgetKind::WorkspaceMap => !self.workspace_rows.is_empty(), WidgetKind::Overview => { + // Opt-out (`display.single_overview = false`): the Overview + // never places, so every mergeable widget docks on its own. + if self.overview_disabled { + return false; + } let mut sections = 0usize; // Status-line facts (model, context %, branch) never count: // the overview only joins detail sections. @@ -775,6 +802,14 @@ impl InfoWidgetData { if !self.todos.is_empty() { sections += 1; } + if self + .memory_info + .as_ref() + .map(MemoryInfo::should_render) + .unwrap_or(false) + { + sections += 1; + } if self .background_info .as_ref() @@ -808,8 +843,25 @@ impl InfoWidgetData { { sections += 1; } - // Only useful as a "join" mode when there are multiple sections. - sections >= 2 + if self + .git_info + .as_ref() + .map(commits_has_data) + .unwrap_or(false) + { + sections += 1; + } + if self + .swarm_info + .as_ref() + .map(|s| !s.managed_members.is_empty()) + .unwrap_or(false) + { + sections += 1; + } + // The overview is a single always-visible panel: any section + // is enough, no multi-section join threshold. + sections >= 1 } WidgetKind::Todos => !self.todos.is_empty(), WidgetKind::MemoryActivity => self @@ -965,6 +1017,10 @@ struct WidgetsState { /// of popping back for a few frames (which resizes the bottom chrome and /// bounces the transcript). swarm_dock_last_engaged: Option, + /// Whether the last placement pass placed an Overview dock whose swarm + /// section has data (the Overview folds the swarm rows in, so the inline + /// strip stands down while it shows). + overview_carries_swarm: bool, /// `scroll_top` of the previous placement pass and when it last changed. /// A scroll moves content under resident widgets just like streaming does, /// so it counts as churn for a short grace period. @@ -982,6 +1038,7 @@ impl Default for WidgetsState { settlement: super::info_widget_settle::SettlementTracker::default(), anchors_area_width: 0, swarm_dock_last_engaged: None, + overview_carries_swarm: false, last_scroll_top: None, last_scroll_change: None, } @@ -1094,6 +1151,33 @@ pub fn calculate_placements( ); state.anchors = outcome.anchors; state.placements = outcome.visible.clone(); + // Recompute `overview_carries_swarm` before refreshing the linger timer: + // the timer must observe this frame's engagement, not the previous one. + // A retained Overview anchor counts too: while a wide transcript line + // covers the anchored Overview for longer than the linger window, only + // the anchor (not a visible placement) still proves the dock owns the + // swarm rows, and the strip must not pop back mid-cover (greptile: the + // strip returned and bounced the transcript after the 2s linger). + // The Overview only "carries" swarm (or any folded-in section) when its + // rect can actually show that section: a pocket that fits only the start + // of the compact page truncates the tail sections, and suppressing the + // fallbacks then hides the agents everywhere (greptile: agent status + // disappeared in a 10-row pocket whose rows Runtime/Todos/Background/ + // Usage already filled). + let swarm_data_present = data + .swarm_info + .as_ref() + .map(|s| !s.managed_members.is_empty()) + .unwrap_or(false); + let overview_shows_swarm = |p: &WidgetPlacement| { + p.kind == WidgetKind::Overview && section_visibility(data, p.rect).swarm + }; + state.overview_carries_swarm = swarm_data_present + && (state.placements.iter().any(overview_shows_swarm) + || state + .anchors + .iter() + .any(|a| overview_shows_swarm(&a.placement))); if swarm_dock_engaged(state) { state.swarm_dock_last_engaged = Some(Instant::now()); } @@ -1115,9 +1199,12 @@ const SCROLL_CHURN_GRACE: Duration = Duration::from_millis(1500); /// strip's return by this much, once. const SWARM_STRIP_STAND_DOWN_LINGER: Duration = Duration::from_millis(2000); -/// Whether the SwarmStatus dock widget is engaged: either actually placed, or -/// hidden-in-place behind a live anchor (a wide transcript line is momentarily -/// covering its slot and it will pop back into the same spot). +/// Whether the swarm dock is engaged: either the SwarmStatus dock widget is +/// actually placed, or hidden-in-place behind a live anchor (a wide transcript +/// line is momentarily covering its slot and it will pop back into the same +/// spot). Also engaged when the Overview dock is placed and its swarm section +/// has data: the Overview folds the swarm rows in, so the inline strip would +/// only duplicate it. fn swarm_dock_engaged(state: &WidgetsState) -> bool { state.enabled && (state @@ -1127,7 +1214,8 @@ fn swarm_dock_engaged(state: &WidgetsState) -> bool { || state .anchors .iter() - .any(|a| a.placement.kind == WidgetKind::SwarmStatus)) + .any(|a| a.placement.kind == WidgetKind::SwarmStatus) + || state.overview_carries_swarm) } /// Whether the inline swarm strip (above the status line) should stand down @@ -1165,6 +1253,7 @@ pub(crate) fn note_widget_pass_skipped() { state.placements.clear(); state.anchors.clear(); state.swarm_dock_last_engaged = None; + state.overview_carries_swarm = false; } } @@ -1179,6 +1268,7 @@ pub(crate) fn clear_widget_placements_for_tests() { state.placements.clear(); state.anchors.clear(); state.swarm_dock_last_engaged = None; + state.overview_carries_swarm = false; } } @@ -2087,6 +2177,10 @@ fn render_sections( ) -> Vec> { let mut lines: Vec> = Vec::new(); + // Section order matches master's Overview so both stay visually aligned: + // Runtime → Todos → Memory → Background → Usage → KV cache → Compaction + // → Changes → Commits → Swarm. + // Detail layer only: model identity, context %, and branch/counts live on // the overscroll status line and are never repeated here. lines.extend(render_model_info(data, inner)); @@ -2101,7 +2195,7 @@ fn render_sections( // Memory info if let Some(info) = &data.memory_info - && (info.total_count > 0 || info.activity.is_some()) + && info.should_render() { if matches!(focus, Some(InfoPageKind::MemoryExpanded)) { lines.extend(render_memory_expanded(info, inner)); @@ -2132,11 +2226,54 @@ fn render_sections( lines.push(render_kv_cache_summary_line(cache)); } + // Compaction: one line, same detail the standalone widget carries on its + // body (the mode rides the standalone border, so it goes inline here). + if let Some(info) = data.compaction_info.as_ref() { + let summary_tokens = (info.summary_chars / crate::compaction::CHARS_PER_TOKEN) + .max(usize::from(info.summary_chars > 0)); + let detail = format!( + "Compaction {} · {} old · {} active · ~{} summary tok · {}", + if info.is_compacting { + "compacting" + } else { + "compacted" + }, + info.compacted_messages, + info.active_messages, + summary_tokens, + info.mode + ); + let color = if info.is_compacting { + rgb(255, 220, 140) + } else { + rgb(110, 210, 140) + }; + lines.push(Line::from(vec![ + Span::styled("🗜 ", Style::default().fg(color)), + Span::styled( + truncate_smart(&detail, inner.width.saturating_sub(2) as usize), + Style::default().fg(rgb(180, 180, 190)), + ), + ])); + } + // Changed files (the detail behind the line's git counts). if data.git_info.as_ref().is_some_and(changes_has_data) { lines.extend(render_git_widget(data, inner)); } + // Recent commits on the current branch. + if let Some(git) = data.git_info.as_ref() { + lines.extend(render_commits_compact(git, inner.width as usize)); + } + + // Managed swarm agents. + if let Some(swarm) = data.swarm_info.as_ref() + && !swarm.managed_members.is_empty() + { + lines.extend(render_swarm_compact(swarm, inner.width as usize)); + } + lines } diff --git a/crates/jcode-tui/src/tui/info_widget_commits.rs b/crates/jcode-tui/src/tui/info_widget_commits.rs index 817d209be8..5ac1daaf3a 100644 --- a/crates/jcode-tui/src/tui/info_widget_commits.rs +++ b/crates/jcode-tui/src/tui/info_widget_commits.rs @@ -33,10 +33,60 @@ fn now_secs() -> i64 { .unwrap_or(0) } -pub(super) fn commits_has_data(info: &GitInfo) -> bool { +pub(crate) fn commits_has_data(info: &GitInfo) -> bool { commits_has_data_at(info, now_secs()) } +/// Rows the overview Commits section lists before collapsing the rest into an +/// inline `+N more` row. +pub(crate) const OVERVIEW_COMMITS_MAX_ROWS: usize = 3; + +/// Overview Commits section: a header line (`Commits ↑N unpushed`) plus up to +/// [`OVERVIEW_COMMITS_MAX_ROWS`] recent commits and an overflow row. Mirrors +/// [`super::info_widget_overview::compact_commits_height`]. +pub(super) fn render_commits_compact(info: &GitInfo, width: usize) -> Vec> { + let now = now_secs(); + if !commits_has_data_at(info, now) { + return Vec::new(); + } + + let mut lines = Vec::new(); + + let unpushed = info + .recent_commits + .iter() + .filter(|c| c.unpushed) + .count() + .max(info.ahead); + let mut header = vec![Span::styled( + "Commits", + Style::default().fg(rgb(180, 180, 190)).bold(), + )]; + if unpushed > 0 { + header.push(Span::raw(" ")); + header.push(Span::styled( + format!("↑{unpushed} unpushed"), + Style::default().fg(rgb(140, 180, 255)), + )); + } + lines.push(Line::from(header)); + + let rows = OVERVIEW_COMMITS_MAX_ROWS.min(info.recent_commits.len()); + for commit in &info.recent_commits[..rows] { + lines.push(commit_line(commit, width, now)); + } + + let hidden = info.recent_commits.len().saturating_sub(rows); + if hidden > 0 { + lines.push(Line::from(vec![Span::styled( + format!(" +{hidden} more"), + Style::default().fg(rgb(110, 110, 125)), + )])); + } + + lines +} + fn commits_has_data_at(info: &GitInfo, now: i64) -> bool { info.recent_commits.first().is_some_and(|newest| { newest.unpushed || now.saturating_sub(newest.timestamp) < COMMITS_FRESH_SECS diff --git a/crates/jcode-tui/src/tui/info_widget_layout.rs b/crates/jcode-tui/src/tui/info_widget_layout.rs index 32fa37472a..9b1e5a2fe6 100644 --- a/crates/jcode-tui/src/tui/info_widget_layout.rs +++ b/crates/jcode-tui/src/tui/info_widget_layout.rs @@ -2,7 +2,9 @@ use super::info_widget::{ InfoWidgetData, Side, WidgetKind, WidgetPlacement, calculate_widget_height, is_overview_mergeable, }; +use super::info_widget_overview::section_visibility; use ratatui::layout::Rect; +use std::cell::Cell; use std::collections::HashSet; /// Minimum width needed to show the widget. @@ -128,6 +130,59 @@ pub(crate) fn calculate_placements_anchored( data: &InfoWidgetData, enabled: bool, prev_anchors: &[WidgetAnchor], +) -> PlacementOutcome { + let outcome = + calculate_placements_anchored_inner(messages_area, margins, data, enabled, prev_anchors); + // Overview reclaim: if the Overview is available but could not place + // while an anchored *mergeable* widget holds a slot, that widget was + // anchored before the Overview had data and its content now folds into + // the Overview. Re-run once without those anchors so the higher-priority + // Overview can take the pocket. The retry is only adopted when the + // Overview actually places, so a frame that cannot host the Overview + // anyway keeps the original outcome (and its anchors) unchanged. + if data.available_widgets().contains(&WidgetKind::Overview) + && !outcome + .visible + .iter() + .any(|p| p.kind == WidgetKind::Overview) + { + let mergeable_only: Vec = prev_anchors + .iter() + .filter(|a| { + a.placement.kind != WidgetKind::Overview && is_overview_mergeable(a.placement.kind) + }) + .cloned() + .collect(); + if !mergeable_only.is_empty() { + let non_mergeable: Vec = prev_anchors + .iter() + .filter(|a| { + a.placement.kind == WidgetKind::Overview + || !is_overview_mergeable(a.placement.kind) + }) + .cloned() + .collect(); + let retry = calculate_placements_anchored_inner( + messages_area, + margins, + data, + enabled, + &non_mergeable, + ); + if retry.visible.iter().any(|p| p.kind == WidgetKind::Overview) { + return retry; + } + } + } + outcome +} + +fn calculate_placements_anchored_inner( + messages_area: Rect, + margins: &Margins, + data: &InfoWidgetData, + enabled: bool, + prev_anchors: &[WidgetAnchor], ) -> PlacementOutcome { if !enabled || messages_area.height == 0 || messages_area.width == 0 { return PlacementOutcome { @@ -289,6 +344,23 @@ pub(crate) fn calculate_placements_anchored( .min(messages_area.width); let renderable = fit_width >= MIN_WIDGET_WIDTH; + // Height recheck: an anchored Overview must be able to actually show + // its *current* content. The Overview folds sections in, so its + // required height grows when new data (commits, swarm members, ...) + // arrives after the anchor was recorded. Keeping the stale rectangle + // would draw nothing (`render_overview_framed` bails when no page + // fits the inner height), suppress its mergeable widgets, and hide + // the inline swarm strip - the content would surface nowhere. + // `calculate_widget_height` clamps to the anchor's own height, so an + // oversized need surfaces as 0 ("no page fits"); drop the anchor then + // and let Phase 2 either re-home the Overview at the required height + // or give the standalone widgets and the strip the content back. + if prev.kind == WidgetKind::Overview + && calculate_widget_height(WidgetKind::Overview, data, fit_width, prev.rect.height) == 0 + { + continue; + } + // Width is monotonic non-increasing for the life of an anchor: it shrinks to // clear newly-wide content but never grows back while pinned. Growing would // move a right-anchored widget's left edge inward every time the margin @@ -421,15 +493,56 @@ pub(crate) fn calculate_placements_anchored( // Phase 2: greedily place remaining widgets. // // `overview_active` already covers the case where Overview is shown OR only - // hidden-in-place; in both cases its mergeable widgets (model/context/...) are - // suppressed so they don't pop in at a *different* location while Overview is - // momentarily covered. `overview_placed` additionally covers a brand-new + // hidden-in-place; in both cases its mergeable widgets (model/context/...) + // are suppressed so they don't pop in at a *different* location while Overview + // is momentarily covered. `overview_placed` additionally covers a brand-new // Overview placed within this very Phase 2 pass. + // + // A shown Overview only owns the sections its rect can actually render. + // The compact page truncates at its inner height, so a small pocket whose + // rows the front sections fill truncates the tail (Changes/Commits/Swarm) + // away; the standalone fallbacks for those sections must stay placeable + // or their content surfaces nowhere (greptile: a 10-row pocket hid the + // Commits and SwarmStatus widgets while the Overview truncated both + // sections, losing worker status and the fresh unpushed commit). + // + // Hidden-in-place Overview (`overview_active` set by the Phase 1 branch) + // suppresses everything: it pops back into its slot, and a fallback + // planted elsewhere for a few frames is exactly the churn the anchors + // exist to prevent. Only a *shown* Overview gets section-checked. The + // rect lives in a Cell: Phase 1 seeds it from the kept placement, and + // Phase 2 refreshes it the moment a fresh Overview places, so even the + // very first frame after a re-home checks the actual placed rect instead + // of suppressing every mergeable widget for one blind frame. + let overview_rect = Cell::new( + placements + .iter() + .find(|p| p.kind == WidgetKind::Overview) + .map(|p| p.rect), + ); + let overview_shows_section = |kind: WidgetKind| -> bool { + let Some(rect) = overview_rect.get() else { + return true; + }; + let vis = section_visibility(data, rect); + match kind { + WidgetKind::ModelInfo => vis.model_info, + WidgetKind::Todos => vis.todos, + WidgetKind::BackgroundTasks => vis.background, + WidgetKind::UsageLimits => vis.usage, + WidgetKind::KvCache => vis.kv_cache, + WidgetKind::Compaction => vis.compaction, + WidgetKind::GitStatus => vis.changes, + WidgetKind::Commits => vis.commits, + WidgetKind::SwarmStatus => vis.swarm, + _ => true, + } + }; let mut overview_placed = overview_active; for kind in available { if kept.contains(&kind) || anchored.contains(&kind) - || (overview_placed && is_overview_mergeable(kind)) + || (overview_placed && is_overview_mergeable(kind) && overview_shows_section(kind)) { continue; } @@ -475,6 +588,10 @@ pub(crate) fn calculate_placements_anchored( rect: Rect::new(x, messages_area.y + placed_top, width, widget_height), side, }; + if kind == WidgetKind::Overview { + overview_placed = true; + overview_rect.set(Some(placement.rect)); + } placements.push(placement.clone()); next_anchors.push(WidgetAnchor { placement, @@ -483,9 +600,6 @@ pub(crate) fn calculate_placements_anchored( // row, so the moment the user keeps scrolling it rides with the content. content_top: margins.scroll_top + placed_top as usize, }); - if kind == WidgetKind::Overview { - overview_placed = true; - } let remaining_height = height.saturating_sub(widget_height); if remaining_height < MIN_WIDGET_HEIGHT { diff --git a/crates/jcode-tui/src/tui/info_widget_layout_tests.rs b/crates/jcode-tui/src/tui/info_widget_layout_tests.rs index 34cb185e86..5c71ee40a9 100644 --- a/crates/jcode-tui/src/tui/info_widget_layout_tests.rs +++ b/crates/jcode-tui/src/tui/info_widget_layout_tests.rs @@ -288,7 +288,7 @@ fn todo_and_background_contention_prioritizes_todos_without_overlap() { } /// (b) When Overview is shown, its mergeable widgets (todos, background, -/// swarm, ...) must not also place standalone. +/// swarm, commits, ...) must not also place standalone. #[test] fn overview_suppresses_mergeable_widgets_under_contention() { let data = contended_data(); @@ -525,3 +525,163 @@ fn covered_resident_rehomes_immediately_when_content_is_settled() { "and keeps its anchor to return to" ); } + +/// `display.single_overview = false` restores the classic layout: the +/// consolidated Overview never places and each mergeable section (todos, +/// background, ...) docks as its own widget. The default (true) keeps the +/// single consolidated panel. +#[test] +fn single_overview_false_places_separate_widgets_instead_of_overview() { + let area = Rect::new(0, 0, 80, 30); + let data = InfoWidgetData { + todos: vec![todo("t1", "in_progress"), todo("t2", "pending")], + background_info: Some(BackgroundInfo { + running_count: 1, + running_tasks: vec!["bash".to_string()], + ..Default::default() + }), + ..Default::default() + }; + let margins = margins_for(40, 30, false); + + // Default: the Overview consolidates todos and background. + let consolidated = calculate_placements_anchored(area, &margins, &data, true, &[]); + assert_placements_sane("consolidated", area, &consolidated.visible); + assert!( + consolidated + .visible + .iter() + .any(|p| p.kind == WidgetKind::Overview), + "default layout should place the consolidated Overview" + ); + assert!( + !consolidated + .visible + .iter() + .any(|p| p.kind == WidgetKind::Todos), + "with the Overview placed, the standalone Todos widget stays suppressed" + ); + + // Opt out: classic separate widgets, no Overview anywhere. + let separate_data = InfoWidgetData { + overview_disabled: true, + ..data.clone() + }; + let separate = calculate_placements_anchored(area, &margins, &separate_data, true, &[]); + assert_placements_sane("separate", area, &separate.visible); + assert!( + !separate + .visible + .iter() + .any(|p| p.kind == WidgetKind::Overview), + "single_overview=false must not place the consolidated Overview" + ); + assert!( + separate.visible.iter().any(|p| p.kind == WidgetKind::Todos), + "single_overview=false must let the standalone Todos widget place" + ); + assert!( + separate + .visible + .iter() + .any(|p| p.kind == WidgetKind::BackgroundTasks), + "single_overview=false must let the standalone Background widget place" + ); + + // An anchor recorded under the consolidated layout must not resurrect the + // Overview after the flip: the stale anchor dies because the Overview is + // no longer in `available`. + let flip = + calculate_placements_anchored(area, &margins, &separate_data, true, &consolidated.anchors); + assert_placements_sane("flip", area, &flip.visible); + assert!( + !flip.visible.iter().any(|p| p.kind == WidgetKind::Overview), + "a stale Overview anchor must not survive the single_overview flip" + ); +} + +/// Folded sections must not suppress their standalone widgets. +/// +/// The Overview folds sections that do not fit its rect, but the placement +/// pass used to suppress every mergeable widget whenever an Overview was +/// placed, regardless of whether the rect actually showed that section. The +/// result: a short Overview slot (borderline height after its content grew, +/// or a covered resident that came back at a smaller height) folded the +/// details AND kept their standalone widgets suppressed - the content was +/// visible nowhere. `overview_shows_section` now consults the same fits +/// flags the renderer folds by, so a folded section's standalone widget +/// places again. +#[test] +fn overview_folded_sections_do_not_suppress_standalone_widgets() { + // Runtime (1 row) + usage (label + primary + secondary = 3 rows) + + // changes (5 dirty files, no overflow) = 9 inner rows of content. + let data = InfoWidgetData { + model: Some("claude-test-model".to_string()), + provider_name: Some("anthropic".to_string()), + usage_info: Some(UsageInfo { + provider: UsageProvider::Anthropic, + primary_limit_label: Some("5-hour".to_string()), + secondary_limit_label: Some("Weekly".to_string()), + available: true, + ..Default::default() + }), + git_info: Some(GitInfo { + branch: "master".to_string(), + modified: 5, + dirty_files: vec![ + DirtyFile::new('M', "src/a.rs"), + DirtyFile::new('M', "src/b.rs"), + DirtyFile::new('M', "src/c.rs"), + DirtyFile::new('M', "src/d.rs"), + DirtyFile::new('M', "src/e.rs"), + ], + dirty_total: 5, + ..Default::default() + }), + ..Default::default() + }; + + // Both pockets are 10 rows (inner height 8): the Overview places on the + // right, capped at the pocket, so its 9-row content folds the tail + // (changes) away. Centered mode opens the left pocket for the standalone + // widgets (left margins only exist when centered). + let area = Rect::new(0, 0, 80, 10); + let margins = margins_for(36, 10, true); + let outcome = calculate_placements_anchored(area, &margins, &data, true, &[]); + assert_placements_sane("folded overview", area, &outcome.visible); + let overview = outcome + .visible + .iter() + .find(|p| p.kind == WidgetKind::Overview) + .expect("overview places with model + usage + changes"); + let vis = super::super::info_widget_overview::section_visibility(&data, overview.rect); + assert!( + vis.model_info && vis.usage, + "front sections must still fit: {:?} vis={vis:?}", + overview.rect + ); + assert!( + !vis.changes, + "changes section must fold in a 10-row slot: {:?} vis={vis:?}", + overview.rect + ); + + // Folded-out section: its standalone widget must place (left pocket) + // instead of staying suppressed and hiding the dirty-file list. + assert!( + outcome + .visible + .iter() + .any(|p| p.kind == WidgetKind::GitStatus), + "folded-out changes section must keep its standalone GitStatus widget: {:?}", + outcome.visible.iter().map(|p| p.kind).collect::>() + ); + // Sections the Overview does show stay suppressed exactly as before. + assert!( + !outcome + .visible + .iter() + .any(|p| p.kind == WidgetKind::UsageLimits), + "a section the Overview shows must keep its standalone widget suppressed" + ); +} diff --git a/crates/jcode-tui/src/tui/info_widget_model.rs b/crates/jcode-tui/src/tui/info_widget_model.rs index 05edad13d6..351dc58f8a 100644 --- a/crates/jcode-tui/src/tui/info_widget_model.rs +++ b/crates/jcode-tui/src/tui/info_widget_model.rs @@ -91,6 +91,69 @@ fn runtime_rows(data: &InfoWidgetData) -> Vec { let muted = rgb(140, 140, 150); let mut rows = Vec::new(); + // Selected (default) model + effort first: the primary identity of the + // session. The home icon marks the default model the session runs on; + // the status line shows a shortened form, the panel carries the full name. + if let Some(model) = non_empty(data.model.as_deref()) { + let mut text = model.to_string(); + if let Some(effort) = non_empty(data.reasoning_effort.as_deref()) { + text.push_str(&format!(" · {effort}")); + } + rows.push(RuntimeRow { + icon: "🏠", + icon_color: rgb(255, 135, 200), + text, + text_color: rgb(220, 220, 230), + }); + } + + // Agent model overrides from config. Only shown when actually set; the + // inherit default (None) is silent to keep the panel compact. Like the + // memory row below, the swarm row also appears for an effort-only + // setting (`agents.swarm_effort` without `agents.swarm_model`), so users + // can verify the worker effort in the panel. + if let Some(model) = non_empty(data.swarm_model_override.as_deref()) { + let mut text = model.to_string(); + if let Some(effort) = non_empty(data.swarm_model_effort.as_deref()) { + text.push_str(&format!(" · {effort}")); + } + rows.push(RuntimeRow { + icon: "🐝", + icon_color: rgb(255, 200, 100), + text, + text_color: muted, + }); + } else if let Some(effort) = non_empty(data.swarm_model_effort.as_deref()) { + rows.push(RuntimeRow { + icon: "🐝", + icon_color: rgb(255, 200, 100), + text: format!("· {effort}"), + text_color: muted, + }); + } + // The memory row also appears for an effort-only setting + // (`agents.memory_effort` without a model override), so users can + // verify the sidecar effort in the panel. + if let Some(model) = non_empty(data.memory_model_override.as_deref()) { + let mut text = model.to_string(); + if let Some(effort) = non_empty(data.memory_model_effort.as_deref()) { + text.push_str(&format!(" · {effort}")); + } + rows.push(RuntimeRow { + icon: "🧠", + icon_color: rgb(200, 150, 255), + text, + text_color: muted, + }); + } else if let Some(effort) = non_empty(data.memory_model_effort.as_deref()) { + rows.push(RuntimeRow { + icon: "🧠", + icon_color: rgb(200, 150, 255), + text: format!("· {effort}"), + text_color: muted, + }); + } + let is_openai = data .provider_name .as_deref() @@ -201,17 +264,21 @@ mod tests { } #[test] - fn runtime_widget_never_repeats_status_line_identity() { + fn runtime_widget_shows_model_and_effort_plus_runtime_detail() { let mut d = data(); d.connection_type = Some("websocket".to_string()); d.tokens_per_second = Some(61.7); let out = text(render_model_widget(&d, Rect::new(0, 0, 30, 8)).all_lines()); - for owned in ["GPT", "codex", "high", "(hi)", "openai", "OAuth", "jcode"] { + // The panel leads with the selected model + effort (user-requested), + // then the runtime detail. Identity facts that only the status line + // owns (provider name, auth, dir) stay out. + for owned in ["openai", "OAuth", "jcode"] { assert!( !out.contains(owned), "{owned:?} belongs to the status line: {out}" ); } + assert!(out.contains("gpt-5-codex · high"), "{out}"); assert!(out.contains("fast tier"), "{out}"); assert!(out.contains("websocket"), "{out}"); assert!(out.contains("62 tok/s"), "{out}"); @@ -227,16 +294,21 @@ mod tests { for tier in [None, Some("off"), Some("default")] { let mut d = data(); d.service_tier = tier.map(str::to_string); - assert!(!runtime_has_data(&d), "tier {tier:?}"); + // The selected model row always renders, so the widget has data; + // the tier row must not. + assert!(runtime_has_data(&d), "tier {tier:?}"); + let out = text(render_model_widget(&d, Rect::new(0, 0, 30, 8)).all_lines()); + assert!(!out.contains("tier"), "tier {tier:?}: {out}"); } } #[test] - fn identity_only_session_has_no_runtime_widget() { + fn identity_only_session_renders_just_the_model_row() { let mut d = data(); d.service_tier = None; - assert!(!runtime_has_data(&d)); - assert_eq!(runtime_height(&d), 0); + // With no runtime detail, the selected model + effort row still + // renders: the panel always shows what model the session runs on. + assert_eq!(runtime_height(&d), 1); } #[test] @@ -247,6 +319,56 @@ mod tests { d.session_count = Some(3); let framed = render_model_widget(&d, Rect::new(0, 0, 30, 8)); assert_eq!(framed.lines.len() as u16, runtime_height(&d)); - assert!(text(framed.all_lines()).contains("sauropod · 3 sessions")); + assert!(text(framed.all_lines()).contains("via fireworks")); + } + + #[test] + fn swarm_and_memory_override_rows_render_when_set() { + let mut d = data(); + d.service_tier = None; + d.swarm_model_override = Some("glm-5.2".to_string()); + d.swarm_model_effort = Some("low".to_string()); + d.memory_model_override = Some("gemini-3.8-flash".to_string()); + d.memory_model_effort = Some("none".to_string()); + let out = text(render_model_widget(&d, Rect::new(0, 0, 40, 8)).all_lines()); + assert!(out.contains("🐝 glm-5.2 · low"), "{out}"); + assert!(out.contains("🧠 gemini-3.8-flash · none"), "{out}"); + // Selected model row plus the two override rows. + assert_eq!(runtime_height(&d), 3); + } + + #[test] + fn memory_effort_only_still_renders_memory_row() { + // `agents.memory_effort` without a model override must still show the + // memory row, so an effort-only setting is verifiable in the panel. + let mut d = data(); + d.service_tier = None; + d.memory_model_effort = Some("low".to_string()); + let out = text(render_model_widget(&d, Rect::new(0, 0, 40, 8)).all_lines()); + assert!(out.contains("🧠 · low"), "{out}"); + assert_eq!(runtime_height(&d), 2); + } + + #[test] + fn swarm_effort_only_still_renders_swarm_row() { + // `agents.swarm_effort` without `agents.swarm_model` must still show + // the swarm row, so an effort-only setting is verifiable in the panel + // (mirrors the memory row's effort-only branch). + let mut d = data(); + d.service_tier = None; + d.swarm_model_effort = Some("low".to_string()); + let out = text(render_model_widget(&d, Rect::new(0, 0, 40, 8)).all_lines()); + assert!(out.contains("🐝 · low"), "{out}"); + // The Overview section renders the same rows via render_model_info. + let overview = text(render_model_info(&d, Rect::new(0, 0, 40, 8))); + assert!(overview.contains("🐝 · low"), "{overview}"); + assert_eq!(runtime_height(&d), 2); + } + + #[test] + fn inherit_overrides_render_no_row() { + let mut d = data(); + d.service_tier = None; + assert_eq!(runtime_height(&d), 1); } } diff --git a/crates/jcode-tui/src/tui/info_widget_overview.rs b/crates/jcode-tui/src/tui/info_widget_overview.rs index a75f1f8863..488082e093 100644 --- a/crates/jcode-tui/src/tui/info_widget_overview.rs +++ b/crates/jcode-tui/src/tui/info_widget_overview.rs @@ -1,7 +1,8 @@ use super::info_widget::{ - InfoWidgetData, UsageProvider, changes_section_height, is_traceworthy_memory_event, - model_info_height, + InfoWidgetData, OVERVIEW_COMMITS_MAX_ROWS, OVERVIEW_SWARM_MAX_ROWS, UsageProvider, + changes_section_height, commits_has_data, is_traceworthy_memory_event, model_info_height, }; +use ratatui::layout::Rect; pub(crate) const MAX_TODO_LINES: usize = 12; @@ -25,6 +26,132 @@ pub(crate) struct PageLayout { pub show_dots: bool, } +/// Which tail sections of the compact Overview page a placed rect can actually +/// show. The compact page renders sections in a fixed order (Runtime → Todos +/// → Memory → Background → Usage → KV cache → Compaction → Changes → Commits +/// → Swarm) and `render_overview_framed` truncates whatever the inner height +/// cannot fit. A section that lands entirely in the truncated tail is *not* +/// shown by that Overview, so its standalone fallback must stay available +/// (greptile: a 10-row pocket whose rows Runtime/Todos/Background/Usage filled +/// suppressed the Commits and SwarmStatus fallbacks while the placed Overview +/// truncated both sections away, hiding worker status everywhere). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct SectionVisibility { + /// The Runtime (model info) section fits fully in the rect. + pub model_info: bool, + /// The Todos section fits fully in the rect. + pub todos: bool, + /// The Background section fits fully in the rect. + pub background: bool, + /// The Usage section fits fully in the rect. + pub usage: bool, + /// The KV cache section fits fully in the rect. + pub kv_cache: bool, + /// The Compaction section fits fully in the rect. + pub compaction: bool, + /// The Changes (dirty files) section fits fully in the rect. + pub changes: bool, + /// The Commits section fits fully in the rect. + pub commits: bool, + /// The Swarm section fits fully in the rect. + pub swarm: bool, +} + +/// Which sections a placed Overview rect (outer rect, borders included) can +/// show. Sections that would only be partially visible count as not shown: +/// the render truncates at inner height, and a half-drawn section header is +/// worse than the standalone fallback that shows the content in full. +/// The rect "shows" a section only when every page it can rotate through +/// shows it: the strip and fallback suppression span the whole widget, not +/// one page, so a section truncated on any page must keep its fallback. +pub(crate) fn section_visibility(data: &InfoWidgetData, rect: Rect) -> SectionVisibility { + // Mirror render_overview_framed: memory is the dedicated widget's content, + // so the Overview strips it before rendering (and the MemoryExpanded + // page therefore never exists here). + let mut overview = data.clone(); + overview.memory_info = None; + let inner_height = rect.height.saturating_sub(2); + let model_h = model_info_height(&overview); + let todos_h = compact_todos_height(&overview); + let background_h = compact_background_height(&overview); + let usage_h = compact_usage_height(&overview); + let kv_h = compact_kv_cache_height(&overview); + let compaction_h = compact_compaction_height(&overview); + let changes_h = changes_section_height(&overview); + let commits_h = compact_commits_height(&overview); + let swarm_h = compact_swarm_height(&overview); + let fits = |front: u16, section: u16| section > 0 && front + section <= inner_height; + // Sections render front-to-back in a fixed order; a section is shown only + // when it and everything before it fits, exactly like the truncating render. + // Returns (total front height, per-section fits flags). + let front_sections = |expanded_todos: bool| -> (u16, [bool; 6]) { + let todos = if expanded_todos { + expanded_todos_height(&overview) + } else { + todos_h + }; + let sections = [ + (model_h, model_h > 0), + (todos, todos > 0), + (background_h, background_h > 0), + (usage_h, usage_h > 0), + (kv_h, kv_h > 0), + (compaction_h, compaction_h > 0), + ]; + let mut front = 0u16; + let mut flags = [false; 6]; + for (idx, (height, present)) in sections.iter().enumerate() { + flags[idx] = *present + && front + .checked_add(*height) + .is_some_and(|end| end <= inner_height); + front = front.saturating_add(*height); + } + (front, flags) + }; + let per_page = |expanded_todos: bool| { + let (front, [model_info, todos, background, usage, kv_cache, compaction]) = + front_sections(expanded_todos); + SectionVisibility { + model_info, + todos, + background, + usage, + kv_cache, + compaction, + changes: fits(front, changes_h), + commits: fits(front + changes_h, commits_h), + swarm: fits(front + changes_h + commits_h, swarm_h), + } + }; + let compact = per_page(false); + let layout = compute_page_layout(&overview, 0, inner_height); + if layout.pages.is_empty() { + // The slot is shorter than even the compact page: the renderer falls + // back to the truncated compact page (see render_overview_framed), + // so only that page's tail visibility matters. + return compact; + } + let mut combined = compact; + if layout + .pages + .iter() + .any(|page| page.kind == InfoPageKind::TodosExpanded) + { + let expanded = per_page(true); + combined.model_info &= expanded.model_info; + combined.todos &= expanded.todos; + combined.background &= expanded.background; + combined.usage &= expanded.usage; + combined.kv_cache &= expanded.kv_cache; + combined.compaction &= expanded.compaction; + combined.changes &= expanded.changes; + combined.commits &= expanded.commits; + combined.swarm &= expanded.swarm; + } + combined +} + pub(crate) fn compute_page_layout( data: &InfoWidgetData, _inner_width: usize, @@ -144,6 +271,47 @@ fn compact_kv_cache_height(data: &InfoWidgetData) -> u16 { if data.cache_hit_info.is_some() { 1 } else { 0 } } +/// Overview rows used by the Compaction section, mirroring [`render_sections`]. +fn compact_compaction_height(data: &InfoWidgetData) -> u16 { + u16::from(data.compaction_info.is_some()) +} + +/// Overview rows used by the Commits section, mirroring [`render_sections`]. +/// Must mirror `render_commits_compact` exactly: header + shown commits + +/// overflow row. +fn compact_commits_height(data: &InfoWidgetData) -> u16 { + let Some(info) = data.git_info.as_ref() else { + return 0; + }; + if !commits_has_data(info) || info.recent_commits.is_empty() { + return 0; + } + let rows = OVERVIEW_COMMITS_MAX_ROWS.min(info.recent_commits.len()); + let overflow = u16::from(info.recent_commits.len() > rows); + 1 + rows as u16 + overflow +} + +/// Overview rows used by the Swarm section, mirroring [`render_sections`]. +/// Must mirror `render_swarm_compact` exactly: header + shown agents + +/// overflow row + plan meter. +fn compact_swarm_height(data: &InfoWidgetData) -> u16 { + let Some(info) = data.swarm_info.as_ref() else { + return 0; + }; + let members = info.managed_members.len(); + if members == 0 { + return 0; + } + let rows = members.min(OVERVIEW_SWARM_MAX_ROWS); + let overflow = u16::from(members > rows); + let plan = u16::from( + info.plan_progress + .map(|(_, _, total)| total > 0) + .unwrap_or(false), + ); + 1 + rows as u16 + overflow + plan +} + /// Overview height. Status-line facts (model identity, context %, branch and /// counts) are not sections here, only the detail behind them. fn compact_overview_height(data: &InfoWidgetData) -> u16 { @@ -153,7 +321,10 @@ fn compact_overview_height(data: &InfoWidgetData) -> u16 { + compact_background_height(data) + compact_usage_height(data) + compact_kv_cache_height(data) + + compact_compaction_height(data) + changes_section_height(data) + + compact_commits_height(data) + + compact_swarm_height(data) } fn expanded_todos_height(data: &InfoWidgetData) -> u16 { diff --git a/crates/jcode-tui/src/tui/info_widget_swarm_background.rs b/crates/jcode-tui/src/tui/info_widget_swarm_background.rs index 8be24438c2..76ee5cc6a1 100644 --- a/crates/jcode-tui/src/tui/info_widget_swarm_background.rs +++ b/crates/jcode-tui/src/tui/info_widget_swarm_background.rs @@ -595,3 +595,135 @@ fn plan_meter(done: u32, running: u32, total: u32) -> Line<'static> { ), ]) } + +/// Overview Swarm section: a compact projection of the swarm dock. One row +/// per agent (attention first), a header line carrying the same +/// `N/M active` headline, and a trailing plan-meter line when a plan is +/// active. Mirrors [`compact_swarm_height`]. +pub(super) fn render_swarm_compact(info: &SwarmInfo, width: usize) -> Vec> { + use jcode_tui_render::swarm_gallery::{status_accent, status_glyph}; + let members = &info.managed_members; + if members.is_empty() { + return Vec::new(); + } + + let mut order: Vec<&SwarmMemberStatus> = members.iter().collect(); + order.sort_by(|a, b| { + dock_status_rank(&a.status) + .cmp(&dock_status_rank(&b.status)) + .then_with(|| { + let coord = |m: &SwarmMemberStatus| m.role.as_deref() != Some("coordinator"); + coord(a).cmp(&coord(b)) + }) + .then_with(|| a.session_id.cmp(&b.session_id)) + }); + + let active = members + .iter() + .filter(|m| jcode_tui_render::swarm_gallery::is_active_status(&m.status)) + .count(); + let attention = members + .iter() + .filter(|m| dock_is_attention(&m.status)) + .count(); + + let shown = &order[..order.len().min(OVERVIEW_SWARM_MAX_ROWS)]; + + let mut lines = Vec::with_capacity(shown.len() + 2); + + // Header: `Swarm N/M active ⚠K`, same headline the dock border carries. + let mut header = vec![ + Span::styled("🐝 ", Style::default().fg(rgb(255, 200, 100))), + Span::styled( + format!("Swarm {}/{} active", active, members.len()), + Style::default().fg(rgb(180, 180, 190)).bold(), + ), + ]; + if attention > 0 { + header.push(Span::styled( + format!(" ⚠ {attention}"), + Style::default().fg(rgb(255, 170, 80)).bold(), + )); + } + lines.push(Line::from(header)); + + for member in shown { + let accent = status_accent(&member.status); + let glyph = status_glyph(&member.status, info.spinner_frame); + let name = swarm_member_label(member); + let star = if member.role.as_deref() == Some("coordinator") { + "★ " + } else { + "" + }; + let detail = dock_activity(member); + let right = match member.todo_progress { + Some((done, total)) if total > 0 && !dock_is_finished(&member.status) => { + format!("{done}/{total}") + } + _ => member + .status_age_secs + .and_then(|s| { + let age = jcode_tui_render::swarm_gallery::humanize_age(s); + (age != "now").then_some(age) + }) + .unwrap_or_default(), + }; + let fixed = 2 + + UnicodeWidthStr::width(star) + + UnicodeWidthStr::width(name.as_str()) + + 2 + + if right.is_empty() { + 0 + } else { + UnicodeWidthStr::width(right.as_str()) + 1 + }; + let activity_w = width.saturating_sub(fixed); + let activity = if activity_w >= 4 { + truncate_smart(&detail, activity_w) + } else { + String::new() + }; + let mut spans = vec![ + Span::styled(format!("{glyph} "), Style::default().fg(accent)), + Span::styled(star.to_string(), Style::default().fg(rgb(255, 200, 100))), + Span::styled(name, Style::default().fg(rgb(210, 210, 220))), + Span::raw(" "), + ]; + let activity_len = UnicodeWidthStr::width(activity.as_str()); + spans.push(Span::styled( + activity, + Style::default().fg(rgb(160, 160, 170)), + )); + if !right.is_empty() && activity_w >= 4 { + let pad = activity_w.saturating_sub(activity_len) + 1; + spans.push(Span::raw(" ".repeat(pad))); + spans.push(Span::styled(right, Style::default().fg(rgb(120, 120, 130)))); + } + let line = Line::from(spans); + lines.push(if line.width() <= width { + line + } else { + frame::fit(&line, width).unwrap_or_default() + }); + } + + let hidden = order.len().saturating_sub(OVERVIEW_SWARM_MAX_ROWS); + if hidden > 0 { + lines.push(Line::from(vec![Span::styled( + format!(" +{hidden} more"), + Style::default().fg(rgb(110, 110, 125)), + )])); + } + + if let Some((done, running, total)) = info.plan_progress + && total > 0 + { + lines.push(plan_meter(done, running, total)); + } + + lines +} + +/// Agents the overview Swarm section lists before collapsing the rest. +pub(crate) const OVERVIEW_SWARM_MAX_ROWS: usize = 3; diff --git a/crates/jcode-tui/src/tui/info_widget_tests.rs b/crates/jcode-tui/src/tui/info_widget_tests.rs index b4f6d211cb..5c3d5d9519 100644 --- a/crates/jcode-tui/src/tui/info_widget_tests.rs +++ b/crates/jcode-tui/src/tui/info_widget_tests.rs @@ -1012,19 +1012,27 @@ fn contextual_subgraph_prefers_memory_hub() { #[test] fn overview_requires_multiple_sections() { - // Status-line facts (model identity) are never an overview section. + // The Overview is a single always-visible panel: any section is enough, + // no multi-section join threshold. The selected model + effort row (🏠) + // is a runtime section on its own. let identity_only = InfoWidgetData { model: Some("gpt-test".to_string()), queue_mode: Some(true), ..Default::default() }; - assert!(!identity_only.has_data_for(WidgetKind::Overview)); + assert!(identity_only.has_data_for(WidgetKind::Overview)); + // A lone non-status-line section still places the overview. let one_section = InfoWidgetData { - session_name: Some("sauropod".to_string()), + queue_mode: Some(true), ..Default::default() }; - assert!(!one_section.has_data_for(WidgetKind::Overview)); + assert!(one_section.has_data_for(WidgetKind::Overview)); + + // Truly empty data has nothing to render: the overview stays out of the + // layout instead of placing an empty frame. + let no_sections = InfoWidgetData::default(); + assert!(!no_sections.has_data_for(WidgetKind::Overview)); let two_sections = InfoWidgetData { session_name: Some("sauropod".to_string()), @@ -1032,6 +1040,81 @@ fn overview_requires_multiple_sections() { ..Default::default() }; assert!(two_sections.has_data_for(WidgetKind::Overview)); + + // A disabled memory feature is not an overview section: `disabled` means + // no recall/extraction activity, so there is no detail to join. + let disabled_memory = InfoWidgetData { + memory_info: Some(MemoryInfo { + disabled: true, + ..Default::default() + }), + ..Default::default() + }; + assert!(!disabled_memory.has_data_for(WidgetKind::Overview)); + assert!(!disabled_memory.has_data_for(WidgetKind::MemoryActivity)); + + // End-to-end through the real public render path: place the overview + // dock with calculate_placements and render_all into a TestBackend, + // then assert at the cell level what the user would actually see. + // A disabled memory feature must not paint a 🧠 row; an enabled one + // must. + { + let _lock = crate::tui::ui::render_state_test_lock(); + // Stateless placement (`calculate_placements_anchored`) instead of the + // shared-state `calculate_placements`: parallel tests mutate the + // global widget state, which would blank this draw non-deterministically. + let area = Rect::new(0, 0, 38, 24); + let margins = super::Margins { + right_widths: vec![38; 24], + ..Default::default() + }; + + let draw_overview = |data: &InfoWidgetData| -> String { + let outcome = super::super::info_widget_layout::calculate_placements_anchored( + area, + &margins, + data, + true, + &[], + ); + let backend = ratatui::backend::TestBackend::new(38, 24); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| { + super::render_all(frame, &outcome.visible, data); + }) + .expect("overview draw should not panic"); + let buf = terminal.backend().buffer(); + (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") + }; + + let mut disabled = disabled_memory.clone(); + disabled.queue_mode = Some(true); + let rendered = draw_overview(&disabled); + assert!( + !rendered.contains('🧠'), + "disabled memory must not render a 🧠 row in the overview, got: {rendered:?}" + ); + + let mut enabled = disabled_memory.clone(); + enabled.queue_mode = Some(true); + enabled.memory_info = Some(MemoryInfo { + total_count: 4, + ..Default::default() + }); + let rendered = draw_overview(&enabled); + assert!( + rendered.contains('🧠'), + "enabled memory must render its 🧠 row in the overview, got: {rendered:?}" + ); + } } #[test] @@ -2232,3 +2315,489 @@ fn narrow_overview_background_row_keeps_progress_visible() { "progress must fit at overview width:\n{out}" ); } + +#[test] +fn overview_never_hides_memory_data() { + // The overview strips `memory_info` (the dedicated MemoryActivity widget + // owns that content, matching master). Regression: MemoryActivity must NOT + // be overview-mergeable, or placing the overview would suppress the only + // renderer of memory data. + let _lock = crate::tui::ui::render_state_test_lock(); + let area = Rect::new(0, 0, 38, 24); + let margins = super::Margins { + right_widths: vec![38; 24], + ..Default::default() + }; + let data = super::InfoWidgetData { + queue_mode: Some(true), + memory_info: Some(MemoryInfo { + total_count: 4, + ..Default::default() + }), + // A second section so the overview has content beyond memory and places. + usage_info: Some(super::UsageInfo { + provider: super::UsageProvider::CostBased, + available: true, + ..Default::default() + }), + ..Default::default() + }; + assert!(!super::is_overview_mergeable(super::WidgetKind::Overview)); + assert!(!super::is_overview_mergeable( + super::WidgetKind::MemoryActivity + )); + + let outcome = super::super::info_widget_layout::calculate_placements_anchored( + area, + &margins, + &data, + true, + &[], + ); + assert!( + outcome + .visible + .iter() + .any(|p| p.kind == super::WidgetKind::Overview), + "overview should place (queue_mode + usage sections): {:?}", + outcome.visible.iter().map(|p| p.kind).collect::>() + ); + assert!( + outcome + .visible + .iter() + .any(|p| p.kind == super::WidgetKind::MemoryActivity), + "memory activity must still place alongside the overview" + ); + + let backend = ratatui::backend::TestBackend::new(38, 24); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| { + super::render_all(frame, &outcome.visible, &data); + }) + .expect("draw should not panic"); + let buf = terminal.backend().buffer(); + let rendered = (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + rendered.contains('🧠'), + "memory row must be visible somewhere while the overview is placed, got:\n{rendered}" + ); +} + +#[test] +fn mergeable_anchor_yields_when_overview_arrives() { + // `calculate_placements` mutates process-wide widget state (anchors, + // placements, swarm stand-down). Hold the shared render-state lock for the + // whole test so concurrent render tests (e.g. + // `swarm_strip_stands_down_through_dock_blinks`) cannot interleave their + // own placement calls between these frames. + let _lock = crate::tui::ui::render_state_test_lock(); + use super::{GitInfo, RecentCommit, WidgetKind, calculate_placements}; + crate::tui::info_widget::clear_widget_placements_for_tests(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let data = super::InfoWidgetData { + git_info: Some(GitInfo { + branch: "fix/info_fields".into(), + ahead: 2, + recent_commits: [ + ( + "e4b4d3a", + "tui: overview folds swarm and commits", + 120, + true, + ), + ("fdad464", "tui: compact swarm rows", 300, true), + ] + .into_iter() + .map(|(hash, subject, ago, unpushed)| RecentCommit { + hash: hash.into(), + subject: subject.into(), + timestamp: now - ago, + unpushed, + added: None, + removed: None, + }) + .collect(), + ..Default::default() + }), + ..Default::default() + }; + let area = ratatui::layout::Rect::new(0, 0, 120, 30); + let kinds = |p: &[super::WidgetPlacement]| p.iter().map(|p| p.kind).collect::>(); + + // Frame A: only a shallow pocket exists. Overview (min 8 rows) cannot + // fit, but a lone Commits dock (min 1 + chrome) can, so Commits anchors. + let shallow = super::Margins { + right_widths: vec![44, 44, 44, 44, 44, 44, 44, 0, 0, 0, 0, 0, 0], + scroll_top: 10, + ..Default::default() + }; + let mut pa = Vec::new(); + for _ in 0..6 { + pa = calculate_placements(area, &shallow, &data); + } + assert_eq!( + kinds(&pa), + vec![WidgetKind::Commits], + "frame A: only the shallow commits dock fits" + ); + + // Frame B: a deep pocket opens (terminal scrolled / wide lines gone) and + // usage data arrives. The Overview must take over the deep pocket and + // the mergeable Commits dock must fold in. + let mut data2 = data.clone(); + data2.usage_info = Some(super::UsageInfo { + provider: super::UsageProvider::CostBased, + available: true, + ..Default::default() + }); + let deep = super::Margins { + right_widths: vec![44; 30], + scroll_top: 10, + ..Default::default() + }; + let pb = calculate_placements(area, &deep, &data2); + assert_eq!( + kinds(&pb), + vec![WidgetKind::Overview], + "frame B: overview must reclaim, commits fold in, got: {:?}", + kinds(&pb) + ); +} + +/// Regression (PR review): an anchored Overview must not silently swallow new +/// sections it can no longer fit. +/// +/// Frame A anchors a model-only Overview in a small pocket. Frame B adds +/// commits and swarm members (sections that grow `compact_overview_height` +/// beyond the anchor's height while the terminal geometry stays the same). +/// The anchor must not hold its stale three-row rectangle: `render_overview_framed` +/// would draw nothing (the compact page no longer fits the inner height), the +/// mergeable standalone widgets stay suppressed, and `overview_carries_swarm` +/// also stands the inline swarm strip down - the user loses the panel, recent +/// commits, and managed-agent status, persisting on subsequent frames. +/// +/// Expected behavior: either the anchor reflows to the new required height, or +/// it yields so the standalone widgets and the inline strip can carry the +/// content. +#[test] +fn anchored_overview_outgrown_by_new_activity_yields_or_reflows() { + let _lock = crate::tui::ui::render_state_test_lock(); + use super::{ + GitInfo, RecentCommit, WidgetKind, calculate_placements, calculate_widget_height, + swarm_strip_stands_down_for_dock, + }; + crate::tui::info_widget::clear_widget_placements_for_tests(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let commit = |hash: &str, subject: &str, ago: i64| RecentCommit { + hash: hash.into(), + subject: subject.into(), + timestamp: now - ago, + unpushed: true, + added: None, + removed: None, + }; + let member = |id: &str| crate::protocol::SwarmMemberStatus { + session_id: id.to_string(), + friendly_name: Some(id.to_string()), + status: "running".to_string(), + detail: None, + task_label: None, + role: None, + is_headless: Some(true), + live_attachments: None, + status_age_secs: Some(2), + output_tail: None, + report_back_to_session_id: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }; + + // Frame A: model-only Overview (one runtime row + borders = 3 rows). + let data = super::InfoWidgetData { + model: Some("gpt-test".to_string()), + ..Default::default() + }; + let area = ratatui::layout::Rect::new(0, 0, 120, 30); + let margins = super::Margins { + right_widths: vec![44; 30], + scroll_top: 10, + ..Default::default() + }; + let mut pa = Vec::new(); + for _ in 0..3 { + pa = calculate_placements(area, &margins, &data); + } + let overview_a = pa + .iter() + .find(|p| p.kind == WidgetKind::Overview) + .expect("frame A: model-only overview should anchor in the wide margin"); + assert_eq!( + overview_a.rect.height, 3, + "frame A: model-only overview is a 3-row panel" + ); + + // Frame B: commits and managed swarm members arrive. The same geometry + // keeps a 30-row pocket, but the compact overview now needs more rows. + let mut data2 = data.clone(); + data2.git_info = Some(GitInfo { + branch: "fix/info_fields".into(), + ahead: 1, + recent_commits: vec![commit( + "e4b4d3a", + "tui: overview folds swarm and commits", + 120, + )], + ..Default::default() + }); + data2.swarm_info = Some(super::SwarmInfo { + managed_members: vec![member("w1"), member("w2")], + ..Default::default() + }); + let required = calculate_widget_height(WidgetKind::Overview, &data2, overview_a.rect.width, 30); + assert!( + required > overview_a.rect.height, + "frame B: new sections must grow the required overview height" + ); + + let pb = calculate_placements(area, &margins, &data2); + if let Some(overview_b) = pb.iter().find(|p| p.kind == WidgetKind::Overview) { + // Path 1: the anchor reflowed to a height that can actually show the + // (at least compact) overview content. + let inner = overview_b.rect.height.saturating_sub(2); + let mut overview_data = data2.clone(); + overview_data.memory_info = None; + let compact = super::super::info_widget_overview::compute_page_layout( + &overview_data, + overview_b.rect.width.saturating_sub(2) as usize, + inner, + ); + assert!( + compact.max_page_height > 0 && compact.max_page_height <= inner, + "kept overview must fit a page: rect {:?} max_page_height {} inner {}", + overview_b.rect, + compact.max_page_height, + inner + ); + assert!( + swarm_strip_stands_down_for_dock(), + "a renderable overview carrying swarm rows keeps the strip down" + ); + } else { + // Path 2: no renderable Overview could be placed, so the mergeable + // standalone widgets must come back and the swarm strip must be free + // to render the managed agents (no phantom `overview_carries_swarm`). + assert!( + pb.iter().any(|p| p.kind == WidgetKind::Commits), + "frame B: without a placeable overview the commits dock must return, got {:?}", + pb.iter().map(|p| p.kind).collect::>() + ); + assert!( + pb.iter().any(|p| p.kind == WidgetKind::SwarmStatus), + "frame B: without a placeable overview the swarm dock must return" + ); + assert!( + !swarm_strip_stands_down_for_dock(), + "frame B: the strip must be free when no overview carries the swarm rows" + ); + } +} + +/// Regression (greptile P1, PR 1709): a placed Overview whose pocket fits +/// only the front sections of the compact page must not suppress the +/// standalone fallbacks for the tail sections it truncates away. +/// +/// A 10-row margin pocket hosts the Overview; Runtime + Todos + Background + +/// Usage rows fill its whole body, so the compact page truncates before the +/// Changes/Commits/Swarm sections. Before the fix, `overview_carries_swarm` +/// still stood the inline strip down and Phase 2 still suppressed the +/// SwarmStatus and Commits docks, so a running worker and a fresh unpushed +/// commit were visible nowhere (another pocket was available for them). +/// +/// Expected, phase 1: with a second pocket free, the Commits/SwarmStatus +/// fallbacks place into it and the (now real) SwarmStatus dock owns the strip. +/// Expected, phase 2: with no other pocket at all, no dock can place, so the +/// inline strip must come back up - the agents surface there. +#[test] +fn overview_truncated_tail_keeps_fallbacks_and_strip() { + let _lock = crate::tui::ui::render_state_test_lock(); + use super::{ + GitInfo, RecentCommit, WidgetKind, calculate_placements, swarm_strip_stands_down_for_dock, + }; + use crate::tui::info_widget_overview::section_visibility; + crate::tui::info_widget::clear_widget_placements_for_tests(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let member = |id: &str| crate::protocol::SwarmMemberStatus { + session_id: id.to_string(), + friendly_name: Some(id.to_string()), + status: "running".to_string(), + detail: None, + task_label: None, + role: None, + is_headless: Some(true), + live_attachments: None, + status_age_secs: Some(2), + output_tail: None, + report_back_to_session_id: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }; + let commit = |hash: &str, subject: &str, ago: i64| RecentCommit { + hash: hash.into(), + subject: subject.into(), + timestamp: now - ago, + unpushed: true, + added: None, + removed: None, + }; + + let todo = |id: &str, status: &str| crate::todo::TodoItem { + group: None, + content: format!("task {id}"), + status: status.to_string(), + priority: "high".to_string(), + id: id.to_string(), + blocked_by: Vec::new(), + assigned_to: None, + confidence: None, + completion_confidence: None, + confidence_history: Vec::new(), + }; + + // Front sections: runtime rows (model) + todos (2 rows) + background + // (1 header + 1 task row) + usage (label + primary). Together they leave + // 1 free row of the 10-row pocket's 8 body rows, so only the tail + // (Changes/Commits/Swarm) is truncated away and usage still fits. + let data = super::InfoWidgetData { + model: Some("gpt-test".to_string()), + todos: vec![todo("a", "in_progress"), todo("b", "completed")], + background_info: Some(super::BackgroundInfo { + running_count: 1, + running_tasks: vec!["bash".to_string()], + ..Default::default() + }), + usage_info: Some(super::UsageInfo { + provider: super::UsageProvider::Anthropic, + available: true, + primary_limit_label: Some("5h".to_string()), + ..Default::default() + }), + git_info: Some(GitInfo { + branch: "fix/info_fields".into(), + ahead: 1, + recent_commits: vec![commit( + "e4b4d3a", + "tui: overview folds swarm and commits", + 120, + )], + ..Default::default() + }), + swarm_info: Some(super::SwarmInfo { + managed_members: vec![member("w1"), member("w2")], + ..Default::default() + }), + ..Default::default() + }; + + // Right margin: a 10-row pocket (rows 0..10) and a second free pocket + // (rows 14..24) so the fallbacks have somewhere to place. + let area = ratatui::layout::Rect::new(0, 0, 120, 30); + let mut right_widths = vec![0u16; 30]; + for w in right_widths.iter_mut().take(10) { + *w = 44; + } + for w in right_widths.iter_mut().skip(14).take(10) { + *w = 44; + } + let margins = super::Margins { + right_widths, + scroll_top: 10, + ..Default::default() + }; + + let mut placements = Vec::new(); + for _ in 0..3 { + placements = calculate_placements(area, &margins, &data); + } + + let overview = placements + .iter() + .find(|p| p.kind == WidgetKind::Overview) + .expect("the overview should anchor in the 10-row pocket"); + let vis = section_visibility(&data, overview.rect); + assert!( + !vis.commits && !vis.swarm, + "pocket must truncate the tail sections before the fix's scenario, got {:?} for {:?}", + vis, + overview.rect + ); + + // Phase 1 - fallbacks released: with a second pocket free, Commits and + // SwarmStatus must place there even though the placed Overview truncates + // its tail sections. Before the fix they were suppressed and the agents + // were visible nowhere despite the free pocket. + assert!( + placements.iter().any(|p| p.kind == WidgetKind::Commits), + "the truncated overview must not suppress the Commits dock, got {:?}", + placements.iter().map(|p| p.kind).collect::>() + ); + assert!( + placements.iter().any(|p| p.kind == WidgetKind::SwarmStatus), + "the truncated overview must not suppress the SwarmStatus dock" + ); + // The strip legitimately stands down now: the SwarmStatus dock (not a + // phantom overview) owns the swarm rows. This is the fixed steady state. + assert!( + swarm_strip_stands_down_for_dock(), + "the placed SwarmStatus dock should own the strip instead of the truncated overview" + ); + + // Phase 2 - no room anywhere: keep only the single 10-row pocket so no + // dock can place, and clear the anchors so the truncated Overview cannot + // linger as one either. The agents must surface somewhere: the inline + // swarm strip must come back up. + crate::tui::info_widget::clear_widget_placements_for_tests(); + let mut right_widths = vec![0u16; 30]; + for w in right_widths.iter_mut().take(10) { + *w = 44; + } + let margins = super::Margins { + right_widths, + scroll_top: 10, + ..Default::default() + }; + let placements = calculate_placements(area, &margins, &data); + assert!( + placements.iter().any(|p| p.kind == WidgetKind::Overview), + "single-pocket frame: the truncated overview keeps its pocket, got {:?}", + placements.iter().map(|p| p.kind).collect::>() + ); + assert!( + !placements.iter().any(|p| p.kind == WidgetKind::SwarmStatus) + && !placements.iter().any(|p| p.kind == WidgetKind::Commits), + "single-pocket frame: no room for the docks, they must not phantom-place" + ); + assert!( + !swarm_strip_stands_down_for_dock(), + "no dock owns the swarm rows, so the strip must come back up" + ); +} diff --git a/crates/jcode-tui/src/tui/ui_overlays.rs b/crates/jcode-tui/src/tui/ui_overlays.rs index 15a650cbf0..e4aa40582f 100644 --- a/crates/jcode-tui/src/tui/ui_overlays.rs +++ b/crates/jcode-tui/src/tui/ui_overlays.rs @@ -302,6 +302,10 @@ pub(super) fn draw_help_overlay(frame: &mut Frame, area: Rect, scroll: usize, ap "/show-agentgrep-output [status|on|off]", "Render full agentgrep search output inline in chat", )); + lines.push(help_entry( + "/overview [status|on|off]", + "Toggle the consolidated info panel vs separate widgets", + )); lines.push(help_entry("/config", "Show active configuration")); lines.push(help_entry("/config init", "Create default config file")); lines.push(help_entry("/config edit", "Open config in $EDITOR")); diff --git a/crates/jcode-tui/src/tui/ui_tests/swarm_buffer.rs b/crates/jcode-tui/src/tui/ui_tests/swarm_buffer.rs index 74a0c5aaf8..3f5048fbe6 100644 --- a/crates/jcode-tui/src/tui/ui_tests/swarm_buffer.rs +++ b/crates/jcode-tui/src/tui/ui_tests/swarm_buffer.rs @@ -329,15 +329,21 @@ fn swarm_strip_stands_down_through_dock_blinks() { "no dock engagement yet: strip should be free to show" ); - // Dock places: strip stands down. + // Dock places: strip stands down. The Overview dock folds the swarm + // rows in (SwarmStatus is a mergeable section of it), so the standalone + // SwarmStatus dock stays suppressed and Overview carries the strip's job. let placed = calculate_placements(messages_area, &wide_margins, &data); assert!( - placed.iter().any(|p| p.kind == WidgetKind::SwarmStatus), - "dock should place with a wide free margin" + placed.iter().any(|p| p.kind == WidgetKind::Overview), + "overview dock should place with a wide free margin" + ); + assert!( + !placed.iter().any(|p| p.kind == WidgetKind::SwarmStatus), + "standalone swarm dock stays merged into the overview" ); assert!( swarm_strip_stands_down_for_dock(), - "strip must stand down while the dock shows" + "strip must stand down while the overview dock shows the swarm rows" ); // Full-draw integration: with the dock engaged, ui::draw omits the strip @@ -382,7 +388,9 @@ fn swarm_strip_stands_down_through_dock_blinks() { calculate_placements(messages_area, &wide_margins, &data); let blink = calculate_placements(messages_area, &covered_margins, &data); assert!( - blink.iter().all(|p| p.kind != WidgetKind::SwarmStatus), + blink + .iter() + .all(|p| p.kind != WidgetKind::Overview && p.kind != WidgetKind::SwarmStatus), "covered margin must hide the dock this frame" ); assert!( @@ -406,12 +414,104 @@ fn swarm_strip_stands_down_through_dock_blinks() { !swarm_strip_stands_down_for_dock(), "strip should be free to return once the dock is genuinely gone" ); + + // An overview without swarm data must NOT stand the strip down: only the + // overview's swarm section carries the strip's job. + let mut no_swarm = data.clone(); + no_swarm + .swarm_info + .as_mut() + .expect("swarm info") + .managed_members + .clear(); + // Keep a non-swarm section (runtime) so the overview still has a + // reason to place; the point is that its swarm section is what gates + // the strip. + no_swarm.queue_mode = Some(true); + no_swarm.model = Some("gpt-test".to_string()); + let placed = calculate_placements(messages_area, &wide_margins, &no_swarm); + assert!( + placed.iter().any(|p| p.kind == WidgetKind::Overview), + "overview should still place without swarm data" + ); + assert!( + !swarm_strip_stands_down_for_dock(), + "swarm strip must stay up when the overview carries no swarm rows" + ); +} + +#[test] +fn swarm_strip_stands_down_while_anchored_overview_stays_covered() { + // Greptile P2 ("Swarm strip returns too early"): while a wide transcript + // line covers the anchored Overview for longer than the 2s linger, only + // the retained anchor still proves the overview owns the swarm rows. The + // strip must keep standing down through the whole cover, or it pops back + // mid-cover and bounces the transcript when the overview reappears. + let _lock = viewport_snapshot_test_lock(); + use crate::tui::info_widget::{ + WidgetKind, calculate_placements, swarm_strip_stands_down_for_dock, + }; + crate::tui::info_widget::clear_widget_placements_for_tests(); + + let coordinator = strip_member("s0", "researcher", "running"); + let data = crate::tui::info_widget::InfoWidgetData { + swarm_info: Some(crate::tui::info_widget::SwarmInfo { + managed_members: vec![coordinator, strip_member("s1", "reviewer", "running")], + ..Default::default() + }), + ..Default::default() + }; + let messages_area = Rect::new(0, 0, 120, 26); + let wide_margins = crate::tui::info_widget::Margins { + right_widths: vec![44; 26], + ..Default::default() + }; + let covered_margins = crate::tui::info_widget::Margins { + right_widths: vec![0; 26], + // Upstream (d9ec5f674) only retains a covered anchor while the + // transcript is still churning: settled content re-homes the widget + // right away. The greptile scenario was a wide streaming line parked + // over the anchored Overview, so the covered frames must model churn. + content_churning: true, + ..Default::default() + }; + + // Engage: overview places with swarm rows, strip stands down. + let placed = calculate_placements(messages_area, &wide_margins, &data); + assert!(placed.iter().any(|p| p.kind == WidgetKind::Overview)); + assert!(swarm_strip_stands_down_for_dock()); + + // Covered frames keep arriving far past the 2s linger window (the 32-frame + // loop in the blink test never actually waits out the timer; this one + // does). The anchor is still retained, so engagement must survive. + std::thread::sleep(std::time::Duration::from_millis(2200)); + for _ in 0..4 { + let covered = calculate_placements(messages_area, &covered_margins, &data); + assert!( + covered + .iter() + .all(|p| p.kind != WidgetKind::Overview && p.kind != WidgetKind::SwarmStatus), + "covered margin must hide the dock these frames" + ); + } + assert!( + swarm_strip_stands_down_for_dock(), + "a retained overview anchor keeps the strip down past the linger window" + ); + + // Sanity: when the cover genuinely tears the dock down (widget pass + // skipped), the strip is free again. + crate::tui::info_widget::note_widget_pass_skipped(); + assert!( + !swarm_strip_stands_down_for_dock(), + "strip should be free once the anchored overview is genuinely gone" + ); } -/// The swarm dock widget renders the compact summary at the cell level: +/// The overview dock widget renders the compact swarm rows at the cell level: /// place it through the real `calculate_placements` + `render_all` path into -/// a TestBackend and assert the summary + progress bar landed inside the -/// placement rect. +/// a TestBackend and assert the summary + member rows landed inside the +/// placement rect (the standalone SwarmStatus dock is merged into Overview). #[test] fn swarm_dock_widget_full_render_writes_agent_rows_in_margin() { let _lock = viewport_snapshot_test_lock(); @@ -443,13 +543,13 @@ fn swarm_dock_widget_full_render_writes_agent_rows_in_margin() { crate::tui::info_widget::calculate_placements(messages_area, &margins, &data); dock_rect = placements .iter() - .find(|p| p.kind == crate::tui::info_widget::WidgetKind::SwarmStatus) + .find(|p| p.kind == crate::tui::info_widget::WidgetKind::Overview) .map(|p| p.rect); crate::tui::info_widget::render_all(frame, &placements, &data); }) .expect("dock widget render should not panic"); - let rect = dock_rect.expect("SwarmStatus dock should be placed with a wide free margin"); + let rect = dock_rect.expect("Overview dock should be placed with a wide free margin"); let rows = buffer_rows(&terminal); let dock_text: String = rows[rect.y as usize..(rect.y + rect.height) as usize] .iter() diff --git a/scripts/code_size_budget.json b/scripts/code_size_budget.json index cbfd82a206..7d7a63ca8f 100644 --- a/scripts/code_size_budget.json +++ b/scripts/code_size_budget.json @@ -40,17 +40,17 @@ "crates/jcode-base/src/provider/models.rs": 1349, "crates/jcode-base/src/provider_catalog.rs": 1309, "crates/jcode-base/src/session.rs": 1799, - "crates/jcode-base/src/sidecar.rs": 1715, + "crates/jcode-base/src/sidecar.rs": 2563, "crates/jcode-base/src/skill.rs": 1593, "crates/jcode-base/src/subscription_api.rs": 1206, "crates/jcode-compaction-core/src/lib.rs": 1371, - "crates/jcode-config-types/src/lib.rs": 1616, + "crates/jcode-config-types/src/lib.rs": 1623, "crates/jcode-harness-api-server/src/translate.rs": 3616, "crates/jcode-import-core/src/lib.rs": 1645, "crates/jcode-plan/src/lib.rs": 1201, "crates/jcode-provider-anthropic-runtime/src/lib.rs": 2766, "crates/jcode-provider-bedrock/src/lib.rs": 1491, - "crates/jcode-provider-copilot-runtime/src/lib.rs": 1201, + "crates/jcode-provider-copilot-runtime/src/lib.rs": 1209, "crates/jcode-provider-core/src/lib.rs": 1538, "crates/jcode-provider-cursor-runtime/src/agent_transport.rs": 1873, "crates/jcode-provider-cursor-runtime/src/wire.rs": 1431, @@ -77,7 +77,7 @@ "crates/jcode-tui/src/tui/app/auth.rs": 3448, "crates/jcode-tui/src/tui/app/auth_account_commands.rs": 1217, "crates/jcode-tui/src/tui/app/auth_account_picker.rs": 1233, - "crates/jcode-tui/src/tui/app/commands.rs": 3946, + "crates/jcode-tui/src/tui/app/commands.rs": 4010, "crates/jcode-tui/src/tui/app/debug_bench.rs": 1286, "crates/jcode-tui/src/tui/app/helpers.rs": 1741, "crates/jcode-tui/src/tui/app/hotkey_feedback.rs": 1222, @@ -90,12 +90,12 @@ "crates/jcode-tui/src/tui/app/remote/key_handling.rs": 2717, "crates/jcode-tui/src/tui/app/remote/server_events.rs": 3081, "crates/jcode-tui/src/tui/app/state_ui.rs": 2328, - "crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs": 1807, + "crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs": 1831, "crates/jcode-tui/src/tui/app/tui_lifecycle.rs": 1410, - "crates/jcode-tui/src/tui/app/tui_state.rs": 2440, + "crates/jcode-tui/src/tui/app/tui_state.rs": 2458, "crates/jcode-tui/src/tui/app/turn.rs": 1689, "crates/jcode-tui/src/tui/backend.rs": 1579, - "crates/jcode-tui/src/tui/info_widget.rs": 2267, + "crates/jcode-tui/src/tui/info_widget.rs": 2404, "crates/jcode-tui/src/tui/mod.rs": 2496, "crates/jcode-tui/src/tui/session_picker.rs": 2559, "crates/jcode-tui/src/tui/session_picker/loading.rs": 3084, diff --git a/scripts/panic_budget.json b/scripts/panic_budget.json index 0ef40a14bc..58f618aa53 100644 --- a/scripts/panic_budget.json +++ b/scripts/panic_budget.json @@ -1,5 +1,5 @@ { - "total": 162, + "total": 196, "tracked_files": { "crates/jcode-app-core/build.rs": 4, "crates/jcode-app-core/src/agent/provider.rs": 1, @@ -16,12 +16,17 @@ "crates/jcode-app-core/src/tool/discover.rs": 1, "crates/jcode-app-core/src/tool/mod.rs": 1, "crates/jcode-app-core/src/tool/sdk.rs": 1, + "crates/jcode-base/examples/voice_recorder.rs": 2, + "crates/jcode-base/examples/voice_startup_profile.rs": 2, "crates/jcode-base/src/auth/oauth.rs": 3, "crates/jcode-base/src/hooks.rs": 1, "crates/jcode-base/src/mcp/manager.rs": 2, "crates/jcode-base/src/memory_jev.rs": 1, "crates/jcode-base/src/provider_activity_oauth.rs": 3, + "crates/jcode-base/src/sidecar.rs": 27, "crates/jcode-base/src/subscription_notice.rs": 1, + "crates/jcode-base/src/voice.rs": 1, + "crates/jcode-base/src/voice/nari.rs": 2, "crates/jcode-base/src/voice_intent.rs": 5, "crates/jcode-harness-api-server/src/translate.rs": 3, "crates/jcode-harness-api/examples/harness_repl.rs": 15, diff --git a/scripts/swallowed_error_budget.json b/scripts/swallowed_error_budget.json index 583b03d7c1..31b2843976 100644 --- a/scripts/swallowed_error_budget.json +++ b/scripts/swallowed_error_budget.json @@ -1,9 +1,9 @@ { - "total": 3714, + "total": 3717, "totals_by_pattern": { "dot_ok": 1429, "let_underscore": 1355, - "unwrap_or_default": 930 + "unwrap_or_default": 933 }, "tracked_files": { "crates/jcode-app-core/src/agent.rs": { @@ -2039,7 +2039,7 @@ "crates/jcode-tui/src/tui/app/commands.rs": { "dot_ok": 4, "let_underscore": 14, - "unwrap_or_default": 20 + "unwrap_or_default": 21 }, "crates/jcode-tui/src/tui/app/commands_cloud.rs": { "dot_ok": 0, @@ -2374,7 +2374,7 @@ "crates/jcode-tui/src/tui/info_widget_swarm_background.rs": { "dot_ok": 0, "let_underscore": 0, - "unwrap_or_default": 3 + "unwrap_or_default": 5 }, "crates/jcode-tui/src/tui/info_widget_timeline.rs": { "dot_ok": 2, diff --git a/scripts/test_size_budget.json b/scripts/test_size_budget.json index 7fff62717b..06273a3b19 100644 --- a/scripts/test_size_budget.json +++ b/scripts/test_size_budget.json @@ -11,7 +11,7 @@ "crates/jcode-app-core/src/tool/communicate_tests.rs": 1711, "crates/jcode-app-core/src/tool/selfdev/tests.rs": 1443, "crates/jcode-app-core/src/tool/tests.rs": 1944, - "crates/jcode-base/src/config_tests.rs": 1752, + "crates/jcode-base/src/config_tests.rs": 1835, "crates/jcode-base/src/live_tests.rs": 3087, "crates/jcode-base/src/provider/tests.rs": 1270, "crates/jcode-base/src/provider/tests/model_resolution.rs": 2219, @@ -30,7 +30,7 @@ "crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs": 1672, "crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs": 1670, "crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs": 2671, - "crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs": 2303, + "crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs": 2315, "crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs": 1325, "crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs": 1583, "crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs": 1580, @@ -40,7 +40,7 @@ "crates/jcode-tui/src/tui/app/tests/state_model_poke_02/part_01.rs": 1680, "crates/jcode-tui/src/tui/app/tests/state_model_poke_03.rs": 3078, "crates/jcode-tui/src/tui/app/tests/swarm_plan_graph_inline.rs": 1881, - "crates/jcode-tui/src/tui/info_widget_tests.rs": 2234, + "crates/jcode-tui/src/tui/info_widget_tests.rs": 2803, "crates/jcode-tui/src/tui/session_picker/loading_tests.rs": 1564, "crates/jcode-tui/src/tui/session_picker_tests.rs": 2715, "crates/jcode-tui/src/tui/ui_messages/tests.rs": 3222,