From f99fff969aeebad97643fd9f9c88ddbee3e45611 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 12 Jun 2026 01:07:11 -0700 Subject: [PATCH] release: harden v0.8.59 readiness lane Integrate the v0.8.59 release-readiness aggregate: command-boundary grouping, Responses schema hardening, Codex reasoning tiers, goal lifecycle/runtime sync, sub-agent stall guards, activity metadata rows, and provider metadata/auth fixes. Credit surfaces are captured in the changelogs for Paulo, Nightt, yekern, and the Devin/Hunter integration work. Co-authored-by: aboimpinto <1231687+aboimpinto@users.noreply.github.com> Co-authored-by: nightt5879 <87569709+nightt5879@users.noreply.github.com> --- CHANGELOG.md | 38 + crates/agent/src/lib.rs | 10 +- crates/tui/CHANGELOG.md | 67 ++ crates/tui/src/client.rs | 50 + crates/tui/src/client/responses.rs | 82 +- .../commands/{ => groups/config}/config.rs | 6 +- crates/tui/src/commands/groups/config/mod.rs | 32 + .../commands/{ => groups/config}/status.rs | 0 .../src/commands/{ => groups/core}/anchor.rs | 0 .../src/commands/{ => groups/core}/core.rs | 6 +- .../commands/{ => groups/core}/feedback.rs | 0 .../tui/src/commands/{ => groups/core}/hf.rs | 0 .../src/commands/{ => groups/core}/hooks.rs | 0 crates/tui/src/commands/groups/core/mod.rs | 141 +++ .../commands/{ => groups/core}/provider.rs | 0 .../src/commands/{ => groups/core}/queue.rs | 0 .../src/commands/{ => groups/core}/stash.rs | 0 .../commands/{ => groups/debug}/balance.rs | 0 .../src/commands/{ => groups/debug}/change.rs | 2 +- .../src/commands/{ => groups/debug}/debug.rs | 6 +- crates/tui/src/commands/groups/debug/mod.rs | 46 + .../commands/{ => groups/memory}/memory.rs | 0 crates/tui/src/commands/groups/memory/mod.rs | 21 + .../src/commands/{ => groups/memory}/note.rs | 0 crates/tui/src/commands/groups/mod.rs | 16 + .../src/commands/{ => groups/project}/goal.rs | 143 ++- .../src/commands/{ => groups/project}/init.rs | 0 crates/tui/src/commands/groups/project/mod.rs | 23 + .../commands/{ => groups/project}/share.rs | 0 crates/tui/src/commands/groups/session/mod.rs | 196 ++++ .../commands/{ => groups/session}/rename.rs | 0 .../commands/{ => groups/session}/session.rs | 2 +- crates/tui/src/commands/groups/skills/mod.rs | 26 + .../commands/{ => groups/skills}/restore.rs | 0 .../commands/{ => groups/skills}/review.rs | 0 .../commands/{ => groups/skills}/skills.rs | 0 .../{ => groups/utility}/attachment.rs | 0 .../src/commands/{ => groups/utility}/jobs.rs | 0 .../src/commands/{ => groups/utility}/mcp.rs | 0 crates/tui/src/commands/groups/utility/mod.rs | 27 + .../commands/{ => groups/utility}/network.rs | 0 .../src/commands/{ => groups/utility}/task.rs | 0 crates/tui/src/commands/mod.rs | 989 +----------------- crates/tui/src/commands/parse.rs | 19 + crates/tui/src/commands/registry.rs | 549 ++++++++++ crates/tui/src/config.rs | 57 +- crates/tui/src/config_ui.rs | 18 +- crates/tui/src/core/engine.rs | 55 +- crates/tui/src/core/engine/dispatch.rs | 2 +- crates/tui/src/core/engine/tests.rs | 60 +- crates/tui/src/core/engine/turn_loop.rs | 33 +- crates/tui/src/core/events.rs | 5 + crates/tui/src/core/ops.rs | 3 + crates/tui/src/main.rs | 21 +- crates/tui/src/model_routing.rs | 66 +- crates/tui/src/models.rs | 41 + crates/tui/src/prompts.rs | 19 +- crates/tui/src/runtime_threads.rs | 4 + crates/tui/src/settings.rs | 2 +- crates/tui/src/tools/goal.rs | 102 +- crates/tui/src/tools/schema_sanitize.rs | 323 +++++- crates/tui/src/tools/subagent/mod.rs | 65 +- crates/tui/src/tools/subagent/tests.rs | 117 +++ crates/tui/src/tui/app.rs | 141 ++- crates/tui/src/tui/auto_router.rs | 3 +- crates/tui/src/tui/history.rs | 235 ++++- crates/tui/src/tui/hotbar/actions.rs | 37 +- crates/tui/src/tui/model_picker.rs | 146 ++- crates/tui/src/tui/ui.rs | 93 +- crates/tui/src/tui/ui/tests.rs | 136 +++ crates/tui/src/tui/views/mod.rs | 2 +- crates/tui/src/tui/widgets/mod.rs | 6 +- docs/CONFIGURATION.md | 4 +- docs/KEYBINDINGS.md | 2 +- docs/MODES.md | 7 +- docs/PROVIDERS.md | 6 +- 76 files changed, 3085 insertions(+), 1223 deletions(-) rename crates/tui/src/commands/{ => groups/config}/config.rs (99%) create mode 100644 crates/tui/src/commands/groups/config/mod.rs rename crates/tui/src/commands/{ => groups/config}/status.rs (100%) rename crates/tui/src/commands/{ => groups/core}/anchor.rs (100%) rename crates/tui/src/commands/{ => groups/core}/core.rs (99%) rename crates/tui/src/commands/{ => groups/core}/feedback.rs (100%) rename crates/tui/src/commands/{ => groups/core}/hf.rs (100%) rename crates/tui/src/commands/{ => groups/core}/hooks.rs (100%) create mode 100644 crates/tui/src/commands/groups/core/mod.rs rename crates/tui/src/commands/{ => groups/core}/provider.rs (100%) rename crates/tui/src/commands/{ => groups/core}/queue.rs (100%) rename crates/tui/src/commands/{ => groups/core}/stash.rs (100%) rename crates/tui/src/commands/{ => groups/debug}/balance.rs (100%) rename crates/tui/src/commands/{ => groups/debug}/change.rs (99%) rename crates/tui/src/commands/{ => groups/debug}/debug.rs (99%) create mode 100644 crates/tui/src/commands/groups/debug/mod.rs rename crates/tui/src/commands/{ => groups/memory}/memory.rs (100%) create mode 100644 crates/tui/src/commands/groups/memory/mod.rs rename crates/tui/src/commands/{ => groups/memory}/note.rs (100%) create mode 100644 crates/tui/src/commands/groups/mod.rs rename crates/tui/src/commands/{ => groups/project}/goal.rs (70%) rename crates/tui/src/commands/{ => groups/project}/init.rs (100%) create mode 100644 crates/tui/src/commands/groups/project/mod.rs rename crates/tui/src/commands/{ => groups/project}/share.rs (100%) create mode 100644 crates/tui/src/commands/groups/session/mod.rs rename crates/tui/src/commands/{ => groups/session}/rename.rs (100%) rename crates/tui/src/commands/{ => groups/session}/session.rs (99%) create mode 100644 crates/tui/src/commands/groups/skills/mod.rs rename crates/tui/src/commands/{ => groups/skills}/restore.rs (100%) rename crates/tui/src/commands/{ => groups/skills}/review.rs (100%) rename crates/tui/src/commands/{ => groups/skills}/skills.rs (100%) rename crates/tui/src/commands/{ => groups/utility}/attachment.rs (100%) rename crates/tui/src/commands/{ => groups/utility}/jobs.rs (100%) rename crates/tui/src/commands/{ => groups/utility}/mcp.rs (100%) create mode 100644 crates/tui/src/commands/groups/utility/mod.rs rename crates/tui/src/commands/{ => groups/utility}/network.rs (100%) rename crates/tui/src/commands/{ => groups/utility}/task.rs (100%) create mode 100644 crates/tui/src/commands/parse.rs create mode 100644 crates/tui/src/commands/registry.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 136727cd..48c6396c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Cursor-style activity metadata rows (#3146).** Dense successful tool-run + summaries now render as a single muted `Explored ...` / `Updated metadata` + row while keeping keyboard/mouse expansion and detail inspection intact. - **Provider-wait observability (#3095).** Footer stall reasons now name the active provider/model route, idle seconds vs stream budget, and whether a fanout plan is still at `0 running` or dispatch is pending. Structured @@ -18,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 behind a configurable semaphore (`[subagents] interactive_max_launch`, default 4) with a visible `queued: waiting for an interactive fanout slot` reason before their first model step. +- **Goal lifecycle controls.** `/goal` is now the primary command surface for + session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear` + controls while `/hunt` remains a compatibility alias. +- **Command-boundary ownership layers (#2888/#3055).** Built-in slash command + metadata now lives in `commands/registry.rs`, slash parsing in + `commands/parse.rs`, and handlers under group-owned command areas, preserving + the existing dispatch surface while reducing future `commands/mod.rs` churn. ### Fixed @@ -27,14 +37,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Interrupted sub-agent lifecycle (#3080).** API-timeout interruptions now emit `MailboxMessage::Interrupted`, render terminal interrupted cards, and reconcile stale running fanout counts from manager snapshots. +- **OpenAI Codex reasoning tiers.** Switching from DeepSeek to `openai-codex` + now normalizes stale reasoning state into Responses-compatible + `low`/`medium`/`high`/`xhigh` tiers and reports Codex as a Responses payload + provider. +- **OpenAI Codex context metadata (#3070).** The `gpt-5.5` default and + CodeWhale aliases now use OpenAI's documented 1,050,000-token context window + and 128,000 max-output metadata for context pressure, prompts, and doctor + capability output. +- **OpenRouter Nemotron 3 Ultra preset.** The OpenRouter preset and model + registry now emit `nvidia/nemotron-3-ultra-550b-a55b` while keeping the old + Ultra aliases compatible. +- **OpenRouter auth after MiMo switches (#3064).** Switching from Xiaomi MiMo + to OpenRouter now has regression coverage for preflight key failures and + Bearer auth header isolation before any request can be dispatched. +- **Responses strict-tool schema compatibility (#3062/#3017/#1883).** Responses + function tools now preserve per-tool strict-mode compatibility, keep optional + strict-schema fields nullable, and append deterministic constraint notes when + root composition groups must be flattened for Responses. - **Runtime prompt autonomous loop guard (#3061).** Runtime policy reference now explicitly forbids initiating new work when `` is the only new turn content and no tool/sub-agent handoff is pending. +- **Goal runtime status sync.** Goal token budgets and active/paused/complete + status now sync into the engine alongside the objective, and model-visible + `update_goal` can only mark goals complete or blocked. ### Contributors - Devin session work on #3080/#3095 (PRs #3103, #3104, #3106) — Hunter Bown (maintainer integration/cherry-pick on `codex/v0.8.59-release-ready`). +- Nightt (@nightt5879) for the Responses strict-tool schema hardening in PR + #3062. +- yekern (@yekern) for the #3061 runtime-prompt loop safety report and repro + that shaped the dispatch guard. +- Paulo Aboim Pinto (@aboimpinto) for the staged command-boundary design and + Layer 3 registry/parser extraction in PR #2888, plus the #2851/#2791/#2870 + architecture stream that guided the grouped command areas in #3055. ## [0.8.58] - 2026-06-11 diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 71214623..ed38a3e9 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -643,11 +643,14 @@ impl Default for ModelRegistry { }, // NVIDIA Nemotron 3 Ultra (OpenRouter) ModelInfo { - id: "nvidia/nemotron-3-ultra".to_string(), + id: "nvidia/nemotron-3-ultra-550b-a55b".to_string(), provider: ProviderKind::Openrouter, aliases: vec![ + "nvidia/nemotron-3-ultra".to_string(), "nemotron-3-ultra".to_string(), + "nemotron-3-ultra-550b-a55b".to_string(), "nvidia-nemotron-3-ultra".to_string(), + "nvidia-nemotron-3-ultra-550b-a55b".to_string(), ], supports_tools: true, supports_reasoning: true, @@ -1220,6 +1223,11 @@ mod tests { ("minimax-m3", "minimax/minimax-m3"), ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"), ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"), + ("nemotron-3-ultra", "nvidia/nemotron-3-ultra-550b-a55b"), + ( + "nvidia/nemotron-3-ultra", + "nvidia/nemotron-3-ultra-550b-a55b", + ), ] { let resolved = registry.resolve(Some(alias), Some(ProviderKind::Openrouter)); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index a59e1698..b456a676 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Cursor-style activity metadata rows (#3146).** Dense successful tool-run + summaries now render as a single muted `Explored ...` / `Updated metadata` + row while keeping keyboard/mouse expansion and detail inspection intact. +- **Provider-wait observability (#3095).** Footer stall reasons now name the + active provider/model route, idle seconds vs stream budget, and whether a + fanout plan is still at `0 running` or dispatch is pending. Structured + provider-wait incidents log once per turn from the main tick loop (not on + every footer redraw). +- **Interactive fanout launch gate (#3095).** Direct sub-agent children queue + behind a configurable semaphore (`[subagents] interactive_max_launch`, + default 4) with a visible `queued: waiting for an interactive fanout slot` + reason before their first model step. +- **Goal lifecycle controls.** `/goal` is now the primary command surface for + session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear` + controls while `/hunt` remains a compatibility alias. +- **Command-boundary ownership layers (#2888/#3055).** Built-in slash command + metadata now lives in `commands/registry.rs`, slash parsing in + `commands/parse.rs`, and handlers under group-owned command areas, preserving + the existing dispatch surface while reducing future `commands/mod.rs` churn. + +### Fixed + +- **TUI mouse-report leak (#3063/#3067).** Strip raw SGR mouse coordinate + tails from the composer even when `use_mouse_capture` is false, covering + orphaned terminal reporting state after crashes or focus races. +- **Interrupted sub-agent lifecycle (#3080).** API-timeout interruptions now + emit `MailboxMessage::Interrupted`, render terminal interrupted cards, and + reconcile stale running fanout counts from manager snapshots. +- **OpenAI Codex reasoning tiers.** Switching from DeepSeek to `openai-codex` + now normalizes stale reasoning state into Responses-compatible + `low`/`medium`/`high`/`xhigh` tiers and reports Codex as a Responses payload + provider. +- **OpenAI Codex context metadata (#3070).** The `gpt-5.5` default and + CodeWhale aliases now use OpenAI's documented 1,050,000-token context window + and 128,000 max-output metadata for context pressure, prompts, and doctor + capability output. +- **OpenRouter Nemotron 3 Ultra preset.** The OpenRouter preset and model + registry now emit `nvidia/nemotron-3-ultra-550b-a55b` while keeping the old + Ultra aliases compatible. +- **OpenRouter auth after MiMo switches (#3064).** Switching from Xiaomi MiMo + to OpenRouter now has regression coverage for preflight key failures and + Bearer auth header isolation before any request can be dispatched. +- **Responses strict-tool schema compatibility (#3062/#3017/#1883).** Responses + function tools now preserve per-tool strict-mode compatibility, keep optional + strict-schema fields nullable, and append deterministic constraint notes when + root composition groups must be flattened for Responses. +- **Runtime prompt autonomous loop guard (#3061).** Runtime policy reference + now explicitly forbids initiating new work when `` is the + only new turn content and no tool/sub-agent handoff is pending. +- **Goal runtime status sync.** Goal token budgets and active/paused/complete + status now sync into the engine alongside the objective, and model-visible + `update_goal` can only mark goals complete or blocked. + +### Contributors + +- Devin session work on #3080/#3095 (PRs #3103, #3104, #3106) — Hunter Bown + (maintainer integration/cherry-pick on `codex/v0.8.59-release-ready`). +- Nightt (@nightt5879) for the Responses strict-tool schema hardening in PR + #3062. +- yekern (@yekern) for the #3061 runtime-prompt loop safety report and repro + that shaped the dispatch guard. +- Paulo Aboim Pinto (@aboimpinto) for the staged command-boundary design and + Layer 3 registry/parser extraction in PR #2888, plus the #2851/#2791/#2870 + architecture stream that guided the grouped command areas in #3055. + ## [0.8.58] - 2026-06-11 ### Added diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index da34b971..9f7c44a6 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -757,6 +757,7 @@ fn build_default_headers( if header_name == AUTHORIZATION || header_name == CONTENT_TYPE || auth_header_name.as_ref() == Some(&header_name) + || (auth_header_name.is_some() && is_auth_dialect_header(&header_name)) { continue; } @@ -765,6 +766,12 @@ fn build_default_headers( Ok(headers) } +fn is_auth_dialect_header(header_name: &HeaderName) -> bool { + header_name == AUTHORIZATION + || header_name == HeaderName::from_static("api-key") + || header_name == HeaderName::from_static("x-api-key") +} + fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool { let normalized = base_url.trim().to_ascii_lowercase(); let without_scheme = normalized @@ -1875,6 +1882,49 @@ mod tests { ); } + #[test] + fn openrouter_uses_bearer_header_after_mimo_token_plan_context() { + let mut extra = HashMap::new(); + extra.insert("api-key".to_string(), "wrong".to_string()); + let headers = DeepSeekClient::default_headers_for_provider( + "sk-or-test", + &extra, + ApiProvider::Openrouter, + crate::config::DEFAULT_OPENROUTER_BASE_URL, + ) + .expect("headers"); + + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer sk-or-test") + ); + assert!( + headers.get("api-key").is_none(), + "OpenRouter must not inherit Xiaomi MiMo's api-key header dialect" + ); + } + + #[test] + fn custom_api_key_header_is_allowed_without_primary_provider_key() { + let mut extra = HashMap::new(); + extra.insert("api-key".to_string(), "gateway-key".to_string()); + let headers = DeepSeekClient::default_headers_for_provider( + "", + &extra, + ApiProvider::Openai, + "https://gateway.example.test/v1", + ) + .expect("headers"); + + assert_eq!( + headers.get("api-key").and_then(|value| value.to_str().ok()), + Some("gateway-key") + ); + assert!(headers.get(AUTHORIZATION).is_none()); + } + #[test] fn xiaomi_mimo_pay_as_you_go_endpoint_keeps_bearer_header() { let headers = DeepSeekClient::default_headers_for_provider( diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 93150086..ab8f548f 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -56,11 +56,10 @@ impl DeepSeekClient { } } - // Reasoning configuration. The Codex Responses backend only accepts a - // fixed set of effort levels (none/minimal/low/medium/high/xhigh), so - // map CodeWhale's effort string onto those and omit reasoning entirely - // when it is disabled. CodeWhale's "auto" has no Codex equivalent and - // falls back to "medium". + // Reasoning configuration. The Codex Responses backend accepts + // low/medium/high/xhigh, so provider-aware callers normalize inherited + // DeepSeek-only values before request construction: "off" becomes + // "low", and CodeWhale's "auto" falls back to "medium". if let Some(raw) = request.reasoning_effort.as_deref() && let Some(effort) = codex_responses_reasoning_effort(raw) { @@ -597,11 +596,16 @@ fn convert_messages_to_responses_input(request: &MessageRequest) -> Vec { /// Convert a CodeWhale tool definition to a Responses API function tool. fn tool_to_responses_function(tool: &Tool) -> Value { let mut parameters = tool.input_schema.clone(); - schema_sanitize::sanitize_for_responses(&mut parameters); + let constraint_note = schema_sanitize::sanitize_for_responses(&mut parameters); + let description = match constraint_note { + Some(note) if tool.description.trim().is_empty() => note, + Some(note) => format!("{}\n\n{}", tool.description.trim(), note), + None => tool.description.clone(), + }; json!({ "type": "function", "name": tool.name, - "description": tool.description, + "description": description, "parameters": parameters, "strict": false, }) @@ -609,7 +613,7 @@ fn tool_to_responses_function(tool: &Tool) -> Value { fn codex_responses_reasoning_effort(raw: &str) -> Option<&'static str> { match raw.trim().to_ascii_lowercase().as_str() { - "off" | "disabled" | "none" | "false" => None, + "off" | "disabled" | "none" | "false" => Some("low"), "minimal" => Some("minimal"), "low" => Some("low"), "high" => Some("high"), @@ -667,7 +671,7 @@ mod tests { assert_eq!(codex_responses_reasoning_effort("high"), Some("high")); assert_eq!(codex_responses_reasoning_effort("medium"), Some("medium")); assert_eq!(codex_responses_reasoning_effort("auto"), Some("medium")); - assert_eq!(codex_responses_reasoning_effort("off"), None); + assert_eq!(codex_responses_reasoning_effort("off"), Some("low")); } #[test] @@ -750,6 +754,66 @@ mod tests { assert!(parameters.get("not").is_none()); assert!(parameters["properties"].get("patch").is_some()); assert!(parameters["properties"].get("changes").is_some()); + assert_eq!( + payload["description"], + "Apply patch\n\nExactly one of these parameter groups must be provided: `changes` | `patch`." + ); assert!(tool.input_schema.get("oneOf").is_some()); } + + #[test] + fn responses_function_tool_trims_description_before_constraint_note() { + let tool = Tool { + tool_type: None, + name: "apply_patch".to_string(), + description: "Apply patch\n".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "patch": {"type": "string"}, + "changes": {"type": "array"} + }, + "oneOf": [ + {"required": ["patch"]}, + {"required": ["changes"]} + ] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }; + + let payload = tool_to_responses_function(&tool); + + assert_eq!( + payload["description"], + "Apply patch\n\nExactly one of these parameter groups must be provided: `changes` | `patch`." + ); + } + + #[test] + fn responses_function_tool_leaves_description_unchanged_without_constraint_note() { + let tool = Tool { + tool_type: None, + name: "lookup".to_string(), + description: "Lookup".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": {"type": "string"} + } + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }; + + let payload = tool_to_responses_function(&tool); + + assert_eq!(payload["description"], "Lookup"); + } } diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/groups/config/config.rs similarity index 99% rename from crates/tui/src/commands/config.rs rename to crates/tui/src/commands/groups/config/config.rs index 10b0d1d5..97e52f53 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -247,7 +247,11 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { .default_model .unwrap_or_else(|| "(default)".to_string()) }), - "reasoning_effort" | "effort" => Some(app.reasoning_effort.as_setting().to_string()), + "reasoning_effort" | "effort" => Some( + app.reasoning_effort + .as_setting_for_provider(app.api_provider) + .to_string(), + ), "prefer_external_pdftotext" | "external_pdftotext" | "pdftotext" => Settings::load() .ok() .map(|settings| settings.prefer_external_pdftotext.to_string()), diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs new file mode 100644 index 00000000..7bb5ad98 --- /dev/null +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -0,0 +1,32 @@ +//! Config command area: settings, modes, themes, trust, and status surfaces. + +#[allow(clippy::module_inception)] +pub mod config; +mod status; + +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(in crate::commands) fn dispatch( + app: &mut App, + command: &str, + arg: Option<&str>, +) -> Option { + let result = match command { + "config" => config::config_command(app, arg), + "sidebar" => config::sidebar(app, arg), + "settings" => config::show_settings(app), + "status" => status::status(app), + "statusline" => config::status_line(app), + "mode" => config::mode(app, arg), + "jihua" => config::mode(app, Some("plan")), + "zidong" => config::mode(app, Some("yolo")), + "theme" => config::theme(app, arg), + "verbose" => config::verbose(app, arg), + "trust" | "xinren" => config::trust(app, arg), + "logout" => config::logout(app), + "slop" | "canzha" => config::slop(app, arg), + _ => return None, + }; + Some(result) +} diff --git a/crates/tui/src/commands/status.rs b/crates/tui/src/commands/groups/config/status.rs similarity index 100% rename from crates/tui/src/commands/status.rs rename to crates/tui/src/commands/groups/config/status.rs diff --git a/crates/tui/src/commands/anchor.rs b/crates/tui/src/commands/groups/core/anchor.rs similarity index 100% rename from crates/tui/src/commands/anchor.rs rename to crates/tui/src/commands/groups/core/anchor.rs diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/groups/core/core.rs similarity index 99% rename from crates/tui/src/commands/core.rs rename to crates/tui/src/commands/groups/core/core.rs index c8f32bdd..dd8cda24 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -17,7 +17,7 @@ use super::CommandResult; pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { if let Some(topic) = topic { // Show help for specific command - if let Some(cmd) = super::get_command_info(topic) { + if let Some(cmd) = crate::commands::get_command_info(topic) { let mut help = format!( "{}\n\n {}\n\n {} {}", cmd.name, @@ -179,9 +179,7 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { }; let old_model = app.model_display_label(); let model_changed = app.auto_model || app.model != model_id; - app.auto_model = false; - app.model = model_id.clone(); - app.last_effective_model = None; + app.set_model_selection(model_id.clone()); app.update_model_compaction_budget(); if model_changed { app.clear_model_scoped_telemetry(); diff --git a/crates/tui/src/commands/feedback.rs b/crates/tui/src/commands/groups/core/feedback.rs similarity index 100% rename from crates/tui/src/commands/feedback.rs rename to crates/tui/src/commands/groups/core/feedback.rs diff --git a/crates/tui/src/commands/hf.rs b/crates/tui/src/commands/groups/core/hf.rs similarity index 100% rename from crates/tui/src/commands/hf.rs rename to crates/tui/src/commands/groups/core/hf.rs diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/groups/core/hooks.rs similarity index 100% rename from crates/tui/src/commands/hooks.rs rename to crates/tui/src/commands/groups/core/hooks.rs diff --git a/crates/tui/src/commands/groups/core/mod.rs b/crates/tui/src/commands/groups/core/mod.rs new file mode 100644 index 00000000..e3cf56f5 --- /dev/null +++ b/crates/tui/src/commands/groups/core/mod.rs @@ -0,0 +1,141 @@ +//! Core command area: model/provider selection, help, navigation, and the +//! persistent RLM / sub-agent entry points. + +mod anchor; +#[allow(clippy::module_inception)] +mod core; +mod feedback; +mod hf; +mod hooks; +mod provider; +mod queue; +mod stash; + +pub(in crate::commands) use self::core::reset_conversation_state; + +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(in crate::commands) fn dispatch( + app: &mut App, + command: &str, + arg: Option<&str>, +) -> Option { + let result = match command { + "anchor" | "maodian" => anchor::anchor(app, arg), + "help" | "?" | "bangzhu" | "帮助" => core::help(app, arg), + "clear" | "qingping" => core::clear(app), + "exit" | "quit" | "q" | "tuichu" => core::exit(), + "model" | "moxing" => core::model(app, arg), + "models" | "moxingliebiao" => core::models(app), + "provider" => provider::provider(app, arg), + "queue" | "queued" => queue::queue(app, arg), + "stash" | "park" => stash::stash(app, arg), + "hooks" | "hook" | "gouzi" => hooks::hooks(app, arg), + "subagents" | "agents" | "zhinengti" => core::subagents(app), + "agent" | "daili" => agent(app, arg), + "links" | "dashboard" | "api" | "lianjie" => core::deepseek_links(app), + "feedback" => feedback::feedback(app, arg), + "hf" | "huggingface" => hf::hf(app, arg), + "home" | "stats" | "overview" | "zhuye" | "shouye" => core::home_dashboard(app), + "workspace" | "cwd" => core::workspace_switch(app, arg), + "profile" | "dangan" => core::profile_switch(app, arg), + "rlm" | "recursive" | "digui" => rlm(app, arg), + "translate" | "translation" | "transale" => core::translate(app), + _ => return None, + }; + Some(result) +} + +/// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from +/// Zhang et al. (arXiv:2512.24601). +/// +/// The user's prompt text is passed as the argument. It will be stored +/// in the REPL as the `PROMPT` variable. The root LLM will only see +/// metadata about the REPL state, never the prompt text directly. +pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { + let (max_depth, target) = match parse_depth_prefixed_arg(arg, 1) { + Ok(parsed) => parsed, + Err(message) => return CommandResult::error(message), + }; + let target = match target { + Some(p) if !p.trim().is_empty() => p.trim().to_string(), + _ => { + return CommandResult::error( + "Usage: /rlm [N] \n\n\ + Opens a persistent RLM context with sub_rlm depth N (0-3, default 1)." + .to_string(), + ); + } + }; + + let source_arg = if resolves_to_existing_file(app, &target) { + format!(r#"file_path: "{target}""#) + } else { + format!("content: {target:?}") + }; + let message = format!( + "Open and use a persistent RLM session for this request. Call `rlm_open` with name `slash_rlm` and {source_arg}. Then call `rlm_configure` with `sub_rlm_max_depth: {max_depth}`. Use `rlm_eval` to inspect the context through `peek`, `search`, and `chunk`, and call `finalize(...)` from the REPL when ready. If a `var_handle` is returned, use `handle_read` for bounded slices or projections before answering." + ); + + CommandResult::with_message_and_action( + format!("Opening persistent RLM context at depth {max_depth}..."), + AppAction::SendMessage(message), + ) +} + +/// Open a persistent sub-agent session from a slash command. +pub fn agent(_app: &mut App, arg: Option<&str>) -> CommandResult { + let (max_depth, task) = match parse_depth_prefixed_arg(arg, 1) { + Ok(parsed) => parsed, + Err(message) => return CommandResult::error(message), + }; + let task = match task { + Some(task) if !task.trim().is_empty() => task.trim().to_string(), + _ => { + return CommandResult::error( + "Usage: /agent [N] \n\n\ + Opens a persistent sub-agent session with recursive agent depth N (0-3, default 1).", + ); + } + }; + let message = format!( + "Open a persistent sub-agent session for this task. Call `agent_open` with name `slash_agent`, `prompt: {task:?}`, and `max_depth: {max_depth}`. Use `agent_eval` to wait for the next terminal/current projection and `handle_read` on the returned transcript_handle if you need more detail. Verify any claimed side effects before reporting success." + ); + CommandResult::with_message_and_action( + format!("Opening persistent sub-agent at depth {max_depth}..."), + AppAction::SendMessage(message), + ) +} + +fn parse_depth_prefixed_arg( + arg: Option<&str>, + default_depth: u32, +) -> Result<(u32, Option<&str>), String> { + let Some(raw) = arg.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok((default_depth, None)); + }; + let mut parts = raw.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or_default(); + if first.chars().all(|ch| ch.is_ascii_digit()) { + let depth: u32 = first + .parse() + .map_err(|_| "Depth must be an integer from 0 to 3".to_string())?; + if depth > 3 { + return Err("Depth must be between 0 and 3".to_string()); + } + Ok((depth, parts.next().map(str::trim))) + } else { + Ok((default_depth, Some(raw))) + } +} + +fn resolves_to_existing_file(app: &App, input: &str) -> bool { + let path = std::path::Path::new(input); + let candidate = if path.is_absolute() { + path.to_path_buf() + } else { + app.workspace.join(path) + }; + candidate.is_file() +} diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/groups/core/provider.rs similarity index 100% rename from crates/tui/src/commands/provider.rs rename to crates/tui/src/commands/groups/core/provider.rs diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/groups/core/queue.rs similarity index 100% rename from crates/tui/src/commands/queue.rs rename to crates/tui/src/commands/groups/core/queue.rs diff --git a/crates/tui/src/commands/stash.rs b/crates/tui/src/commands/groups/core/stash.rs similarity index 100% rename from crates/tui/src/commands/stash.rs rename to crates/tui/src/commands/groups/core/stash.rs diff --git a/crates/tui/src/commands/balance.rs b/crates/tui/src/commands/groups/debug/balance.rs similarity index 100% rename from crates/tui/src/commands/balance.rs rename to crates/tui/src/commands/groups/debug/balance.rs diff --git a/crates/tui/src/commands/change.rs b/crates/tui/src/commands/groups/debug/change.rs similarity index 99% rename from crates/tui/src/commands/change.rs rename to crates/tui/src/commands/groups/debug/change.rs index 81a543be..8d37eebc 100644 --- a/crates/tui/src/commands/change.rs +++ b/crates/tui/src/commands/groups/debug/change.rs @@ -19,7 +19,7 @@ use super::CommandResult; /// If the changelog section exceeds this, we truncate and show a notice. /// 4096 chars is large enough for most version entries. const MAX_INLINE_CHANGELOG_CHARS: usize = 4096; -const CODEWHALE_CHANGELOG: &str = include_str!("../../CHANGELOG.md"); +const CODEWHALE_CHANGELOG: &str = include_str!("../../../../CHANGELOG.md"); /// Execute the `/change` command. /// diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/groups/debug/debug.rs similarity index 99% rename from crates/tui/src/commands/debug.rs rename to crates/tui/src/commands/groups/debug/debug.rs index 5d997c75..875ec6ab 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/groups/debug/debug.rs @@ -176,10 +176,12 @@ fn format_cache_inspect(app: &mut App, verbose: bool, json_mode: bool) -> String let reasoning_effort = if app.reasoning_effort == crate::tui::app::ReasoningEffort::Auto { app.last_effective_reasoning_effort - .and_then(crate::tui::app::ReasoningEffort::api_value) + .and_then(|effort| effort.api_value_for_provider(app.api_provider)) .map(str::to_string) } else { - app.reasoning_effort.api_value().map(str::to_string) + app.reasoning_effort + .api_value_for_provider(app.api_provider) + .map(str::to_string) }; let request = MessageRequest { model: app.model.clone(), diff --git a/crates/tui/src/commands/groups/debug/mod.rs b/crates/tui/src/commands/groups/debug/mod.rs new file mode 100644 index 00000000..50d935e1 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/mod.rs @@ -0,0 +1,46 @@ +//! Debug command area: token/cost introspection, cache tooling, undo/retry, +//! and the change log. + +mod balance; +mod change; +#[allow(clippy::module_inception)] +mod debug; + +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(in crate::commands) fn dispatch( + app: &mut App, + command: &str, + arg: Option<&str>, +) -> Option { + let result = match command { + "tokens" => debug::tokens(app), + "cost" => debug::cost(app), + "balance" => balance::balance(app), + "cache" => debug::cache(app, arg), + "change" => change::change(app, arg), + "system" | "xitong" => debug::system_prompt(app), + "context" | "ctx" => debug::context(app), + "edit" => debug::edit(app), + "diff" => debug::diff(app), + "undo" => { + // Try surgical patch-undo first; fall back to conversation undo + // if no snapshots are available or if the snapshot undo couldn't + // find anything useful. + let result = debug::patch_undo(app); + if result.message.as_deref().is_none_or(|m| { + m.starts_with("No snapshots found") + || m.starts_with("No older tool or pre-turn") + || m.starts_with("Snapshot repo") + }) { + debug::undo_conversation(app) + } else { + result + } + } + "retry" | "chongshi" => debug::retry(app), + _ => return None, + }; + Some(result) +} diff --git a/crates/tui/src/commands/memory.rs b/crates/tui/src/commands/groups/memory/memory.rs similarity index 100% rename from crates/tui/src/commands/memory.rs rename to crates/tui/src/commands/groups/memory/memory.rs diff --git a/crates/tui/src/commands/groups/memory/mod.rs b/crates/tui/src/commands/groups/memory/mod.rs new file mode 100644 index 00000000..bc066c3b --- /dev/null +++ b/crates/tui/src/commands/groups/memory/mod.rs @@ -0,0 +1,21 @@ +//! Memory command area: persistent memory and quick notes. + +#[allow(clippy::module_inception)] +mod memory; +mod note; + +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(in crate::commands) fn dispatch( + app: &mut App, + command: &str, + arg: Option<&str>, +) -> Option { + let result = match command { + "memory" => memory::memory(app, arg), + "note" => note::note(app, arg), + _ => return None, + }; + Some(result) +} diff --git a/crates/tui/src/commands/note.rs b/crates/tui/src/commands/groups/memory/note.rs similarity index 100% rename from crates/tui/src/commands/note.rs rename to crates/tui/src/commands/groups/memory/note.rs diff --git a/crates/tui/src/commands/groups/mod.rs b/crates/tui/src/commands/groups/mod.rs new file mode 100644 index 00000000..cae969f0 --- /dev/null +++ b/crates/tui/src/commands/groups/mod.rs @@ -0,0 +1,16 @@ +//! Group-owned built-in command areas. +//! +//! Each group module owns the handler files for its command area and +//! exposes a `dispatch` slice that claims the command names it owns and +//! returns `None` for everything else. `commands::execute` chains the +//! group dispatchers in order, so a command name must be claimed by +//! exactly one group. + +pub mod config; +pub mod core; +pub mod debug; +pub mod memory; +pub mod project; +pub mod session; +pub mod skills; +pub mod utility; diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/groups/project/goal.rs similarity index 70% rename from crates/tui/src/commands/goal.rs rename to crates/tui/src/commands/groups/project/goal.rs index ce3858b5..764b92ec 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/groups/project/goal.rs @@ -1,4 +1,4 @@ -//! /hunt command — declare a quarry with token budget and verdict tracking (#2092). +//! /goal command, with /hunt kept as a compatibility alias (#2092). use std::io::Write; @@ -6,7 +6,7 @@ use crate::tui::app::{App, AppAction, HuntVerdict}; use super::CommandResult; -/// Declare, show, or close a hunt +/// Declare, show, pause, resume, or close a goal. pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { match arg { Some("clear") | Some("reset") => { @@ -14,15 +14,20 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { app.hunt.token_budget = None; app.hunt.started_at = None; app.hunt.verdict = HuntVerdict::default(); - CommandResult::message("Hunt cleared.") + CommandResult::message("Goal cleared.") } Some("done") | Some("complete") | Some("hunted") => close_hunt(app, HuntVerdict::Hunted), - Some("wound") | Some("wounded") => close_hunt(app, HuntVerdict::Wounded), - Some("escape") | Some("escaped") => close_hunt(app, HuntVerdict::Escaped), + Some("pause") | Some("paused") | Some("wound") | Some("wounded") => { + close_hunt(app, HuntVerdict::Wounded) + } + Some("resume") | Some("continue") => resume_hunt(app), + Some("block") | Some("blocked") | Some("escape") | Some("escaped") => { + close_hunt(app, HuntVerdict::Escaped) + } Some(text) if !text.is_empty() => { let (objective, budget) = parse_hunt_budget(text); if objective.is_empty() || objective.chars().all(|c| c == '|') { - return CommandResult::error("Usage: /hunt [budget: N]"); + return CommandResult::error(goal_usage()); } app.hunt.quarry = Some(objective.clone()); app.hunt.token_budget = budget; @@ -32,7 +37,7 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { .map(|b| format!(" (budget: {b} tokens)")) .unwrap_or_default(); CommandResult::with_message_and_action( - format!("Hunt set: \"{objective}\"{budget_str} — tracking progress."), + format!("Goal set: \"{objective}\"{budget_str} - tracking progress."), AppAction::SendMessage(objective), ) } @@ -57,22 +62,16 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { }) .unwrap_or_default(); let verdict_label = match app.hunt.verdict { - HuntVerdict::Hunting => "[HUNTING]", - HuntVerdict::Hunted => "[HUNTED]", - HuntVerdict::Wounded => "[WOUNDED]", - HuntVerdict::Escaped => "[ESCAPED]", + HuntVerdict::Hunting => "[ACTIVE]", + HuntVerdict::Hunted => "[COMPLETE]", + HuntVerdict::Wounded => "[PAUSED]", + HuntVerdict::Escaped => "[BLOCKED]", }; CommandResult::message(format!( - "Hunt {verdict_label}: \"{obj}\" — elapsed: {elapsed}{budget_str}" + "Goal {verdict_label}: \"{obj}\" - elapsed: {elapsed}{budget_str}" )) } else { - CommandResult::message( - "No hunt set. Use /hunt [budget: N] to declare one.\n\ - /hunt hunted — mark complete\n\ - /hunt wounded — mark interrupted (resumable)\n\ - /hunt escaped — mark abandoned\n\ - /hunt clear — remove the current hunt.", - ) + CommandResult::message(goal_usage()) } } } @@ -80,11 +79,15 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { fn close_hunt(app: &mut App, verdict: HuntVerdict) -> CommandResult { if app.hunt.quarry.as_deref().is_none_or(str::is_empty) { - return CommandResult::error("No hunt set. Use /hunt [budget: N] first."); + return CommandResult::error("No goal set. Use /goal [budget: N] first."); } let prev = app.hunt.verdict; - let should_write_trophy = prev != verdict || !matches!(verdict, HuntVerdict::Hunted); + let should_write_trophy = match verdict { + HuntVerdict::Hunted => prev != verdict, + HuntVerdict::Escaped => true, + HuntVerdict::Wounded | HuntVerdict::Hunting => false, + }; if should_write_trophy && let Err(err) = write_trophy_card(app, verdict) { return CommandResult::error(err); } @@ -97,16 +100,44 @@ fn close_hunt(app: &mut App, verdict: HuntVerdict) -> CommandResult { .started_at .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) .unwrap_or_else(|| "unknown".to_string()); - CommandResult::message(format!("Hunt complete! Elapsed: {elapsed}")) + CommandResult::message(format!("Goal complete. Elapsed: {elapsed}")) } HuntVerdict::Wounded => { - CommandResult::message("Hunt wounded — progress saved, can be resumed.") + CommandResult::message("Goal paused. Progress is saved; use /goal resume to continue.") } - HuntVerdict::Escaped => CommandResult::message("Hunt escaped — quarry abandoned."), - HuntVerdict::Hunting => CommandResult::message("Hunt resumed."), + HuntVerdict::Escaped => CommandResult::message("Goal blocked."), + HuntVerdict::Hunting => CommandResult::message("Goal resumed."), } } +fn resume_hunt(app: &mut App) -> CommandResult { + let Some(objective) = app + .hunt + .quarry + .as_deref() + .map(str::trim) + .filter(|objective| !objective.is_empty()) + .map(str::to_string) + else { + return CommandResult::error("No paused goal set. Use /goal first."); + }; + + app.hunt.verdict = HuntVerdict::Hunting; + if app.hunt.started_at.is_none() { + app.hunt.started_at = Some(std::time::Instant::now()); + } + CommandResult::with_message_and_action("Goal resumed.", AppAction::SendMessage(objective)) +} + +fn goal_usage() -> &'static str { + "No goal set. Use /goal [budget: N] to set one.\n\ + /goal complete - mark complete\n\ + /goal pause - pause without continuing\n\ + /goal resume - resume and continue\n\ + /goal blocked - mark blocked\n\ + /goal clear - remove the current goal." +} + /// Parse text like "Implement login | budget: 50000" into (objective, budget). fn parse_hunt_budget(text: &str) -> (String, Option) { if let Some((obj, rest)) = text.split_once(" | budget:") { @@ -126,14 +157,14 @@ fn parse_hunt_budget(text: &str) -> (String, Option) { } } -/// Write a trophy card to `~/.codewhale/trophies/-