From 88f6da7270113d6f3dfe6f7502bf95a10fc92b7a Mon Sep 17 00:00:00 2001 From: Alejandro Blanco-M Date: Fri, 16 Oct 2026 04:30:16 +0200 Subject: [PATCH 1/3] test(mcp): accept reset close in handshake deadline server The test server read the connection to EOF with unwrap after the client closed it. When the client aborts with unread SSE bytes still in flight, the peer sends RST instead of FIN, so read_to_end saw ECONNRESET and the test failed. Failed about 1 in 5 runs locally. Accept both orderly EOF and ConnectionReset as a valid client close; any other read error still fails the test. --- crates/jcode-base/src/mcp/remote_tests.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From 101d397b9d98bcf08aed6293375ce19839ad7030 Mon Sep 17 00:00:00 2001 From: Alejandro Blanco-M Date: Fri, 16 Oct 2026 04:35:05 +0200 Subject: [PATCH 2/3] test(tui): clear ambient named-profile env in copilot picker tests The copilot picker tests assert pure copilot routing for grok-code-fast-1, but an ambient named provider profile (e.g. a session started with a corporate OpenAI-compatible gateway via JCODE_PROVIDER_PROFILE_ACTIVE) keeps the built-in openai-compatible static catalogs in the picker, so the firmware profile claims grok-code-fast-1 and the tests fail. They only passed in full runs because earlier tests leaked an env cleanup. Capture and remove the three profile vars for the duration of each test so they pass under any ambient profile. --- .../app/tests/remote_startup_input_01/part_02.rs | 13 +++++++++++++ .../app/tests/remote_startup_input_02/part_01.rs | 12 ++++++++++++ 2 files changed, 25 insertions(+) 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); From 2f66b95a7a029bd9e4676ff98e0f47caba5d3f6a Mon Sep 17 00:00:00 2001 From: Alejandro Blanco-M Date: Thu, 15 Oct 2026 12:14:46 +0200 Subject: [PATCH 3/3] tui: release fallbacks when the overview truncates its tail sections --- crates/jcode-base/src/config.rs | 2 + crates/jcode-base/src/config/config_file.rs | 12 + crates/jcode-base/src/config/default_file.rs | 16 +- crates/jcode-base/src/config/env_overrides.rs | 13 + crates/jcode-base/src/config_tests.rs | 83 ++ crates/jcode-base/src/sidecar.rs | 890 +++++++++++++++++- crates/jcode-config-types/src/display.rs | 7 + crates/jcode-config-types/src/lib.rs | 7 + .../src/copilot_tests.rs | 17 + .../jcode-provider-copilot-runtime/src/lib.rs | 10 +- crates/jcode-tui/src/tui/app.rs | 4 +- crates/jcode-tui/src/tui/app/commands.rs | 6 +- .../src/tui/app/commands_overview.rs | 87 ++ crates/jcode-tui/src/tui/app/input_help.rs | 3 + .../src/tui/app/state_ui_input_helpers.rs | 24 + .../tui/app/tests/cache_prompt_accounting.rs | 55 ++ .../tui/app/tests/slash_command_boundaries.rs | 124 +++ crates/jcode-tui/src/tui/app/tui_state.rs | 18 + crates/jcode-tui/src/tui/info_widget.rs | 155 ++- .../jcode-tui/src/tui/info_widget_commits.rs | 52 +- .../jcode-tui/src/tui/info_widget_layout.rs | 128 ++- .../src/tui/info_widget_layout_tests.rs | 162 +++- crates/jcode-tui/src/tui/info_widget_model.rs | 136 ++- .../jcode-tui/src/tui/info_widget_overview.rs | 175 +++- .../src/tui/info_widget_swarm_background.rs | 132 +++ crates/jcode-tui/src/tui/info_widget_tests.rs | 577 +++++++++++- crates/jcode-tui/src/tui/ui_overlays.rs | 4 + .../src/tui/ui_tests/swarm_buffer.rs | 120 ++- scripts/code_size_budget.json | 14 +- scripts/panic_budget.json | 7 +- scripts/swallowed_error_budget.json | 8 +- scripts/test_size_budget.json | 6 +- 32 files changed, 2972 insertions(+), 82 deletions(-) create mode 100644 crates/jcode-tui/src/tui/app/commands_overview.rs 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/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/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,